mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Merge pull request #23 from pedramamini/leaderboard
Leaderboard Integration with RunMaestro.ai
This commit is contained in:
@@ -136,7 +136,7 @@ Each session shows a color-coded status indicator:
|
||||
- 🟡 **Yellow** - Agent is thinking
|
||||
- 🔴 **Red** - No connection with agent
|
||||
- 🟠 **Pulsing Orange** - Attempting to establish connection
|
||||
- 🟢 **Pulsing Green** - Unread messages (appears next to status dot for non-active sessions)
|
||||
- 🔴 **Red Badge** - Unread messages (small red dot overlapping top-right of status indicator, iPhone-style)
|
||||
|
||||
## Screenshots
|
||||
All these screenshots were captured in the them "Pedurple". For screenshots of other themes, see [THEMES.md](THEMES.md). Also note that these screenshots are probably dated as the project is evolving rapidly.
|
||||
|
||||
10
package.json
10
package.json
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "maestro",
|
||||
"version": "0.7.4",
|
||||
"description": "Multi-Instance AI Coding Console - Unified IDE for managing multiple AI coding assistants",
|
||||
"version": "0.8.0",
|
||||
"description": "Run AI coding agents autonomously for days.",
|
||||
"main": "dist/main/index.js",
|
||||
"author": {
|
||||
"name": "Maestro Team",
|
||||
"email": "maestro@example.com"
|
||||
"name": "Pedram Amini",
|
||||
"email": "pedram@runmaestro.ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"license": "AGPL 3.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/yourusername/maestro.git"
|
||||
|
||||
@@ -278,6 +278,279 @@ ${report.topInfluencers.map(u => `| [@${u.username}](https://github.com/${u.user
|
||||
return md;
|
||||
}
|
||||
|
||||
function generateHtmlDashboard(report, stargazers, forkers, userDetails) {
|
||||
const embeddedData = {
|
||||
report,
|
||||
stargazers,
|
||||
forkers,
|
||||
userDetails: userDetails || {}
|
||||
};
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maestro Community Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.header { text-align: center; margin-bottom: 32px; }
|
||||
.header h1 { font-size: 2.5rem; color: #58a6ff; margin-bottom: 8px; }
|
||||
.header p { color: #8b949e; font-size: 0.9rem; }
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-card .value { font-size: 2.5rem; font-weight: bold; color: #58a6ff; }
|
||||
.stat-card .label { color: #8b949e; margin-top: 4px; }
|
||||
.stat-card.stars .value { color: #f0c14b; }
|
||||
.stat-card.forks .value { color: #a371f7; }
|
||||
.stat-card.users .value { color: #3fb950; }
|
||||
.stat-card.reach .value { color: #ff7b72; }
|
||||
.charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.chart-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
}
|
||||
.chart-card h3 { margin-bottom: 16px; color: #c9d1d9; }
|
||||
.chart-container { position: relative; height: 300px; }
|
||||
.tables-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
.table-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.table-card h3 {
|
||||
margin-bottom: 16px;
|
||||
color: #c9d1d9;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #161b22;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #30363d; }
|
||||
th { color: #8b949e; font-weight: 500; font-size: 0.85rem; }
|
||||
td { font-size: 0.9rem; }
|
||||
tr:hover { background: #1f2428; }
|
||||
.user-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; }
|
||||
a { color: #58a6ff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge.starred { background: #f0c14b22; color: #f0c14b; }
|
||||
.badge.forked { background: #a371f722; color: #a371f7; }
|
||||
.badge.both { background: #3fb95022; color: #3fb950; }
|
||||
.location-bar { display: flex; align-items: center; margin-bottom: 8px; }
|
||||
.location-bar .name {
|
||||
width: 150px;
|
||||
font-size: 0.85rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.location-bar .bar {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: #30363d;
|
||||
border-radius: 4px;
|
||||
margin: 0 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.location-bar .bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #58a6ff, #a371f7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.location-bar .count { width: 30px; text-align: right; font-size: 0.85rem; color: #8b949e; }
|
||||
@media (max-width: 600px) {
|
||||
.charts-grid, .tables-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Maestro Community Dashboard</h1>
|
||||
<p id="generated-at"></p>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card stars"><div class="value" id="total-stars">-</div><div class="label">Stars</div></div>
|
||||
<div class="stat-card forks"><div class="value" id="total-forks">-</div><div class="label">Forks</div></div>
|
||||
<div class="stat-card users"><div class="value" id="total-users">-</div><div class="label">Unique Members</div></div>
|
||||
<div class="stat-card reach"><div class="value" id="total-reach">-</div><div class="label">Community Reach</div></div>
|
||||
</div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card"><h3>Star Growth Over Time</h3><div class="chart-container"><canvas id="star-chart"></canvas></div></div>
|
||||
<div class="chart-card"><h3>Fork Growth Over Time</h3><div class="chart-container"><canvas id="fork-chart"></canvas></div></div>
|
||||
</div>
|
||||
<div class="charts-grid">
|
||||
<div class="chart-card"><h3>Top Locations</h3><div id="locations-chart"></div></div>
|
||||
<div class="chart-card"><h3>Top Companies</h3><div id="companies-chart"></div></div>
|
||||
</div>
|
||||
<div class="tables-grid">
|
||||
<div class="table-card"><h3>Top Influencers</h3><table id="influencers-table"><thead><tr><th>User</th><th>Company</th><th>Followers</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="table-card"><h3>Recent Activity</h3><table id="activity-table"><thead><tr><th>User</th><th>Action</th><th>Date</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="table-card"><h3>Highly Engaged (Starred + Forked)</h3><table id="engaged-table"><thead><tr><th>User</th><th>Location</th><th>Followers</th></tr></thead><tbody></tbody></table></div>
|
||||
<div class="table-card"><h3>All Community Members</h3><table id="members-table"><thead><tr><th>User</th><th>Status</th><th>Joined GitHub</th></tr></thead><tbody></tbody></table></div>
|
||||
</div>
|
||||
<script>
|
||||
const DATA = ${JSON.stringify(embeddedData)};
|
||||
Chart.defaults.color = '#8b949e';
|
||||
Chart.defaults.borderColor = '#30363d';
|
||||
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
|
||||
return num.toString();
|
||||
}
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return 'N/A';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
function escapeText(str) { return str ? String(str) : ''; }
|
||||
function createLink(href, text) {
|
||||
const a = document.createElement('a');
|
||||
a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer'; a.textContent = text;
|
||||
return a;
|
||||
}
|
||||
function createBadge(type) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'badge ' + type; span.textContent = type;
|
||||
return span;
|
||||
}
|
||||
function createGrowthChart(canvasId, data, color) {
|
||||
new Chart(document.getElementById(canvasId).getContext('2d'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.map(d => d.date),
|
||||
datasets: [{ label: 'Cumulative', data: data.map(d => d.cumulative), borderColor: color, backgroundColor: color + '22', fill: true, tension: 0.3, pointRadius: 3, pointHoverRadius: 6 }]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { grid: { display: false }, ticks: { maxTicksLimit: 10 } }, y: { beginAtZero: true, grid: { color: '#30363d' } } } }
|
||||
});
|
||||
}
|
||||
function createBarChart(containerId, data, maxItems = 10) {
|
||||
const container = document.getElementById(containerId);
|
||||
container.replaceChildren();
|
||||
const maxCount = Math.max(...data.slice(0, maxItems).map(d => d[1]));
|
||||
data.slice(0, maxItems).forEach(([name, count]) => {
|
||||
const row = document.createElement('div'); row.className = 'location-bar';
|
||||
const nameDiv = document.createElement('div'); nameDiv.className = 'name'; nameDiv.title = escapeText(name); nameDiv.textContent = escapeText(name);
|
||||
const barDiv = document.createElement('div'); barDiv.className = 'bar';
|
||||
const barFill = document.createElement('div'); barFill.className = 'bar-fill'; barFill.style.width = (count / maxCount * 100) + '%';
|
||||
barDiv.appendChild(barFill);
|
||||
const countDiv = document.createElement('div'); countDiv.className = 'count'; countDiv.textContent = count;
|
||||
row.appendChild(nameDiv); row.appendChild(barDiv); row.appendChild(countDiv);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
function populateTable(tableId, rows, cellFn) {
|
||||
const tbody = document.querySelector('#' + tableId + ' tbody');
|
||||
tbody.replaceChildren();
|
||||
rows.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
cellFn(row).forEach(cell => tr.appendChild(cell));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
(function init() {
|
||||
const { report, stargazers, forkers, userDetails } = DATA;
|
||||
document.getElementById('generated-at').textContent = 'Last updated: ' + formatDate(report.generatedAt);
|
||||
document.getElementById('total-stars').textContent = report.summary.totalStars;
|
||||
document.getElementById('total-forks').textContent = report.summary.totalForks;
|
||||
document.getElementById('total-users').textContent = report.summary.uniqueUsers;
|
||||
document.getElementById('total-reach').textContent = formatNumber(report.demographics?.totalCommunityFollowers || 0);
|
||||
|
||||
createGrowthChart('star-chart', report.starGrowth, '#f0c14b');
|
||||
createGrowthChart('fork-chart', report.forkGrowth, '#a371f7');
|
||||
|
||||
if (report.demographics) {
|
||||
createBarChart('locations-chart', report.demographics.topLocations);
|
||||
createBarChart('companies-chart', report.demographics.topCompanies);
|
||||
}
|
||||
|
||||
populateTable('influencers-table', report.topInfluencers || [], u => {
|
||||
const td1 = document.createElement('td'); td1.appendChild(createLink('https://github.com/' + u.username, '@' + u.username));
|
||||
const td2 = document.createElement('td'); td2.textContent = escapeText(u.company) || '-';
|
||||
const td3 = document.createElement('td'); td3.textContent = formatNumber(u.followers);
|
||||
return [td1, td2, td3];
|
||||
});
|
||||
|
||||
const activity = [...stargazers.map(s => ({ ...s, action: 'starred', date: s.starredAt })), ...forkers.map(f => ({ ...f, action: 'forked', date: f.forkedAt }))].sort((a, b) => new Date(b.date) - new Date(a.date)).slice(0, 20);
|
||||
populateTable('activity-table', activity, a => {
|
||||
const td1 = document.createElement('td'); td1.className = 'user-cell';
|
||||
const img = document.createElement('img'); img.className = 'avatar'; img.src = a.avatarUrl; img.alt = '';
|
||||
td1.appendChild(img); td1.appendChild(createLink(a.profileUrl, '@' + a.username));
|
||||
const td2 = document.createElement('td'); td2.appendChild(createBadge(a.action));
|
||||
const td3 = document.createElement('td'); td3.textContent = formatDate(a.date);
|
||||
return [td1, td2, td3];
|
||||
});
|
||||
|
||||
populateTable('engaged-table', report.engagedUsers || [], username => {
|
||||
const details = userDetails[username] || {};
|
||||
const td1 = document.createElement('td'); td1.appendChild(createLink('https://github.com/' + username, '@' + username));
|
||||
const td2 = document.createElement('td'); td2.textContent = escapeText(details.location) || '-';
|
||||
const td3 = document.createElement('td'); td3.textContent = formatNumber(details.followers || 0);
|
||||
return [td1, td2, td3];
|
||||
});
|
||||
|
||||
const starSet = new Set(stargazers.map(s => s.username));
|
||||
const forkSet = new Set(forkers.map(f => f.username));
|
||||
const allMembers = [...new Set([...starSet, ...forkSet])].sort();
|
||||
populateTable('members-table', allMembers, username => {
|
||||
const details = userDetails[username] || {};
|
||||
const starred = starSet.has(username), forked = forkSet.has(username);
|
||||
const td1 = document.createElement('td'); td1.appendChild(createLink('https://github.com/' + username, '@' + username));
|
||||
const td2 = document.createElement('td');
|
||||
if (starred && forked) td2.appendChild(createBadge('both'));
|
||||
else if (starred) td2.appendChild(createBadge('starred'));
|
||||
else if (forked) td2.appendChild(createBadge('forked'));
|
||||
const td3 = document.createElement('td'); td3.textContent = details.createdAt ? formatDate(details.createdAt) : '-';
|
||||
return [td1, td2, td3];
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const fetchDetails = args.includes('--fetch-details');
|
||||
@@ -351,6 +624,13 @@ async function main() {
|
||||
markdown
|
||||
);
|
||||
|
||||
// Generate self-contained HTML dashboard
|
||||
const htmlDashboard = generateHtmlDashboard(report, stargazers, forkers, userDetails);
|
||||
fs.writeFileSync(
|
||||
path.join(OUTPUT_DIR, 'index.html'),
|
||||
htmlDashboard
|
||||
);
|
||||
|
||||
if (jsonOutput) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
} else {
|
||||
|
||||
@@ -29,6 +29,12 @@ vi.mock('lucide-react', () => ({
|
||||
Loader2: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="loader-icon" className={className} style={style}>⏳</span>
|
||||
),
|
||||
Trophy: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="trophy-icon" className={className} style={style}>🏆</span>
|
||||
),
|
||||
Globe: ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<span data-testid="globe-icon" className={className} style={style}>🌐</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the avatar import
|
||||
@@ -326,9 +332,10 @@ describe('AboutModal', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// The component renders "GitHub" as the button text in author section
|
||||
// Use getByText since there are multiple GitHub buttons
|
||||
expect(screen.getByText('GitHub')).toBeInTheDocument();
|
||||
// The component renders "GitHub" twice - author section and project link
|
||||
// Use getAllByText since there are multiple GitHub buttons
|
||||
const githubLinks = screen.getAllByText('GitHub');
|
||||
expect(githubLinks.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should have LinkedIn profile link', () => {
|
||||
@@ -357,9 +364,9 @@ describe('AboutModal', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// The component renders "GitHub" as the button text - use getByText and find parent button
|
||||
const githubLink = screen.getByText('GitHub');
|
||||
fireEvent.click(githubLink);
|
||||
// The component renders "GitHub" twice - first one is the author profile link
|
||||
const githubLinks = screen.getAllByText('GitHub');
|
||||
fireEvent.click(githubLinks[0]);
|
||||
|
||||
expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini');
|
||||
});
|
||||
@@ -381,7 +388,7 @@ describe('AboutModal', () => {
|
||||
expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://www.linkedin.com/in/pedramamini/');
|
||||
});
|
||||
|
||||
it('should open GitHub repo on View on GitHub click', async () => {
|
||||
it('should open GitHub repo on project GitHub click', async () => {
|
||||
render(
|
||||
<AboutModal
|
||||
theme={theme}
|
||||
@@ -391,8 +398,9 @@ describe('AboutModal', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const viewOnGitHub = screen.getByText('View on GitHub');
|
||||
fireEvent.click(viewOnGitHub);
|
||||
// The component renders "GitHub" twice - second one is the project repo link
|
||||
const githubLinks = screen.getAllByText('GitHub');
|
||||
fireEvent.click(githubLinks[1]);
|
||||
|
||||
expect(window.maestro.shell.openExternal).toHaveBeenCalledWith('https://github.com/pedramamini/Maestro');
|
||||
});
|
||||
|
||||
@@ -1506,28 +1506,14 @@ describe('AgentSessionsBrowser', () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
// First Escape closes search panel (component defaults to search panel view)
|
||||
// Escape should close modal directly (search panel no longer intercepts Escape)
|
||||
await act(async () => {
|
||||
const escapeEvent1 = new KeyboardEvent('keydown', {
|
||||
const escapeEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(escapeEvent1);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
// First Escape should NOT close modal (closes search panel instead)
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Second Escape should close modal
|
||||
await act(async () => {
|
||||
const escapeEvent2 = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
window.dispatchEvent(escapeEvent2);
|
||||
window.dispatchEvent(escapeEvent);
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
|
||||
|
||||
@@ -2233,4 +2233,67 @@ describe('Auto-save Cleanup', () => {
|
||||
const doc1SaveCalls = calls.filter((call: any[]) => call[1] === 'doc1.md' && call[2] === 'Changed content');
|
||||
expect(doc1SaveCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should re-render when hideTopControls changes (memo regression test)', async () => {
|
||||
// This test ensures AutoRun re-renders when hideTopControls prop changes
|
||||
// A previous bug had the memo comparator missing hideTopControls
|
||||
// hideTopControls affects the top control bar visibility when folderPath is set
|
||||
const props = createDefaultProps({ hideTopControls: false, folderPath: '/test/folder' });
|
||||
const { rerender, container } = render(<AutoRun {...props} />);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
// Get elements that are controlled by hideTopControls
|
||||
// The control bar with mode buttons should be visible
|
||||
const controlElements = container.querySelectorAll('button');
|
||||
const initialButtonCount = controlElements.length;
|
||||
|
||||
// Rerender with hideTopControls=true
|
||||
rerender(<AutoRun {...createDefaultProps({ hideTopControls: true, folderPath: '/test/folder' })} />);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
// With hideTopControls=true, the top control bar should be hidden
|
||||
// which means fewer buttons should be visible
|
||||
const updatedControlElements = container.querySelectorAll('button');
|
||||
// The component should have re-rendered and hidden the top controls
|
||||
expect(updatedControlElements.length).toBeLessThan(initialButtonCount);
|
||||
});
|
||||
|
||||
it('should re-render when contentVersion changes (memo regression test)', async () => {
|
||||
// This test ensures AutoRun re-renders when contentVersion changes
|
||||
// contentVersion is used to force-sync on external file changes
|
||||
const onContentChange = vi.fn();
|
||||
const props = createDefaultProps({
|
||||
content: 'Original content',
|
||||
contentVersion: 1,
|
||||
onContentChange,
|
||||
});
|
||||
|
||||
const { rerender } = render(<AutoRun {...props} />);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
// Now simulate an external file change by updating content and contentVersion
|
||||
rerender(<AutoRun {...createDefaultProps({
|
||||
content: 'Externally modified content',
|
||||
contentVersion: 2,
|
||||
onContentChange,
|
||||
})} />);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
// The component should have re-rendered with the new content
|
||||
// In edit mode, check the textarea value
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toHaveValue('Externally modified content');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,8 +148,8 @@ describe('FilePreview', () => {
|
||||
file={null}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -168,8 +168,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -184,8 +184,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'const x = 42;' })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -201,8 +201,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -217,8 +217,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -239,8 +239,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -261,8 +261,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -278,8 +278,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'a'.repeat(1000) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -295,8 +295,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -315,8 +315,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -336,8 +336,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -356,8 +356,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -376,8 +376,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'const x = 42;' })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -396,8 +396,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -424,8 +424,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={onClose}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -456,13 +456,13 @@ describe('FilePreview', () => {
|
||||
file={markdownFile}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleBtn = screen.getByTitle(/Show raw markdown|Show rendered markdown/);
|
||||
const toggleBtn = screen.getByTitle(/Edit file|Show preview/);
|
||||
expect(toggleBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -472,8 +472,8 @@ describe('FilePreview', () => {
|
||||
file={markdownFile}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -481,20 +481,23 @@ describe('FilePreview', () => {
|
||||
expect(screen.getByTestId('react-markdown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders raw markdown when markdownRawMode is true', () => {
|
||||
it('renders textarea editor when markdownEditMode is true', () => {
|
||||
render(
|
||||
<FilePreview
|
||||
file={markdownFile}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={true}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={true}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('react-markdown')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/# Hello World/)).toBeInTheDocument();
|
||||
// In edit mode, we show a textarea instead of raw text
|
||||
const textarea = screen.getByRole('textbox');
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(textarea).toHaveValue('# Hello World\n\nThis is a test.');
|
||||
});
|
||||
|
||||
it('toggles markdown mode when button is clicked', async () => {
|
||||
@@ -503,13 +506,13 @@ describe('FilePreview', () => {
|
||||
file={markdownFile}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
|
||||
const toggleBtn = screen.getByTitle(/Show raw markdown/);
|
||||
const toggleBtn = screen.getByTitle(/Edit file/);
|
||||
fireEvent.click(toggleBtn);
|
||||
|
||||
expect(mockSetMarkdownRawMode).toHaveBeenCalledWith(true);
|
||||
@@ -521,13 +524,13 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTitle(/Show raw markdown/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/Edit file/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle(/Show rendered markdown/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -549,8 +552,8 @@ describe('FilePreview', () => {
|
||||
file={imageFile}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -566,8 +569,8 @@ describe('FilePreview', () => {
|
||||
file={imageFile}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -581,8 +584,8 @@ describe('FilePreview', () => {
|
||||
file={imageFile}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -603,8 +606,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -657,8 +660,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -678,8 +681,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -700,8 +703,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -720,8 +723,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -741,8 +744,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -770,8 +773,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'hello world' })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -803,8 +806,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={true}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={true}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -836,8 +839,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={true}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={true}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -872,8 +875,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'line\n'.repeat(100) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -890,8 +893,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'line\n'.repeat(100) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -908,8 +911,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -926,8 +929,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -948,8 +951,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -974,8 +977,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={onClose}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -993,8 +996,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1026,8 +1029,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1049,8 +1052,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1072,8 +1075,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1095,8 +1098,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1119,8 +1122,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'test content' })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1137,8 +1140,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'a'.repeat(100) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1158,8 +1161,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1188,8 +1191,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1208,8 +1211,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1229,8 +1232,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1256,8 +1259,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1272,8 +1275,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: '' })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1293,8 +1296,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1312,8 +1315,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1329,8 +1332,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1344,8 +1347,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={{}}
|
||||
/>
|
||||
);
|
||||
@@ -1370,8 +1373,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1399,8 +1402,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1426,8 +1429,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1456,8 +1459,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={true}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={true}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1495,8 +1498,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={true}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={true}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1540,8 +1543,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={true}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={true}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1579,8 +1582,8 @@ describe('FilePreview', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={true}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={true}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1620,8 +1623,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'line\n'.repeat(100) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1638,8 +1641,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'line\n'.repeat(100) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1656,8 +1659,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'line\n'.repeat(100) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1674,8 +1677,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'line\n'.repeat(100) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1692,8 +1695,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile({ content: 'line\n'.repeat(100) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1716,8 +1719,8 @@ describe('FilePreview', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={mockSetMarkdownRawMode}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={mockSetMarkdownRawMode}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1773,8 +1776,8 @@ describe('FilePreview utility functions', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1804,8 +1807,8 @@ describe('FilePreview utility functions', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1825,8 +1828,8 @@ describe('FilePreview utility functions', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1855,8 +1858,8 @@ describe('MarkdownImage component', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1877,8 +1880,8 @@ describe('MarkdownImage component', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1898,8 +1901,8 @@ describe('MarkdownImage component', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1928,8 +1931,8 @@ describe('Markdown link interactions', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -1956,8 +1959,8 @@ describe('Stats bar scroll behavior', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -2000,8 +2003,8 @@ describe('Markdown highlight syntax', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -2030,8 +2033,8 @@ describe('Markdown code blocks', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -2052,8 +2055,8 @@ describe('Markdown code blocks', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -2078,8 +2081,8 @@ describe('Token count formatting', () => {
|
||||
file={createMockFile({ content: 'x'.repeat(1000) })}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -2106,8 +2109,8 @@ describe('Search in markdown with highlighting', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={true}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={true}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -2140,8 +2143,8 @@ describe('Search in markdown with highlighting', () => {
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
@@ -2183,8 +2186,8 @@ describe('Gigabyte file size formatting', () => {
|
||||
file={createMockFile()}
|
||||
onClose={vi.fn()}
|
||||
theme={createMockTheme()}
|
||||
markdownRawMode={false}
|
||||
setMarkdownRawMode={vi.fn()}
|
||||
markdownEditMode={false}
|
||||
setMarkdownEditMode={vi.fn()}
|
||||
shortcuts={createMockShortcuts()}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('MainPanel', () => {
|
||||
slashCommands: [],
|
||||
selectedSlashCommandIndex: 0,
|
||||
previewFile: null,
|
||||
markdownRawMode: false,
|
||||
markdownEditMode: false,
|
||||
shortcuts: defaultShortcuts,
|
||||
rightPanelOpen: true,
|
||||
maxOutputLines: 1000,
|
||||
@@ -215,7 +215,7 @@ describe('MainPanel', () => {
|
||||
setSlashCommandOpen: vi.fn(),
|
||||
setSelectedSlashCommandIndex: vi.fn(),
|
||||
setPreviewFile: vi.fn(),
|
||||
setMarkdownRawMode: vi.fn(),
|
||||
setMarkdownEditMode: vi.fn(),
|
||||
setAboutModalOpen: vi.fn(),
|
||||
setRightPanelOpen: vi.fn(),
|
||||
setGitLogOpen: vi.fn(),
|
||||
|
||||
@@ -1043,11 +1043,11 @@ describe('QuickActionsModal', () => {
|
||||
|
||||
describe('Markdown toggle (AI mode)', () => {
|
||||
it('shows Show Formatted Markdown when raw mode is on', () => {
|
||||
const onToggleMarkdownRawMode = vi.fn();
|
||||
const onToggleMarkdownEditMode = vi.fn();
|
||||
const props = createDefaultProps({
|
||||
isAiMode: true,
|
||||
markdownRawMode: true,
|
||||
onToggleMarkdownRawMode,
|
||||
markdownEditMode: true,
|
||||
onToggleMarkdownEditMode,
|
||||
});
|
||||
render(<QuickActionsModal {...props} />);
|
||||
|
||||
@@ -1056,11 +1056,11 @@ describe('QuickActionsModal', () => {
|
||||
});
|
||||
|
||||
it('shows Show Raw Markdown when formatted mode is on', () => {
|
||||
const onToggleMarkdownRawMode = vi.fn();
|
||||
const onToggleMarkdownEditMode = vi.fn();
|
||||
const props = createDefaultProps({
|
||||
isAiMode: true,
|
||||
markdownRawMode: false,
|
||||
onToggleMarkdownRawMode,
|
||||
markdownEditMode: false,
|
||||
onToggleMarkdownEditMode,
|
||||
});
|
||||
render(<QuickActionsModal {...props} />);
|
||||
|
||||
@@ -1069,16 +1069,16 @@ describe('QuickActionsModal', () => {
|
||||
});
|
||||
|
||||
it('handles markdown toggle action', () => {
|
||||
const onToggleMarkdownRawMode = vi.fn();
|
||||
const onToggleMarkdownEditMode = vi.fn();
|
||||
const props = createDefaultProps({
|
||||
isAiMode: true,
|
||||
onToggleMarkdownRawMode,
|
||||
onToggleMarkdownEditMode,
|
||||
});
|
||||
render(<QuickActionsModal {...props} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Show Raw Markdown'));
|
||||
|
||||
expect(onToggleMarkdownRawMode).toHaveBeenCalled();
|
||||
expect(onToggleMarkdownEditMode).toHaveBeenCalled();
|
||||
expect(props.setQuickActionOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,8 @@ vi.mock('lucide-react', () => ({
|
||||
Edit3: () => <span data-testid="icon-edit" />,
|
||||
FolderInput: () => <span data-testid="icon-folder-input" />,
|
||||
Download: () => <span data-testid="icon-download" />,
|
||||
Compass: () => <span data-testid="icon-compass" />,
|
||||
Globe: () => <span data-testid="icon-globe" />,
|
||||
}));
|
||||
|
||||
// Mock gitService
|
||||
|
||||
@@ -1054,6 +1054,30 @@ describe('SettingsModal', () => {
|
||||
expect(setOsNotificationsEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should update checkbox state when prop changes (regression test for memo bug)', async () => {
|
||||
// This test ensures the component re-renders when props change
|
||||
// A previous bug had an overly restrictive memo comparator that prevented re-renders
|
||||
const { rerender } = render(<SettingsModal {...createDefaultProps({ initialTab: 'notifications', osNotificationsEnabled: true })} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
// Verify initial checked state
|
||||
const checkbox = screen.getByText('Enable OS Notifications').closest('label')?.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
|
||||
// Rerender with changed prop (simulating what happens after onChange)
|
||||
rerender(<SettingsModal {...createDefaultProps({ initialTab: 'notifications', osNotificationsEnabled: false })} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
|
||||
// The checkbox should now be unchecked - this would fail with the old memo comparator
|
||||
expect(checkbox.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('should test notification when button is clicked', async () => {
|
||||
render(<SettingsModal {...createDefaultProps({ initialTab: 'notifications' })} />);
|
||||
|
||||
@@ -1137,6 +1161,9 @@ describe('SettingsModal', () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Off' }));
|
||||
expect(setToastDuration).toHaveBeenCalledWith(-1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '5s' }));
|
||||
expect(setToastDuration).toHaveBeenCalledWith(5);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { Theme, AITab } from '../../../renderer/types';
|
||||
// Mock lucide-react
|
||||
vi.mock('lucide-react', () => ({
|
||||
Search: () => <svg data-testid="search-icon" />,
|
||||
Star: () => <svg data-testid="star-icon" />,
|
||||
}));
|
||||
|
||||
// Create a test theme
|
||||
@@ -994,6 +995,145 @@ describe('TabSwitcherModal', () => {
|
||||
expect(screen.queryByText('Different Project Session')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to Starred mode on pill click', async () => {
|
||||
const starredTab = createTestTab({ name: 'Starred Tab', starred: true });
|
||||
const unstarredTab = createTestTab({ name: 'Unstarred Tab', starred: false });
|
||||
|
||||
vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([
|
||||
{
|
||||
claudeSessionId: 'starred-closed-123',
|
||||
projectPath: '/test',
|
||||
sessionName: 'Starred Closed Session',
|
||||
starred: true,
|
||||
},
|
||||
{
|
||||
claudeSessionId: 'unstarred-closed-456',
|
||||
projectPath: '/test',
|
||||
sessionName: 'Unstarred Closed Session',
|
||||
starred: false,
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithLayerStack(
|
||||
<TabSwitcherModal
|
||||
theme={theme}
|
||||
tabs={[starredTab, unstarredTab]}
|
||||
activeTabId={starredTab.id}
|
||||
projectRoot="/test"
|
||||
onTabSelect={vi.fn()}
|
||||
onNamedSessionSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click Starred pill (use exact pattern to avoid matching list items)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Starred \(\d+\)/ }));
|
||||
|
||||
// Should show only starred items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Starred Tab')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Unstarred Tab')).not.toBeInTheDocument();
|
||||
// Closed starred session should also appear
|
||||
expect(screen.getByText('Starred Closed Session')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Unstarred Closed Session')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Placeholder should indicate starred mode
|
||||
expect(screen.getByPlaceholderText('Search starred sessions...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "No starred sessions" when there are no starred items', async () => {
|
||||
vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([]);
|
||||
|
||||
renderWithLayerStack(
|
||||
<TabSwitcherModal
|
||||
theme={theme}
|
||||
tabs={[createTestTab({ name: 'Unstarred Tab', starred: false })]}
|
||||
activeTabId=""
|
||||
projectRoot="/test"
|
||||
onTabSelect={vi.fn()}
|
||||
onNamedSessionSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Starred \(\d+\)/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No starred sessions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct count for Starred pill', async () => {
|
||||
const starredTab1 = createTestTab({ name: 'Starred 1', starred: true });
|
||||
const starredTab2 = createTestTab({ name: 'Starred 2', starred: true });
|
||||
const unstarredTab = createTestTab({ name: 'Unstarred', starred: false });
|
||||
|
||||
vi.mocked(window.maestro.claude.getAllNamedSessions).mockResolvedValue([
|
||||
{
|
||||
claudeSessionId: 'starred-closed-abc',
|
||||
projectPath: '/test',
|
||||
sessionName: 'Starred Closed',
|
||||
starred: true,
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithLayerStack(
|
||||
<TabSwitcherModal
|
||||
theme={theme}
|
||||
tabs={[starredTab1, starredTab2, unstarredTab]}
|
||||
activeTabId={starredTab1.id}
|
||||
projectRoot="/test"
|
||||
onTabSelect={vi.fn()}
|
||||
onNamedSessionSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.maestro.claude.getAllNamedSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show count of 3: 2 open starred + 1 closed starred
|
||||
expect(screen.getByText(/Starred \(3\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cycles through all three modes with Tab key', async () => {
|
||||
renderWithLayerStack(
|
||||
<TabSwitcherModal
|
||||
theme={theme}
|
||||
tabs={[createTestTab({ name: 'Test Tab' })]}
|
||||
activeTabId=""
|
||||
projectRoot="/test"
|
||||
onTabSelect={vi.fn()}
|
||||
onNamedSessionSelect={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search open tabs...');
|
||||
|
||||
// Tab 1: open -> all-named
|
||||
fireEvent.keyDown(input, { key: 'Tab' });
|
||||
expect(screen.getByPlaceholderText('Search named sessions...')).toBeInTheDocument();
|
||||
|
||||
// Tab 2: all-named -> starred
|
||||
fireEvent.keyDown(input, { key: 'Tab' });
|
||||
expect(screen.getByPlaceholderText('Search starred sessions...')).toBeInTheDocument();
|
||||
|
||||
// Tab 3: starred -> open
|
||||
fireEvent.keyDown(input, { key: 'Tab' });
|
||||
expect(screen.getByPlaceholderText('Search open tabs...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('search functionality', () => {
|
||||
@@ -1804,12 +1944,13 @@ describe('TabSwitcherModal', () => {
|
||||
|
||||
const input = screen.getByPlaceholderText('Search open tabs...');
|
||||
|
||||
// Rapid Tab key presses
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Rapid Tab key presses - cycles through 3 modes: open -> all-named -> starred -> open
|
||||
// 9 presses = 9 mod 3 = 0, so we end up back at open tabs
|
||||
for (let i = 0; i < 9; i++) {
|
||||
fireEvent.keyDown(input, { key: 'Tab' });
|
||||
}
|
||||
|
||||
// Should be back to open tabs (even number of switches)
|
||||
// Should be back to open tabs (multiple of 3 switches)
|
||||
expect(screen.getByPlaceholderText('Search open tabs...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -135,8 +135,8 @@ const createDefaultProps = (overrides: Partial<React.ComponentProps<typeof Termi
|
||||
inputRef: { current: null } as React.RefObject<HTMLTextAreaElement>,
|
||||
logsEndRef: { current: null } as React.RefObject<HTMLDivElement>,
|
||||
maxOutputLines: 50,
|
||||
markdownRawMode: false,
|
||||
setMarkdownRawMode: vi.fn(),
|
||||
markdownEditMode: false,
|
||||
setMarkdownEditMode: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -1053,7 +1053,7 @@ describe('TerminalOutput', () => {
|
||||
|
||||
const props = createDefaultProps({
|
||||
session,
|
||||
markdownRawMode: false,
|
||||
markdownEditMode: false,
|
||||
});
|
||||
|
||||
render(<TerminalOutput {...props} />);
|
||||
@@ -1061,8 +1061,8 @@ describe('TerminalOutput', () => {
|
||||
expect(screen.getByTitle(/Show plain text/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls setMarkdownRawMode when toggle is clicked', async () => {
|
||||
const setMarkdownRawMode = vi.fn();
|
||||
it('calls setMarkdownEditMode when toggle is clicked', async () => {
|
||||
const setMarkdownEditMode = vi.fn();
|
||||
const logs: LogEntry[] = [
|
||||
createLogEntry({ text: '# Heading', source: 'stdout' }),
|
||||
];
|
||||
@@ -1074,8 +1074,8 @@ describe('TerminalOutput', () => {
|
||||
|
||||
const props = createDefaultProps({
|
||||
session,
|
||||
markdownRawMode: false,
|
||||
setMarkdownRawMode,
|
||||
markdownEditMode: false,
|
||||
setMarkdownEditMode,
|
||||
});
|
||||
|
||||
render(<TerminalOutput {...props} />);
|
||||
@@ -1085,7 +1085,7 @@ describe('TerminalOutput', () => {
|
||||
fireEvent.click(toggleButton);
|
||||
});
|
||||
|
||||
expect(setMarkdownRawMode).toHaveBeenCalledWith(true);
|
||||
expect(setMarkdownEditMode).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1627,7 +1627,7 @@ describe('helper function behaviors (tested via component)', () => {
|
||||
|
||||
const props = createDefaultProps({
|
||||
session,
|
||||
markdownRawMode: true,
|
||||
markdownEditMode: true,
|
||||
});
|
||||
|
||||
render(<TerminalOutput {...props} />);
|
||||
@@ -1649,7 +1649,7 @@ describe('helper function behaviors (tested via component)', () => {
|
||||
|
||||
const props = createDefaultProps({
|
||||
session,
|
||||
markdownRawMode: true,
|
||||
markdownEditMode: true,
|
||||
});
|
||||
|
||||
render(<TerminalOutput {...props} />);
|
||||
@@ -1663,6 +1663,11 @@ describe('helper function behaviors (tested via component)', () => {
|
||||
describe('memoization behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('LogItemComponent has stable rendering with same props', () => {
|
||||
@@ -1684,4 +1689,45 @@ describe('memoization behavior', () => {
|
||||
// If memo works correctly, this shouldn't cause issues
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should re-render log items when fontFamily changes (memo regression test)', async () => {
|
||||
// This test ensures LogItemComponent re-renders when fontFamily prop changes
|
||||
// A previous bug had the memo comparator missing fontFamily, preventing visual updates
|
||||
const logs: LogEntry[] = [
|
||||
createLogEntry({ id: 'log-1', text: 'Test log content', source: 'stdout' }),
|
||||
];
|
||||
|
||||
const session = createDefaultSession({
|
||||
tabs: [{ id: 'tab-1', claudeSessionId: 'claude-123', logs, isUnread: false }],
|
||||
activeTabId: 'tab-1',
|
||||
});
|
||||
|
||||
const props = createDefaultProps({ session, fontFamily: 'Courier New' });
|
||||
const { rerender, container } = render(<TerminalOutput {...props} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
|
||||
// Find an element with fontFamily styling
|
||||
const styledElements = container.querySelectorAll('[style*="font-family"]');
|
||||
const hasOldFont = Array.from(styledElements).some(el =>
|
||||
(el as HTMLElement).style.fontFamily.includes('Courier New')
|
||||
);
|
||||
expect(hasOldFont).toBe(true);
|
||||
|
||||
// Rerender with different fontFamily
|
||||
rerender(<TerminalOutput {...createDefaultProps({ session, fontFamily: 'Monaco' })} />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
|
||||
// The log items should now use the new font
|
||||
const updatedElements = container.querySelectorAll('[style*="font-family"]');
|
||||
const hasNewFont = Array.from(updatedElements).some(el =>
|
||||
(el as HTMLElement).style.fontFamily.includes('Monaco')
|
||||
);
|
||||
expect(hasNewFont).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1379,4 +1379,108 @@ describe('ThinkingStatusPill', () => {
|
||||
expect(ThinkingStatusPill.displayName).toBe('ThinkingStatusPill');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memo regression tests', () => {
|
||||
it('should re-render when theme changes', () => {
|
||||
// This test ensures the memo comparator includes theme
|
||||
const thinkingSession = createThinkingSession();
|
||||
const { rerender, container } = render(
|
||||
<ThinkingStatusPill
|
||||
sessions={[thinkingSession]}
|
||||
theme={mockTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
// Capture initial text color from theme
|
||||
const pill = container.firstChild as HTMLElement;
|
||||
expect(pill).toBeTruthy();
|
||||
|
||||
// Rerender with different theme
|
||||
const newTheme = {
|
||||
...mockTheme,
|
||||
colors: {
|
||||
...mockTheme.colors,
|
||||
textMain: '#ff0000', // Different text color
|
||||
},
|
||||
};
|
||||
|
||||
rerender(
|
||||
<ThinkingStatusPill
|
||||
sessions={[thinkingSession]}
|
||||
theme={newTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
// Component should have re-rendered with new theme
|
||||
// This test would fail if theme was missing from memo comparator
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should re-render when autoRunState changes', () => {
|
||||
// This test ensures the memo comparator handles autoRunState correctly
|
||||
const idleSession = createMockSession();
|
||||
|
||||
// Start without AutoRun
|
||||
const { rerender } = render(
|
||||
<ThinkingStatusPill
|
||||
sessions={[idleSession]}
|
||||
theme={mockTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should not show anything when no busy sessions and no autoRun
|
||||
expect(screen.queryByText(/thinking/i)).not.toBeInTheDocument();
|
||||
|
||||
// Add autoRunState
|
||||
const autoRunState: BatchRunState = {
|
||||
isRunning: true,
|
||||
isStopping: false,
|
||||
totalTasks: 5,
|
||||
currentTaskIndex: 2,
|
||||
startTime: Date.now(),
|
||||
completedTasks: 3, // This is what gets displayed as "3/5"
|
||||
};
|
||||
|
||||
rerender(
|
||||
<ThinkingStatusPill
|
||||
sessions={[idleSession]}
|
||||
theme={mockTheme}
|
||||
autoRunState={autoRunState}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should now show the AutoRun pill with completedTasks/totalTasks
|
||||
expect(screen.getByText('3/5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should re-render when namedSessions mapping changes', () => {
|
||||
// This test ensures the memo comparator handles namedSessions correctly
|
||||
const thinkingSession = createThinkingSession({
|
||||
claudeSessionId: 'claude-abc123',
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<ThinkingStatusPill
|
||||
sessions={[thinkingSession]}
|
||||
theme={mockTheme}
|
||||
namedSessions={{}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Session name should be the default (may appear in multiple places due to tooltip)
|
||||
expect(screen.getAllByText('Test Session').length).toBeGreaterThan(0);
|
||||
|
||||
// Update namedSessions with a custom name for this Claude session
|
||||
rerender(
|
||||
<ThinkingStatusPill
|
||||
sessions={[thinkingSession]}
|
||||
theme={mockTheme}
|
||||
namedSessions={{ 'claude-abc123': 'Custom Named Session' }}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should now show the custom name
|
||||
expect(screen.getAllByText('Custom Named Session').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -177,6 +177,33 @@ describe('ToastContext', () => {
|
||||
expect(contextValue!.toasts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('duration of -1 disables toast UI but still logs and notifies', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
let contextValue: ReturnType<typeof useToast> | null = null;
|
||||
|
||||
renderWithProvider(
|
||||
<ToastConsumer onMount={(ctx) => { contextValue = ctx; }} />,
|
||||
{ defaultDuration: -1 } // -1 = toasts disabled
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
contextValue!.addToast({
|
||||
type: 'success',
|
||||
title: 'Hidden Toast',
|
||||
message: 'Should not appear in UI',
|
||||
});
|
||||
});
|
||||
|
||||
// Toast should NOT be in the visible toasts array
|
||||
expect(contextValue!.toasts).toHaveLength(0);
|
||||
|
||||
// But logging should still happen
|
||||
expect(window.maestro.logger.toast).toHaveBeenCalledWith('Hidden Toast', expect.any(Object));
|
||||
|
||||
// And OS notification should still be shown (if enabled)
|
||||
expect(window.maestro.notification.show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs toast via window.maestro.logger.toast', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
let contextValue: ReturnType<typeof useToast> | null = null;
|
||||
|
||||
@@ -480,7 +480,7 @@ describe('useActivityTracker', () => {
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('mousemove', expect.any(Function));
|
||||
// Note: mousemove is intentionally NOT listened to (CPU performance optimization)
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('touchstart', expect.any(Function));
|
||||
});
|
||||
@@ -496,7 +496,7 @@ describe('useActivityTracker', () => {
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('mousemove', expect.any(Function));
|
||||
// Note: mousemove is intentionally NOT listened to (CPU performance optimization)
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('touchstart', expect.any(Function));
|
||||
});
|
||||
@@ -531,19 +531,8 @@ describe('useActivityTracker', () => {
|
||||
expect(mockSetSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds to mousemove events', () => {
|
||||
renderHook(() => useActivityTracker('session-1', mockSetSessions));
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(BATCH_UPDATE_INTERVAL_MS);
|
||||
});
|
||||
|
||||
expect(mockSetSessions).toHaveBeenCalled();
|
||||
});
|
||||
// Note: mousemove is intentionally NOT listened to for CPU performance
|
||||
// (it fires hundreds of times per second during cursor movement)
|
||||
|
||||
it('responds to wheel events', () => {
|
||||
renderHook(() => useActivityTracker('session-1', mockSetSessions));
|
||||
@@ -595,7 +584,7 @@ describe('useActivityTracker', () => {
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('mousemove'));
|
||||
window.dispatchEvent(new WheelEvent('wheel'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('useSettings', () => {
|
||||
expect(result.current.defaultSaveToHistory).toBe(true);
|
||||
expect(result.current.leftSidebarWidth).toBe(256);
|
||||
expect(result.current.rightPanelWidth).toBe(384);
|
||||
expect(result.current.markdownRawMode).toBe(false);
|
||||
expect(result.current.markdownEditMode).toBe(false);
|
||||
});
|
||||
|
||||
it('should have correct default values for terminal settings', async () => {
|
||||
@@ -197,7 +197,7 @@ describe('useSettings', () => {
|
||||
defaultSaveToHistory: true,
|
||||
leftSidebarWidth: 300,
|
||||
rightPanelWidth: 400,
|
||||
markdownRawMode: true,
|
||||
markdownEditMode: true,
|
||||
};
|
||||
return values[key];
|
||||
});
|
||||
@@ -211,7 +211,7 @@ describe('useSettings', () => {
|
||||
expect(result.current.defaultSaveToHistory).toBe(true);
|
||||
expect(result.current.leftSidebarWidth).toBe(300);
|
||||
expect(result.current.rightPanelWidth).toBe(400);
|
||||
expect(result.current.markdownRawMode).toBe(true);
|
||||
expect(result.current.markdownEditMode).toBe(true);
|
||||
});
|
||||
|
||||
it('should load saved notification settings', async () => {
|
||||
@@ -519,16 +519,16 @@ describe('useSettings', () => {
|
||||
expect(window.maestro.settings.set).toHaveBeenCalledWith('rightPanelWidth', 500);
|
||||
});
|
||||
|
||||
it('should update markdownRawMode and persist to settings', async () => {
|
||||
it('should update markdownEditMode and persist to settings', async () => {
|
||||
const { result } = renderHook(() => useSettings());
|
||||
await waitForSettingsLoaded(result);
|
||||
|
||||
act(() => {
|
||||
result.current.setMarkdownRawMode(true);
|
||||
result.current.setMarkdownEditMode(true);
|
||||
});
|
||||
|
||||
expect(result.current.markdownRawMode).toBe(true);
|
||||
expect(window.maestro.settings.set).toHaveBeenCalledWith('markdownRawMode', true);
|
||||
expect(result.current.markdownEditMode).toBe(true);
|
||||
expect(window.maestro.settings.set).toHaveBeenCalledWith('markdownEditMode', true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4993,6 +4993,199 @@ function setupIpcHandlers() {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==========================================================================
|
||||
// Leaderboard API
|
||||
// ==========================================================================
|
||||
|
||||
// Submit leaderboard entry to runmaestro.ai
|
||||
ipcMain.handle(
|
||||
'leaderboard:submit',
|
||||
async (
|
||||
_event,
|
||||
data: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
twitterHandle?: string;
|
||||
linkedinHandle?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
longestRunMs?: number;
|
||||
longestRunDate?: string;
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
requiresConfirmation?: boolean;
|
||||
confirmationUrl?: string;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
logger.info('Submitting leaderboard entry', 'Leaderboard', {
|
||||
displayName: data.displayName,
|
||||
email: data.email.substring(0, 3) + '***',
|
||||
badgeLevel: data.badgeLevel,
|
||||
});
|
||||
|
||||
const response = await fetch('https://runmaestro.ai/api/m4estr0/submit', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': `Maestro/${app.getVersion()}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json() as {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
requiresConfirmation?: boolean;
|
||||
confirmationUrl?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('Leaderboard submission successful', 'Leaderboard', {
|
||||
requiresConfirmation: result.requiresConfirmation,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
message: result.message || 'Submission received',
|
||||
requiresConfirmation: result.requiresConfirmation,
|
||||
confirmationUrl: result.confirmationUrl,
|
||||
};
|
||||
} else {
|
||||
logger.warn('Leaderboard submission failed', 'Leaderboard', {
|
||||
status: response.status,
|
||||
error: result.error || result.message,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || 'Submission failed',
|
||||
error: result.error || `Server error: ${response.status}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error submitting to leaderboard', 'Leaderboard', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to connect to leaderboard server',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get leaderboard entries
|
||||
ipcMain.handle(
|
||||
'leaderboard:get',
|
||||
async (
|
||||
_event,
|
||||
options?: { limit?: number }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
entries?: Array<{
|
||||
rank: number;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
avatarUrl?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
}>;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
const limit = options?.limit || 50;
|
||||
const response = await fetch(`https://runmaestro.ai/api/leaderboard?limit=${limit}`, {
|
||||
headers: {
|
||||
'User-Agent': `Maestro/${app.getVersion()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { entries?: unknown[] };
|
||||
return { success: true, entries: data.entries as Array<{
|
||||
rank: number;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
avatarUrl?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
}> };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: `Server error: ${response.status}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching leaderboard', 'Leaderboard', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get longest runs leaderboard
|
||||
ipcMain.handle(
|
||||
'leaderboard:getLongestRuns',
|
||||
async (
|
||||
_event,
|
||||
options?: { limit?: number }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
entries?: Array<{
|
||||
rank: number;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
avatarUrl?: string;
|
||||
longestRunMs: number;
|
||||
runDate: string;
|
||||
}>;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
const limit = options?.limit || 50;
|
||||
const response = await fetch(`https://runmaestro.ai/api/longest-runs?limit=${limit}`, {
|
||||
headers: {
|
||||
'User-Agent': `Maestro/${app.getVersion()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { entries?: unknown[] };
|
||||
return { success: true, entries: data.entries as Array<{
|
||||
rank: number;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
avatarUrl?: string;
|
||||
longestRunMs: number;
|
||||
runDate: string;
|
||||
}> };
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: `Server error: ${response.status}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching longest runs leaderboard', 'Leaderboard', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Handle process output streaming (set up after initialization)
|
||||
|
||||
@@ -641,6 +641,27 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
import: (sessionId: string, autoRunFolderPath: string) =>
|
||||
ipcRenderer.invoke('playbooks:import', sessionId, autoRunFolderPath),
|
||||
},
|
||||
|
||||
// Leaderboard API (runmaestro.ai integration)
|
||||
leaderboard: {
|
||||
submit: (data: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
twitterHandle?: string;
|
||||
linkedinHandle?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
longestRunMs?: number;
|
||||
longestRunDate?: string;
|
||||
}) => ipcRenderer.invoke('leaderboard:submit', data),
|
||||
get: (options?: { limit?: number }) =>
|
||||
ipcRenderer.invoke('leaderboard:get', options),
|
||||
getLongestRuns: (options?: { limit?: number }) =>
|
||||
ipcRenderer.invoke('leaderboard:getLongestRuns', options),
|
||||
},
|
||||
});
|
||||
|
||||
// Type definitions for TypeScript
|
||||
@@ -1135,6 +1156,53 @@ export interface MaestroAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
leaderboard: {
|
||||
submit: (data: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
twitterHandle?: string;
|
||||
linkedinHandle?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
longestRunMs?: number;
|
||||
longestRunDate?: string;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
requiresConfirmation?: boolean;
|
||||
confirmationUrl?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
get: (options?: { limit?: number }) => Promise<{
|
||||
success: boolean;
|
||||
entries?: Array<{
|
||||
rank: number;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
avatarUrl?: string;
|
||||
badgeLevel: number;
|
||||
badgeName: string;
|
||||
cumulativeTimeMs: number;
|
||||
totalRuns: number;
|
||||
}>;
|
||||
error?: string;
|
||||
}>;
|
||||
getLongestRuns: (options?: { limit?: number }) => Promise<{
|
||||
success: boolean;
|
||||
entries?: Array<{
|
||||
rank: number;
|
||||
displayName: string;
|
||||
githubUsername?: string;
|
||||
avatarUrl?: string;
|
||||
longestRunMs: number;
|
||||
runDate: string;
|
||||
}>;
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { PromptComposerModal } from './components/PromptComposerModal';
|
||||
import { ExecutionQueueBrowser } from './components/ExecutionQueueBrowser';
|
||||
import { StandingOvationOverlay } from './components/StandingOvationOverlay';
|
||||
import { FirstRunCelebration } from './components/FirstRunCelebration';
|
||||
import { LeaderboardRegistrationModal } from './components/LeaderboardRegistrationModal';
|
||||
import { PlaygroundPanel } from './components/PlaygroundPanel';
|
||||
import { AutoRunSetupModal } from './components/AutoRunSetupModal';
|
||||
import { DebugWizardModal } from './components/DebugWizardModal';
|
||||
@@ -162,7 +163,7 @@ export default function MaestroConsole() {
|
||||
defaultSaveToHistory, setDefaultSaveToHistory,
|
||||
leftSidebarWidth, setLeftSidebarWidth,
|
||||
rightPanelWidth, setRightPanelWidth,
|
||||
markdownRawMode, setMarkdownRawMode,
|
||||
markdownEditMode, setMarkdownEditMode,
|
||||
terminalWidth, setTerminalWidth,
|
||||
logLevel, setLogLevel,
|
||||
logViewerSelectedLevels, setLogViewerSelectedLevels,
|
||||
@@ -181,6 +182,7 @@ export default function MaestroConsole() {
|
||||
firstAutoRunCompleted, setFirstAutoRunCompleted,
|
||||
recordWizardStart, recordWizardComplete, recordWizardAbandon, recordWizardResume,
|
||||
recordTourStart, recordTourComplete, recordTourSkip,
|
||||
leaderboardRegistration, setLeaderboardRegistration, isLeaderboardRegistered,
|
||||
} = settings;
|
||||
|
||||
// --- STATE ---
|
||||
@@ -262,6 +264,7 @@ export default function MaestroConsole() {
|
||||
const [lightboxImages, setLightboxImages] = useState<string[]>([]); // Context images for navigation
|
||||
const [aboutModalOpen, setAboutModalOpen] = useState(false);
|
||||
const [updateCheckModalOpen, setUpdateCheckModalOpen] = useState(false);
|
||||
const [leaderboardRegistrationOpen, setLeaderboardRegistrationOpen] = useState(false);
|
||||
const [standingOvationData, setStandingOvationData] = useState<{
|
||||
badge: typeof CONDUCTOR_BADGES[number];
|
||||
isNewRecord: boolean;
|
||||
@@ -2698,6 +2701,54 @@ export default function MaestroConsole() {
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Submit to leaderboard if registered and email confirmed
|
||||
if (isLeaderboardRegistered && leaderboardRegistration) {
|
||||
// Calculate updated stats after this run (simulating what recordAutoRunComplete updated)
|
||||
const updatedCumulativeTimeMs = autoRunStats.cumulativeTimeMs + info.elapsedTimeMs;
|
||||
const updatedTotalRuns = autoRunStats.totalRuns + 1;
|
||||
const updatedLongestRunMs = Math.max(autoRunStats.longestRunMs || 0, info.elapsedTimeMs);
|
||||
const updatedBadge = CONDUCTOR_BADGES.find(b =>
|
||||
b.thresholdMs <= updatedCumulativeTimeMs
|
||||
);
|
||||
const updatedBadgeLevel = updatedBadge?.level || 0;
|
||||
const updatedBadgeName = updatedBadge?.name || 'No Badge Yet';
|
||||
|
||||
// Format longest run date
|
||||
let longestRunDate: string | undefined;
|
||||
if (isNewRecord) {
|
||||
longestRunDate = new Date().toISOString().split('T')[0];
|
||||
} else if (autoRunStats.longestRunTimestamp > 0) {
|
||||
longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Submit to leaderboard in background
|
||||
window.maestro.leaderboard.submit({
|
||||
email: leaderboardRegistration.email,
|
||||
displayName: leaderboardRegistration.displayName,
|
||||
githubUsername: leaderboardRegistration.githubUsername,
|
||||
twitterHandle: leaderboardRegistration.twitterHandle,
|
||||
linkedinHandle: leaderboardRegistration.linkedinHandle,
|
||||
badgeLevel: updatedBadgeLevel,
|
||||
badgeName: updatedBadgeName,
|
||||
cumulativeTimeMs: updatedCumulativeTimeMs,
|
||||
totalRuns: updatedTotalRuns,
|
||||
longestRunMs: updatedLongestRunMs,
|
||||
longestRunDate,
|
||||
}).then(result => {
|
||||
if (result.success) {
|
||||
// Update last submission timestamp
|
||||
setLeaderboardRegistration({
|
||||
...leaderboardRegistration,
|
||||
lastSubmissionAt: Date.now(),
|
||||
emailConfirmed: !result.requiresConfirmation,
|
||||
});
|
||||
}
|
||||
// Silent failure - don't bother the user if submission fails
|
||||
}).catch(() => {
|
||||
// Silent failure - leaderboard submission is not critical
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onPRResult: (info) => {
|
||||
@@ -3646,7 +3697,7 @@ export default function MaestroConsole() {
|
||||
const isInAutoRunPanel = ctx.activeFocus === 'right' && ctx.activeRightTab === 'autorun';
|
||||
if (!isInAutoRunPanel && !ctx.previewFile) {
|
||||
e.preventDefault();
|
||||
ctx.setMarkdownRawMode(!ctx.markdownRawMode);
|
||||
ctx.setMarkdownEditMode(!ctx.markdownEditMode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3990,11 +4041,13 @@ export default function MaestroConsole() {
|
||||
}
|
||||
}
|
||||
|
||||
// Ungrouped sessions (sorted alphabetically)
|
||||
const ungroupedSessions = sessions
|
||||
.filter(s => !s.groupId)
|
||||
.sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name));
|
||||
visualOrder.push(...ungroupedSessions);
|
||||
// Ungrouped sessions (sorted alphabetically) - only if not collapsed
|
||||
if (!settings.ungroupedCollapsed) {
|
||||
const ungroupedSessions = sessions
|
||||
.filter(s => !s.groupId)
|
||||
.sort((a, b) => compareNamesIgnoringEmojis(a.name, b.name));
|
||||
visualOrder.push(...ungroupedSessions);
|
||||
}
|
||||
} else {
|
||||
// Sidebar collapsed: cycle through all sessions in their sorted order
|
||||
visualOrder.push(...sortedSessions);
|
||||
@@ -4518,7 +4571,7 @@ export default function MaestroConsole() {
|
||||
processMonitorOpen, logViewerOpen, createGroupModalOpen, confirmModalOpen, renameInstanceModalOpen,
|
||||
renameGroupModalOpen, activeSession, previewFile, fileTreeFilter, fileTreeFilterOpen, gitDiffPreview,
|
||||
gitLogOpen, lightboxImage, hasOpenLayers, hasOpenModal, visibleSessions, sortedSessions, groups,
|
||||
bookmarksCollapsed, leftSidebarOpen, editingSessionId, editingGroupId, markdownRawMode, defaultSaveToHistory,
|
||||
bookmarksCollapsed, leftSidebarOpen, editingSessionId, editingGroupId, markdownEditMode, defaultSaveToHistory,
|
||||
setLeftSidebarOpen, setRightPanelOpen, addNewSession, deleteSession, setQuickActionInitialMode,
|
||||
setQuickActionOpen, cycleSession, toggleInputMode, setShortcutsHelpOpen, setSettingsModalOpen,
|
||||
setSettingsTab, setActiveRightTab, handleSetActiveRightTab, setActiveFocus, setBookmarksCollapsed, setGroups,
|
||||
@@ -4527,7 +4580,7 @@ export default function MaestroConsole() {
|
||||
setSessions, createTab, closeTab, reopenClosedTab, getActiveTab, setRenameTabId, setRenameTabInitialName,
|
||||
setRenameTabModalOpen, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab,
|
||||
setFileTreeFilterOpen, isShortcut, isTabShortcut, handleNavBack, handleNavForward, toggleUnreadFilter,
|
||||
setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage, setMarkdownRawMode,
|
||||
setTabSwitcherOpen, showUnreadOnly, stagedImages, handleSetLightboxImage, setMarkdownEditMode,
|
||||
toggleTabStar, setPromptComposerOpen, openWizardModal
|
||||
};
|
||||
|
||||
@@ -6734,8 +6787,8 @@ export default function MaestroConsole() {
|
||||
processQueuedItem(activeSessionId, nextItem);
|
||||
console.log('[Debug] Released queued item:', nextItem);
|
||||
}}
|
||||
markdownRawMode={markdownRawMode}
|
||||
onToggleMarkdownRawMode={() => setMarkdownRawMode(!markdownRawMode)}
|
||||
markdownEditMode={markdownEditMode}
|
||||
onToggleMarkdownEditMode={() => setMarkdownEditMode(!markdownEditMode)}
|
||||
setUpdateCheckModalOpen={setUpdateCheckModalOpen}
|
||||
openWizard={openWizardModal}
|
||||
wizardGoToStep={wizardGoToStep}
|
||||
@@ -6796,6 +6849,24 @@ export default function MaestroConsole() {
|
||||
sessions={sessions}
|
||||
autoRunStats={autoRunStats}
|
||||
onClose={() => setAboutModalOpen(false)}
|
||||
onOpenLeaderboardRegistration={() => {
|
||||
setAboutModalOpen(false);
|
||||
setLeaderboardRegistrationOpen(true);
|
||||
}}
|
||||
isLeaderboardRegistered={isLeaderboardRegistered}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* --- LEADERBOARD REGISTRATION MODAL --- */}
|
||||
{leaderboardRegistrationOpen && (
|
||||
<LeaderboardRegistrationModal
|
||||
theme={theme}
|
||||
autoRunStats={autoRunStats}
|
||||
existingRegistration={leaderboardRegistration}
|
||||
onClose={() => setLeaderboardRegistrationOpen(false)}
|
||||
onSave={(registration) => {
|
||||
setLeaderboardRegistration(registration);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6815,6 +6886,8 @@ export default function MaestroConsole() {
|
||||
completedTasks={firstRunCelebrationData.completedTasks}
|
||||
totalTasks={firstRunCelebrationData.totalTasks}
|
||||
onClose={() => setFirstRunCelebrationData(null)}
|
||||
onOpenLeaderboardRegistration={() => setLeaderboardRegistrationOpen(true)}
|
||||
isLeaderboardRegistered={isLeaderboardRegistered}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6832,6 +6905,8 @@ export default function MaestroConsole() {
|
||||
acknowledgeBadge(standingOvationData.badge.level);
|
||||
setStandingOvationData(null);
|
||||
}}
|
||||
onOpenLeaderboardRegistration={() => setLeaderboardRegistrationOpen(true)}
|
||||
isLeaderboardRegistered={isLeaderboardRegistered}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -7070,7 +7145,7 @@ export default function MaestroConsole() {
|
||||
slashCommands={allSlashCommands}
|
||||
selectedSlashCommandIndex={selectedSlashCommandIndex}
|
||||
previewFile={previewFile}
|
||||
markdownRawMode={markdownRawMode}
|
||||
markdownEditMode={markdownEditMode}
|
||||
shortcuts={shortcuts}
|
||||
rightPanelOpen={rightPanelOpen}
|
||||
maxOutputLines={maxOutputLines}
|
||||
@@ -7129,7 +7204,7 @@ export default function MaestroConsole() {
|
||||
selectedAtMentionIndex={selectedAtMentionIndex}
|
||||
setSelectedAtMentionIndex={setSelectedAtMentionIndex}
|
||||
setPreviewFile={setPreviewFile}
|
||||
setMarkdownRawMode={setMarkdownRawMode}
|
||||
setMarkdownEditMode={setMarkdownEditMode}
|
||||
setAboutModalOpen={setAboutModalOpen}
|
||||
setRightPanelOpen={setRightPanelOpen}
|
||||
inputRef={inputRef}
|
||||
@@ -7454,6 +7529,14 @@ export default function MaestroConsole() {
|
||||
}
|
||||
}}
|
||||
onOpenPromptComposer={() => setPromptComposerOpen(true)}
|
||||
onReplayMessage={(text: string, images?: string[]) => {
|
||||
// Set staged images if the message had any
|
||||
if (images && images.length > 0) {
|
||||
setStagedImages(images);
|
||||
}
|
||||
// Use setTimeout to ensure state updates are applied before processing
|
||||
setTimeout(() => processInput(text), 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { X, Wand2, ExternalLink, FileCode, BarChart3, Loader2 } from 'lucide-react';
|
||||
import { X, Wand2, ExternalLink, FileCode, BarChart3, Loader2, Trophy, Globe } from 'lucide-react';
|
||||
import type { Theme, Session, AutoRunStats } from '../types';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
@@ -23,9 +23,11 @@ interface AboutModalProps {
|
||||
sessions: Session[];
|
||||
autoRunStats: AutoRunStats;
|
||||
onClose: () => void;
|
||||
onOpenLeaderboardRegistration?: () => void;
|
||||
isLeaderboardRegistered?: boolean;
|
||||
}
|
||||
|
||||
export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModalProps) {
|
||||
export function AboutModal({ theme, sessions, autoRunStats, onClose, onOpenLeaderboardRegistration, isLeaderboardRegistered }: AboutModalProps) {
|
||||
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
||||
const layerIdRef = useRef<string>();
|
||||
const [globalStats, setGlobalStats] = useState<ClaudeGlobalStats | null>(null);
|
||||
@@ -136,7 +138,27 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModa
|
||||
>
|
||||
<div className="w-[450px] max-h-[90vh] border rounded-lg shadow-2xl overflow-hidden flex flex-col" style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }}>
|
||||
<div className="p-4 border-b flex items-center justify-between" style={{ borderColor: theme.colors.border }}>
|
||||
<h2 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>About Maestro</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>About Maestro</h2>
|
||||
<button
|
||||
onClick={() => window.maestro.shell.openExternal('https://runmaestro.ai')}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
title="Visit runmaestro.ai"
|
||||
style={{ color: theme.colors.accent }}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.maestro.shell.openExternal('https://discord.gg/86crXbGb')}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
title="Join our Discord"
|
||||
style={{ color: theme.colors.accent }}
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={onClose} style={{ color: theme.colors.textDim }}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -275,18 +297,45 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModa
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Link */}
|
||||
<button
|
||||
onClick={() => window.maestro.shell.openExternal('https://github.com/pedramamini/Maestro')}
|
||||
className="w-full flex items-center justify-between p-3 rounded border hover:bg-white/5 transition-colors"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" style={{ color: theme.colors.accent }} />
|
||||
<span className="text-sm font-medium" style={{ color: theme.colors.textMain }}>View on GitHub</span>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
</button>
|
||||
{/* Action Links */}
|
||||
<div className="flex gap-2">
|
||||
{/* Project Link */}
|
||||
<button
|
||||
onClick={() => window.maestro.shell.openExternal('https://github.com/pedramamini/Maestro')}
|
||||
className="flex-1 flex items-center justify-between p-3 rounded border hover:bg-white/5 transition-colors"
|
||||
style={{ borderColor: theme.colors.border }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode className="w-4 h-4" style={{ color: theme.colors.accent }} />
|
||||
<span className="text-sm font-medium" style={{ color: theme.colors.textMain }}>GitHub</span>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
</button>
|
||||
|
||||
{/* Leaderboard Registration */}
|
||||
{onOpenLeaderboardRegistration && (
|
||||
<button
|
||||
onClick={onOpenLeaderboardRegistration}
|
||||
className="flex-1 flex items-center justify-between p-3 rounded border hover:bg-white/5 transition-colors"
|
||||
style={{
|
||||
borderColor: isLeaderboardRegistered ? theme.colors.success : theme.colors.accent,
|
||||
backgroundColor: isLeaderboardRegistered ? `${theme.colors.success}10` : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="w-4 h-4" style={{ color: isLeaderboardRegistered ? theme.colors.success : '#FFD700' }} />
|
||||
<span className="text-sm font-medium" style={{ color: theme.colors.textMain }}>
|
||||
{isLeaderboardRegistered ? 'Leaderboard' : 'Join Leaderboard'}
|
||||
</span>
|
||||
</div>
|
||||
{isLeaderboardRegistered ? (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded font-medium" style={{ backgroundColor: theme.colors.success, color: '#000' }}>Active</span>
|
||||
) : (
|
||||
<ExternalLink className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Made in Austin */}
|
||||
<div className="pt-1 text-center flex flex-col items-center gap-1">
|
||||
|
||||
@@ -113,8 +113,6 @@ export function AgentSessionsBrowser({
|
||||
onCloseRef.current = onClose;
|
||||
const viewingSessionRef = useRef(viewingSession);
|
||||
viewingSessionRef.current = viewingSession;
|
||||
const showSearchPanelRef = useRef(showSearchPanel);
|
||||
showSearchPanelRef.current = showSearchPanel;
|
||||
const autoJumpedRef = useRef<string | null>(null); // Track which session we've auto-jumped to
|
||||
|
||||
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
||||
@@ -135,12 +133,10 @@ export function AgentSessionsBrowser({
|
||||
focusTrap: 'lenient',
|
||||
ariaLabel: 'Agent Sessions Browser',
|
||||
onEscape: () => {
|
||||
// If viewing a session detail, go back to list; otherwise close the panel
|
||||
if (viewingSessionRef.current) {
|
||||
setViewingSession(null);
|
||||
setMessages([]);
|
||||
} else if (showSearchPanelRef.current) {
|
||||
// If in search panel, switch back to graph first
|
||||
setShowSearchPanel(false);
|
||||
} else {
|
||||
onCloseRef.current();
|
||||
}
|
||||
@@ -154,22 +150,19 @@ export function AgentSessionsBrowser({
|
||||
};
|
||||
}, [registerLayer, unregisterLayer]);
|
||||
|
||||
// Update handler when viewingSession or showSearchPanel changes
|
||||
// Update handler when viewingSession changes
|
||||
useEffect(() => {
|
||||
if (layerIdRef.current) {
|
||||
updateLayerHandler(layerIdRef.current, () => {
|
||||
if (viewingSessionRef.current) {
|
||||
setViewingSession(null);
|
||||
setMessages([]);
|
||||
} else if (showSearchPanelRef.current) {
|
||||
// If in search panel, switch back to graph first
|
||||
setShowSearchPanel(false);
|
||||
} else {
|
||||
onCloseRef.current();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [viewingSession, showSearchPanel, updateLayerHandler]);
|
||||
}, [viewingSession, updateLayerHandler]);
|
||||
|
||||
// Restore focus and scroll position when returning from detail view to list view
|
||||
const prevViewingSessionRef = useRef<ClaudeSession | null>(null);
|
||||
|
||||
@@ -2081,8 +2081,13 @@ export const AutoRun = memo(AutoRunInner, (prevProps, nextProps) => {
|
||||
prevProps.onStopBatchRun === nextProps.onStopBatchRun &&
|
||||
prevProps.onOpenSetup === nextProps.onOpenSetup &&
|
||||
prevProps.onRefresh === nextProps.onRefresh &&
|
||||
prevProps.onSelectDocument === nextProps.onSelectDocument
|
||||
prevProps.onSelectDocument === nextProps.onSelectDocument &&
|
||||
// UI control props
|
||||
prevProps.hideTopControls === nextProps.hideTopControls &&
|
||||
// External change detection
|
||||
prevProps.contentVersion === nextProps.contentVersion
|
||||
// Note: initialCursorPosition, initialEditScrollPos, initialPreviewScrollPos
|
||||
// are intentionally NOT compared - they're only used on mount
|
||||
// Note: documentTree is derived from documentList, comparing documentList is sufficient
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { FileCode, X, Copy, FileText, Eye, ChevronUp, ChevronDown, Clipboard, Loader2, Image, Globe } from 'lucide-react';
|
||||
import { FileCode, X, Copy, FileText, Eye, ChevronUp, ChevronDown, Clipboard, Loader2, Image, Globe, Save, Edit, FolderOpen } from 'lucide-react';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
@@ -21,8 +21,9 @@ interface FilePreviewProps {
|
||||
file: { name: string; content: string; path: string } | null;
|
||||
onClose: () => void;
|
||||
theme: any;
|
||||
markdownRawMode: boolean;
|
||||
setMarkdownRawMode: (value: boolean) => void;
|
||||
markdownEditMode: boolean;
|
||||
setMarkdownEditMode: (value: boolean) => void;
|
||||
onSave?: (path: string, content: string) => Promise<void>;
|
||||
shortcuts: Record<string, any>;
|
||||
}
|
||||
|
||||
@@ -97,6 +98,17 @@ const formatTokenCount = (count: number): string => {
|
||||
return count.toLocaleString();
|
||||
};
|
||||
|
||||
// Count markdown tasks (checkboxes)
|
||||
const countMarkdownTasks = (content: string): { open: number; closed: number } => {
|
||||
// Match markdown checkboxes: - [ ] or - [x] (also * [ ] and * [x])
|
||||
const openMatches = content.match(/^[\s]*[-*]\s*\[\s*\]/gm);
|
||||
const closedMatches = content.match(/^[\s]*[-*]\s*\[[xX]\]/gm);
|
||||
return {
|
||||
open: openMatches?.length || 0,
|
||||
closed: closedMatches?.length || 0
|
||||
};
|
||||
};
|
||||
|
||||
// Lazy-loaded tokenizer encoder (cl100k_base is used by Claude/GPT-4)
|
||||
let encoderPromise: Promise<ReturnType<typeof getEncoding>> | null = null;
|
||||
const getEncoder = () => {
|
||||
@@ -296,7 +308,7 @@ function remarkHighlight() {
|
||||
};
|
||||
}
|
||||
|
||||
export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdownRawMode, shortcuts }: FilePreviewProps) {
|
||||
export function FilePreview({ file, onClose, theme, markdownEditMode, setMarkdownEditMode, onSave, shortcuts }: FilePreviewProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [showCopyNotification, setShowCopyNotification] = useState(false);
|
||||
@@ -307,13 +319,20 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
const [showStatsBar, setShowStatsBar] = useState(true);
|
||||
const [tokenCount, setTokenCount] = useState<number | null>(null);
|
||||
const [showRemoteImages, setShowRemoteImages] = useState(false);
|
||||
// Edit mode state
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const codeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const layerIdRef = useRef<string>();
|
||||
const matchElementsRef = useRef<HTMLElement[]>([]);
|
||||
|
||||
// Track if content has been modified
|
||||
const hasChanges = markdownEditMode && editContent !== file?.content;
|
||||
|
||||
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
||||
|
||||
if (!file) return null;
|
||||
@@ -322,6 +341,15 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
const isMarkdown = language === 'markdown';
|
||||
const isImage = isImageFile(file.name);
|
||||
|
||||
// Calculate task counts for markdown files
|
||||
const taskCounts = useMemo(() => {
|
||||
if (!isMarkdown || !file?.content) return null;
|
||||
const counts = countMarkdownTasks(file.content);
|
||||
// Only return if there are any tasks
|
||||
if (counts.open === 0 && counts.closed === 0) return null;
|
||||
return counts;
|
||||
}, [isMarkdown, file?.content]);
|
||||
|
||||
// Extract directory path without filename
|
||||
const directoryPath = file.path.substring(0, file.path.lastIndexOf('/'));
|
||||
|
||||
@@ -359,6 +387,40 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
});
|
||||
}, [file?.content, isImage]);
|
||||
|
||||
// Sync edit content when file changes or when entering edit mode
|
||||
useEffect(() => {
|
||||
if (file?.content) {
|
||||
setEditContent(file.content);
|
||||
}
|
||||
}, [file?.content, file?.path]);
|
||||
|
||||
// Focus textarea when entering edit mode
|
||||
useEffect(() => {
|
||||
if (markdownEditMode && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [markdownEditMode]);
|
||||
|
||||
// Save handler
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!file || !onSave || !hasChanges || isSaving) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(file.path, editContent);
|
||||
setCopyNotificationMessage('File Saved');
|
||||
setShowCopyNotification(true);
|
||||
setTimeout(() => setShowCopyNotification(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to save file:', err);
|
||||
setCopyNotificationMessage('Save Failed');
|
||||
setShowCopyNotification(true);
|
||||
setTimeout(() => setShowCopyNotification(false), 2000);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [file, onSave, hasChanges, isSaving, editContent]);
|
||||
|
||||
// Track scroll position to show/hide stats bar
|
||||
useEffect(() => {
|
||||
const contentEl = contentRef.current;
|
||||
@@ -640,9 +702,9 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
}
|
||||
}, [searchQuery, file.content, isMarkdown, isImage]);
|
||||
|
||||
// Scroll to current match for markdown content
|
||||
// Scroll to current match for markdown content (only when searching, not in edit mode)
|
||||
useEffect(() => {
|
||||
if ((isMarkdown && markdownRawMode) || (isMarkdown && searchQuery.trim())) {
|
||||
if (isMarkdown && searchQuery.trim() && !markdownEditMode) {
|
||||
const marks = contentRef.current?.querySelectorAll('mark.search-match-md');
|
||||
if (marks && marks.length > 0 && currentMatchIndex >= 0 && currentMatchIndex < marks.length) {
|
||||
marks.forEach((mark, i) => {
|
||||
@@ -658,7 +720,7 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentMatchIndex, isMarkdown, markdownRawMode, searchQuery, theme.colors.accent]);
|
||||
}, [currentMatchIndex, isMarkdown, markdownEditMode, searchQuery, theme.colors.accent]);
|
||||
|
||||
// Helper to check if a shortcut matches
|
||||
const isShortcut = (e: React.KeyboardEvent, shortcutId: string) => {
|
||||
@@ -689,6 +751,11 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
e.stopPropagation();
|
||||
setSearchOpen(true);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
} else if (e.key === 's' && (e.metaKey || e.ctrlKey) && isMarkdown && markdownEditMode) {
|
||||
// Cmd+S to save in edit mode
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
} else if (isShortcut(e, 'copyFilePath')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -696,7 +763,7 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
} else if (isMarkdown && isShortcut(e, 'toggleMarkdownMode')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMarkdownRawMode(!markdownRawMode);
|
||||
setMarkdownEditMode(!markdownEditMode);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const container = contentRef.current;
|
||||
@@ -752,21 +819,43 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isMarkdown && (
|
||||
<>
|
||||
{/* Save button - only shown in edit mode with changes */}
|
||||
{markdownEditMode && onSave && (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="px-3 py-1.5 rounded text-xs font-medium transition-colors flex items-center gap-1.5"
|
||||
style={{
|
||||
backgroundColor: hasChanges ? theme.colors.accent : theme.colors.bgActivity,
|
||||
color: hasChanges ? theme.colors.accentForeground : theme.colors.textDim,
|
||||
opacity: hasChanges && !isSaving ? 1 : 0.5,
|
||||
cursor: hasChanges && !isSaving ? 'pointer' : 'default',
|
||||
}}
|
||||
title={hasChanges ? "Save changes (⌘S)" : "No changes to save"}
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
{/* Show remote images toggle - only in preview mode */}
|
||||
{!markdownEditMode && (
|
||||
<button
|
||||
onClick={() => setShowRemoteImages(!showRemoteImages)}
|
||||
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: showRemoteImages ? theme.colors.accent : theme.colors.textDim }}
|
||||
title={showRemoteImages ? "Hide remote images" : "Show remote images"}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* Toggle between edit and preview mode */}
|
||||
<button
|
||||
onClick={() => setShowRemoteImages(!showRemoteImages)}
|
||||
onClick={() => setMarkdownEditMode(!markdownEditMode)}
|
||||
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: showRemoteImages ? theme.colors.accent : theme.colors.textDim }}
|
||||
title={showRemoteImages ? "Hide remote images" : "Show remote images"}
|
||||
style={{ color: markdownEditMode ? theme.colors.accent : theme.colors.textDim }}
|
||||
title={`${markdownEditMode ? "Show preview" : "Edit file"} (${formatShortcut('toggleMarkdownMode')})`}
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMarkdownRawMode(!markdownRawMode)}
|
||||
className="p-2 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: markdownRawMode ? theme.colors.accent : theme.colors.textDim }}
|
||||
title={`${markdownRawMode ? "Show rendered markdown" : "Show raw markdown"} (${formatShortcut('toggleMarkdownMode')})`}
|
||||
>
|
||||
{markdownRawMode ? <Eye className="w-4 h-4" /> : <FileText className="w-4 h-4" />}
|
||||
{markdownEditMode ? <Eye className="w-4 h-4" /> : <Edit className="w-4 h-4" />}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -784,7 +873,7 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Copy full path to clipboard"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<FolderOpen className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -796,7 +885,7 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
</div>
|
||||
</div>
|
||||
{/* File Stats subbar - hidden on scroll */}
|
||||
{(fileStats || tokenCount !== null) && showStatsBar && (
|
||||
{(fileStats || tokenCount !== null || taskCounts) && showStatsBar && (
|
||||
<div
|
||||
className="flex items-center gap-4 px-6 py-1.5 border-b transition-all duration-200"
|
||||
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgActivity }}
|
||||
@@ -825,6 +914,13 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{taskCounts && (
|
||||
<div className="text-[10px]" style={{ color: theme.colors.textDim }}>
|
||||
<span className="opacity-60">Tasks:</span>{' '}
|
||||
<span style={{ color: theme.colors.success }}>{taskCounts.closed}</span>
|
||||
<span style={{ color: theme.colors.textMain }}> of {taskCounts.open + taskCounts.closed}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -898,12 +994,40 @@ export function FilePreview({ file, onClose, theme, markdownRawMode, setMarkdown
|
||||
style={{ imageRendering: 'crisp-edges' }}
|
||||
/>
|
||||
</div>
|
||||
) : (isMarkdown && markdownRawMode) || (isMarkdown && searchQuery.trim()) ? (
|
||||
// When in raw markdown mode OR searching in markdown, show plain text with highlights
|
||||
) : isMarkdown && markdownEditMode ? (
|
||||
// Edit mode - show editable textarea
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="w-full h-full font-mono text-sm resize-none outline-none bg-transparent"
|
||||
style={{
|
||||
color: theme.colors.textMain,
|
||||
caretColor: theme.colors.accent,
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
spellCheck={false}
|
||||
onKeyDown={(e) => {
|
||||
// Handle Cmd+S for save
|
||||
if (e.key === 's' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSave();
|
||||
}
|
||||
// Handle Escape to exit edit mode (without save)
|
||||
else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setMarkdownEditMode(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : isMarkdown && searchQuery.trim() ? (
|
||||
// When searching in markdown, show plain text with search highlights
|
||||
<div
|
||||
className="font-mono text-sm whitespace-pre-wrap"
|
||||
style={{ color: theme.colors.textMain }}
|
||||
dangerouslySetInnerHTML={{ __html: searchQuery.trim() ? highlightMatches(file.content) : file.content }}
|
||||
dangerouslySetInnerHTML={{ __html: highlightMatches(file.content) }}
|
||||
/>
|
||||
) : isMarkdown ? (
|
||||
<div className="prose prose-sm max-w-none" style={{ color: theme.colors.textMain }}>
|
||||
|
||||
@@ -28,6 +28,10 @@ interface FirstRunCelebrationProps {
|
||||
totalTasks: number;
|
||||
/** Callback when modal is dismissed */
|
||||
onClose: () => void;
|
||||
/** Callback to open leaderboard registration */
|
||||
onOpenLeaderboardRegistration?: () => void;
|
||||
/** Whether the user is already registered for the leaderboard */
|
||||
isLeaderboardRegistered?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,6 +70,8 @@ export function FirstRunCelebration({
|
||||
completedTasks,
|
||||
totalTasks,
|
||||
onClose,
|
||||
onOpenLeaderboardRegistration,
|
||||
isLeaderboardRegistered,
|
||||
}: FirstRunCelebrationProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
||||
@@ -408,7 +414,7 @@ export function FirstRunCelebration({
|
||||
</div>
|
||||
|
||||
{/* Button */}
|
||||
<div className="px-8 pb-8">
|
||||
<div className="px-8 pb-8 space-y-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={isClosing}
|
||||
@@ -424,8 +430,30 @@ export function FirstRunCelebration({
|
||||
{isClosing ? '🎉 Let\'s Go! 🎉' : 'Got It!'}
|
||||
</button>
|
||||
|
||||
{/* Leaderboard Registration */}
|
||||
{onOpenLeaderboardRegistration && !isLeaderboardRegistered && (
|
||||
<button
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
setTimeout(() => {
|
||||
onOpenLeaderboardRegistration();
|
||||
}, 1100); // Wait for close animation
|
||||
}}
|
||||
disabled={isClosing}
|
||||
className="w-full py-2.5 rounded-lg font-medium transition-all flex items-center justify-center gap-2 hover:opacity-90 disabled:opacity-50"
|
||||
style={{
|
||||
backgroundColor: `${goldColor}20`,
|
||||
color: goldColor,
|
||||
border: `1px solid ${goldColor}60`,
|
||||
}}
|
||||
>
|
||||
<Trophy className="w-4 h-4" />
|
||||
Join Global Leaderboard
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p
|
||||
className="text-xs text-center mt-3"
|
||||
className="text-xs text-center"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
Press Enter or Escape to dismiss
|
||||
|
||||
@@ -115,9 +115,44 @@ export function GitStatusWidget({ cwd, isGitRepo, theme, onViewDiff }: GitStatus
|
||||
|
||||
loadGitStatus();
|
||||
|
||||
// Refresh every 5 seconds
|
||||
const interval = setInterval(loadGitStatus, 5000);
|
||||
return () => clearInterval(interval);
|
||||
// Refresh every 30 seconds (reduced from 5s to save CPU)
|
||||
// Also pause polling when window is hidden
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const startPolling = () => {
|
||||
if (!interval) {
|
||||
interval = setInterval(loadGitStatus, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
// Refresh immediately when becoming visible, then resume polling
|
||||
loadGitStatus();
|
||||
startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling if visible
|
||||
if (!document.hidden) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [cwd, isGitRepo]);
|
||||
|
||||
// Don't render if not a git repo or no changes
|
||||
|
||||
418
src/renderer/components/LeaderboardRegistrationModal.tsx
Normal file
418
src/renderer/components/LeaderboardRegistrationModal.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* LeaderboardRegistrationModal.tsx
|
||||
*
|
||||
* Modal for registering to the runmaestro.ai leaderboard.
|
||||
* Users provide display name, email (required), and optional social handles.
|
||||
* On submission, stats are sent to the API and email confirmation is required.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { X, Trophy, Mail, User, Loader2, Check, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
import type { Theme, AutoRunStats, LeaderboardRegistration } from '../types';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
import { getBadgeForTime, CONDUCTOR_BADGES } from '../constants/conductorBadges';
|
||||
|
||||
// Social media icons as SVG components
|
||||
const GithubIcon = ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const XTwitterIcon = ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LinkedInIcon = ({ className, style }: { className?: string; style?: React.CSSProperties }) => (
|
||||
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface LeaderboardRegistrationModalProps {
|
||||
theme: Theme;
|
||||
autoRunStats: AutoRunStats;
|
||||
existingRegistration: LeaderboardRegistration | null;
|
||||
onClose: () => void;
|
||||
onSave: (registration: LeaderboardRegistration) => void;
|
||||
}
|
||||
|
||||
type SubmitState = 'idle' | 'submitting' | 'success' | 'awaiting_confirmation' | 'error';
|
||||
|
||||
export function LeaderboardRegistrationModal({
|
||||
theme,
|
||||
autoRunStats,
|
||||
existingRegistration,
|
||||
onClose,
|
||||
onSave,
|
||||
}: LeaderboardRegistrationModalProps) {
|
||||
const { registerLayer, unregisterLayer } = useLayerStack();
|
||||
const layerIdRef = useRef<string>();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Form state
|
||||
const [displayName, setDisplayName] = useState(existingRegistration?.displayName || '');
|
||||
const [email, setEmail] = useState(existingRegistration?.email || '');
|
||||
const [twitterHandle, setTwitterHandle] = useState(existingRegistration?.twitterHandle || '');
|
||||
const [githubUsername, setGithubUsername] = useState(existingRegistration?.githubUsername || '');
|
||||
const [linkedinHandle, setLinkedinHandle] = useState(existingRegistration?.linkedinHandle || '');
|
||||
|
||||
// Submission state
|
||||
const [submitState, setSubmitState] = useState<SubmitState>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [successMessage, setSuccessMessage] = useState('');
|
||||
|
||||
// Get current badge info
|
||||
const currentBadge = getBadgeForTime(autoRunStats.cumulativeTimeMs);
|
||||
const badgeLevel = currentBadge?.level || 0;
|
||||
const badgeName = currentBadge?.name || 'No Badge Yet';
|
||||
|
||||
// Validate email format
|
||||
const isValidEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
// Check if form is valid
|
||||
const isFormValid = displayName.trim().length > 0 && email.trim().length > 0 && isValidEmail(email);
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!isFormValid) return;
|
||||
|
||||
setSubmitState('submitting');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
// Format longest run date if available
|
||||
let longestRunDate: string | undefined;
|
||||
if (autoRunStats.longestRunTimestamp > 0) {
|
||||
longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
const result = await window.maestro.leaderboard.submit({
|
||||
email: email.trim(),
|
||||
displayName: displayName.trim(),
|
||||
githubUsername: githubUsername.trim() || undefined,
|
||||
twitterHandle: twitterHandle.trim() || undefined,
|
||||
linkedinHandle: linkedinHandle.trim() || undefined,
|
||||
badgeLevel,
|
||||
badgeName,
|
||||
cumulativeTimeMs: autoRunStats.cumulativeTimeMs,
|
||||
totalRuns: autoRunStats.totalRuns,
|
||||
longestRunMs: autoRunStats.longestRunMs || undefined,
|
||||
longestRunDate,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Save registration locally
|
||||
const registration: LeaderboardRegistration = {
|
||||
email: email.trim(),
|
||||
displayName: displayName.trim(),
|
||||
twitterHandle: twitterHandle.trim() || undefined,
|
||||
githubUsername: githubUsername.trim() || undefined,
|
||||
linkedinHandle: linkedinHandle.trim() || undefined,
|
||||
registeredAt: Date.now(),
|
||||
emailConfirmed: !result.requiresConfirmation,
|
||||
lastSubmissionAt: Date.now(),
|
||||
};
|
||||
onSave(registration);
|
||||
|
||||
if (result.requiresConfirmation) {
|
||||
setSubmitState('awaiting_confirmation');
|
||||
setSuccessMessage('Please check your email to confirm your registration. Your submission will be queued for approval once confirmed.');
|
||||
} else {
|
||||
setSubmitState('success');
|
||||
setSuccessMessage('Your stats have been submitted! Your entry is now queued for manual approval.');
|
||||
}
|
||||
} else {
|
||||
setSubmitState('error');
|
||||
setErrorMessage(result.error || result.message || 'Submission failed');
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitState('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||
}
|
||||
}, [isFormValid, email, displayName, githubUsername, twitterHandle, linkedinHandle, badgeLevel, badgeName, autoRunStats, onSave]);
|
||||
|
||||
// Register layer on mount
|
||||
useEffect(() => {
|
||||
const id = registerLayer({
|
||||
type: 'modal',
|
||||
priority: MODAL_PRIORITIES.LEADERBOARD_REGISTRATION,
|
||||
blocksLowerLayers: true,
|
||||
capturesFocus: true,
|
||||
focusTrap: 'strict',
|
||||
ariaLabel: 'Register for Leaderboard',
|
||||
onEscape: () => onCloseRef.current(),
|
||||
});
|
||||
layerIdRef.current = id;
|
||||
|
||||
containerRef.current?.focus();
|
||||
|
||||
return () => {
|
||||
if (layerIdRef.current) {
|
||||
unregisterLayer(layerIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [registerLayer, unregisterLayer]);
|
||||
|
||||
// Handle Enter key for form submission
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && isFormValid && submitState === 'idle') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}, [isFormValid, submitState, handleSubmit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-[9999] animate-in fade-in duration-200"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Register for Leaderboard"
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className="w-[480px] max-h-[90vh] border rounded-lg shadow-2xl overflow-hidden flex flex-col"
|
||||
style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b flex items-center justify-between" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="w-5 h-5" style={{ color: '#FFD700' }} />
|
||||
<h2 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
|
||||
{existingRegistration ? 'Update Leaderboard Registration' : 'Register for Leaderboard'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4 overflow-y-auto">
|
||||
{/* Info text */}
|
||||
<p className="text-sm" style={{ color: theme.colors.textDim }}>
|
||||
Join the global Maestro leaderboard at{' '}
|
||||
<button
|
||||
onClick={() => window.maestro.shell.openExternal('https://runmaestro.ai')}
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
style={{ color: theme.colors.accent }}
|
||||
>
|
||||
runmaestro.ai
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
. Your cumulative AutoRun time and achievements will be displayed.
|
||||
</p>
|
||||
|
||||
{/* Current stats preview */}
|
||||
<div
|
||||
className="p-3 rounded-lg"
|
||||
style={{ backgroundColor: theme.colors.bgActivity, border: `1px solid ${theme.colors.border}` }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Trophy className="w-4 h-4" style={{ color: '#FFD700' }} />
|
||||
<span className="text-xs font-medium" style={{ color: theme.colors.textMain }}>Your Current Stats</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span style={{ color: theme.colors.textDim }}>Badge: </span>
|
||||
<span className="font-medium" style={{ color: theme.colors.accent }}>{badgeName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.textDim }}>Total Runs: </span>
|
||||
<span className="font-medium" style={{ color: theme.colors.textMain }}>{autoRunStats.totalRuns}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form fields */}
|
||||
<div className="space-y-3">
|
||||
{/* Display Name - Required */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-medium mb-1.5" style={{ color: theme.colors.textMain }}>
|
||||
<User className="w-3.5 h-3.5" />
|
||||
Display Name <span style={{ color: theme.colors.error }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="ConductorPedram"
|
||||
className="w-full px-3 py-2 text-sm rounded border outline-none focus:ring-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email - Required */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-xs font-medium mb-1.5" style={{ color: theme.colors.textMain }}>
|
||||
<Mail className="w-3.5 h-3.5" />
|
||||
Email Address <span style={{ color: theme.colors.error }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="conductor@maestro.ai"
|
||||
className="w-full px-3 py-2 text-sm rounded border outline-none focus:ring-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: email && !isValidEmail(email) ? theme.colors.error : theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
{email && !isValidEmail(email) && (
|
||||
<p className="text-xs mt-1" style={{ color: theme.colors.error }}>
|
||||
Please enter a valid email address
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs mt-1" style={{ color: theme.colors.textDim }}>
|
||||
You'll receive a confirmation email to verify your registration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Social handles - Optional */}
|
||||
<div className="pt-2 border-t" style={{ borderColor: theme.colors.border }}>
|
||||
<p className="text-xs font-medium mb-3" style={{ color: theme.colors.textDim }}>
|
||||
Optional: Link your social profiles
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* GitHub */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GithubIcon className="w-4 h-4 flex-shrink-0" style={{ color: theme.colors.textDim }} />
|
||||
<input
|
||||
type="text"
|
||||
value={githubUsername}
|
||||
onChange={(e) => setGithubUsername(e.target.value.replace(/^@/, ''))}
|
||||
placeholder="username"
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded border outline-none focus:ring-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* X/Twitter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<XTwitterIcon className="w-4 h-4 flex-shrink-0" style={{ color: theme.colors.textDim }} />
|
||||
<input
|
||||
type="text"
|
||||
value={twitterHandle}
|
||||
onChange={(e) => setTwitterHandle(e.target.value.replace(/^@/, ''))}
|
||||
placeholder="handle"
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded border outline-none focus:ring-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* LinkedIn */}
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkedInIcon className="w-4 h-4 flex-shrink-0" style={{ color: theme.colors.textDim }} />
|
||||
<input
|
||||
type="text"
|
||||
value={linkedinHandle}
|
||||
onChange={(e) => setLinkedinHandle(e.target.value.replace(/^@/, ''))}
|
||||
placeholder="username"
|
||||
className="flex-1 px-3 py-1.5 text-sm rounded border outline-none focus:ring-1"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
borderColor: theme.colors.border,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
disabled={submitState === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{submitState === 'error' && (
|
||||
<div
|
||||
className="flex items-start gap-2 p-3 rounded-lg"
|
||||
style={{ backgroundColor: `${theme.colors.error}15`, border: `1px solid ${theme.colors.error}30` }}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: theme.colors.error }} />
|
||||
<p className="text-xs" style={{ color: theme.colors.error }}>{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(submitState === 'success' || submitState === 'awaiting_confirmation') && (
|
||||
<div
|
||||
className="flex items-start gap-2 p-3 rounded-lg"
|
||||
style={{ backgroundColor: `${theme.colors.success}15`, border: `1px solid ${theme.colors.success}30` }}
|
||||
>
|
||||
<Check className="w-4 h-4 flex-shrink-0 mt-0.5" style={{ color: theme.colors.success }} />
|
||||
<p className="text-xs" style={{ color: theme.colors.success }}>{successMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t flex justify-end gap-2" style={{ borderColor: theme.colors.border }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
disabled={submitState === 'submitting'}
|
||||
>
|
||||
{submitState === 'success' || submitState === 'awaiting_confirmation' ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{submitState !== 'success' && submitState !== 'awaiting_confirmation' && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormValid || submitState === 'submitting'}
|
||||
className="px-4 py-2 text-sm font-medium rounded transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent,
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{submitState === 'submitting' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Submitting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trophy className="w-4 h-4" />
|
||||
{existingRegistration ? 'Update & Submit' : 'Register'}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LeaderboardRegistrationModal;
|
||||
@@ -53,7 +53,7 @@ interface MainPanelProps {
|
||||
atMentionSuggestions?: Array<{ value: string; type: 'file' | 'folder'; displayText: string; fullPath: string }>;
|
||||
selectedAtMentionIndex?: number;
|
||||
previewFile: { name: string; content: string; path: string } | null;
|
||||
markdownRawMode: boolean;
|
||||
markdownEditMode: boolean;
|
||||
shortcuts: Record<string, Shortcut>;
|
||||
rightPanelOpen: boolean;
|
||||
maxOutputLines: number;
|
||||
@@ -93,7 +93,7 @@ interface MainPanelProps {
|
||||
setAtMentionStartIndex?: (index: number) => void;
|
||||
setSelectedAtMentionIndex?: (index: number) => void;
|
||||
setPreviewFile: (file: { name: string; content: string; path: string } | null) => void;
|
||||
setMarkdownRawMode: (mode: boolean) => void;
|
||||
setMarkdownEditMode: (mode: boolean) => void;
|
||||
setAboutModalOpen: (open: boolean) => void;
|
||||
setRightPanelOpen: (open: boolean) => void;
|
||||
setGitLogOpen: (open: boolean) => void;
|
||||
@@ -148,6 +148,8 @@ interface MainPanelProps {
|
||||
onInputBlur?: () => void;
|
||||
// Prompt composer modal
|
||||
onOpenPromptComposer?: () => void;
|
||||
// Replay a user message (AI mode)
|
||||
onReplayMessage?: (text: string, images?: string[]) => void;
|
||||
}
|
||||
|
||||
export function MainPanel(props: MainPanelProps) {
|
||||
@@ -159,12 +161,12 @@ export function MainPanel(props: MainPanelProps) {
|
||||
setTabCompletionOpen, setSelectedTabCompletionIndex, setTabCompletionFilter,
|
||||
atMentionOpen, atMentionFilter, atMentionStartIndex, atMentionSuggestions, selectedAtMentionIndex,
|
||||
setAtMentionOpen, setAtMentionFilter, setAtMentionStartIndex, setSelectedAtMentionIndex,
|
||||
previewFile, markdownRawMode, shortcuts, rightPanelOpen, maxOutputLines, gitDiffPreview,
|
||||
previewFile, markdownEditMode, shortcuts, rightPanelOpen, maxOutputLines, gitDiffPreview,
|
||||
fileTreeFilterOpen, logLevel, setGitDiffPreview, setLogViewerOpen, setAgentSessionsOpen, setActiveClaudeSessionId,
|
||||
onResumeClaudeSession, onNewClaudeSession, setActiveFocus, setOutputSearchOpen, setOutputSearchQuery,
|
||||
setInputValue, setEnterToSendAI, setEnterToSendTerminal, setStagedImages, setLightboxImage, setCommandHistoryOpen,
|
||||
setCommandHistoryFilter, setCommandHistorySelectedIndex, setSlashCommandOpen,
|
||||
setSelectedSlashCommandIndex, setPreviewFile, setMarkdownRawMode,
|
||||
setSelectedSlashCommandIndex, setPreviewFile, setMarkdownEditMode,
|
||||
setAboutModalOpen, setRightPanelOpen, setGitLogOpen, inputRef, logsEndRef, terminalOutputRef,
|
||||
fileTreeContainerRef, fileTreeFilterInputRef, toggleInputMode, processInput, handleInterrupt,
|
||||
handleInputKeyDown, handlePaste, handleDrop, getContextColor, setActiveSessionId,
|
||||
@@ -273,16 +275,49 @@ export function MainPanel(props: MainPanelProps) {
|
||||
}, [activeSession?.isGitRepo, activeSession?.inputMode, activeSession?.shellCwd, activeSession?.cwd]);
|
||||
|
||||
// Fetch git info when session changes or becomes a git repo
|
||||
// Pauses polling when window is hidden to save CPU
|
||||
useEffect(() => {
|
||||
if (!activeSession?.isGitRepo) {
|
||||
setGitInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const startPolling = () => {
|
||||
if (!interval) {
|
||||
interval = setInterval(fetchGitInfo, 30000);
|
||||
}
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopPolling();
|
||||
} else {
|
||||
fetchGitInfo();
|
||||
startPolling();
|
||||
}
|
||||
};
|
||||
|
||||
fetchGitInfo();
|
||||
// Refresh git info every 30 seconds (reduced from 10s for performance)
|
||||
const interval = setInterval(fetchGitInfo, 30000);
|
||||
return () => clearInterval(interval);
|
||||
|
||||
if (!document.hidden) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
stopPolling();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [activeSession?.id, activeSession?.isGitRepo, fetchGitInfo]);
|
||||
|
||||
// Cleanup hover timeouts on unmount
|
||||
@@ -806,8 +841,13 @@ export function MainPanel(props: MainPanelProps) {
|
||||
}, 0);
|
||||
}}
|
||||
theme={theme}
|
||||
markdownRawMode={markdownRawMode}
|
||||
setMarkdownRawMode={setMarkdownRawMode}
|
||||
markdownEditMode={markdownEditMode}
|
||||
setMarkdownEditMode={setMarkdownEditMode}
|
||||
onSave={async (path, content) => {
|
||||
await window.maestro.fs.writeFile(path, content);
|
||||
// Update the preview file content after save
|
||||
setPreviewFile({ ...previewFile, content });
|
||||
}}
|
||||
shortcuts={shortcuts}
|
||||
/>
|
||||
</div>
|
||||
@@ -841,8 +881,9 @@ export function MainPanel(props: MainPanelProps) {
|
||||
? activeTab?.scrollTop
|
||||
: activeSession.terminalScrollTop
|
||||
}
|
||||
markdownRawMode={markdownRawMode}
|
||||
setMarkdownRawMode={setMarkdownRawMode}
|
||||
markdownEditMode={markdownEditMode}
|
||||
setMarkdownEditMode={setMarkdownEditMode}
|
||||
onReplayMessage={props.onReplayMessage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -59,8 +59,8 @@ interface QuickActionsModalProps {
|
||||
setPlaygroundOpen?: (open: boolean) => void;
|
||||
onRefreshGitFileState?: () => Promise<void>;
|
||||
onDebugReleaseQueuedItem?: () => void;
|
||||
markdownRawMode?: boolean;
|
||||
onToggleMarkdownRawMode?: () => void;
|
||||
markdownEditMode?: boolean;
|
||||
onToggleMarkdownEditMode?: () => void;
|
||||
setUpdateCheckModalOpen?: (open: boolean) => void;
|
||||
openWizard?: () => void;
|
||||
wizardGoToStep?: (step: WizardStep) => void;
|
||||
@@ -80,7 +80,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
|
||||
setShortcutsHelpOpen, setAboutModalOpen, setLogViewerOpen, setProcessMonitorOpen,
|
||||
setAgentSessionsOpen, setActiveClaudeSessionId, setGitDiffPreview, setGitLogOpen,
|
||||
onRenameTab, onToggleReadOnlyMode, onOpenTabSwitcher, tabShortcuts, isAiMode, setPlaygroundOpen, onRefreshGitFileState,
|
||||
onDebugReleaseQueuedItem, markdownRawMode, onToggleMarkdownRawMode, setUpdateCheckModalOpen, openWizard, wizardGoToStep, setDebugWizardModalOpen, startTour
|
||||
onDebugReleaseQueuedItem, markdownEditMode, onToggleMarkdownEditMode, setUpdateCheckModalOpen, openWizard, wizardGoToStep, setDebugWizardModalOpen, startTour
|
||||
} = props;
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -225,7 +225,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
|
||||
...(isAiMode && onOpenTabSwitcher ? [{ id: 'tabSwitcher', label: 'Tab Switcher', shortcut: tabShortcuts?.tabSwitcher, action: () => { onOpenTabSwitcher(); setQuickActionOpen(false); } }] : []),
|
||||
...(isAiMode && onRenameTab ? [{ id: 'renameTab', label: 'Rename Tab', shortcut: tabShortcuts?.renameTab, action: () => { onRenameTab(); setQuickActionOpen(false); } }] : []),
|
||||
...(isAiMode && onToggleReadOnlyMode ? [{ id: 'toggleReadOnly', label: 'Toggle Read-Only Mode', shortcut: tabShortcuts?.toggleReadOnlyMode, action: () => { onToggleReadOnlyMode(); setQuickActionOpen(false); } }] : []),
|
||||
...(isAiMode && onToggleMarkdownRawMode ? [{ id: 'toggleMarkdown', label: markdownRawMode ? 'Show Formatted Markdown' : 'Show Raw Markdown', shortcut: shortcuts.toggleMarkdownMode, subtext: markdownRawMode ? 'Currently showing plain text' : 'Currently showing formatted', action: () => { onToggleMarkdownRawMode(); setQuickActionOpen(false); } }] : []),
|
||||
...(isAiMode && onToggleMarkdownEditMode ? [{ id: 'toggleMarkdown', label: 'Toggle Edit/Preview', shortcut: shortcuts.toggleMarkdownMode, subtext: markdownEditMode ? 'Currently in edit mode' : 'Currently in preview mode', action: () => { onToggleMarkdownEditMode(); setQuickActionOpen(false); } }] : []),
|
||||
...(activeSession ? [{ id: 'kill', label: `Remove Agent: ${activeSession.name}`, shortcut: shortcuts.killInstance, action: () => deleteSession(activeSessionId) }] : []),
|
||||
{ id: 'settings', label: 'Settings', shortcut: shortcuts.settings, action: () => { setSettingsModalOpen(true); setQuickActionOpen(false); } },
|
||||
{ id: 'theme', label: 'Change Theme', action: () => { setSettingsModalOpen(true); setSettingsTab('theme'); setQuickActionOpen(false); } },
|
||||
@@ -257,6 +257,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
|
||||
} }] : []),
|
||||
{ id: 'devtools', label: 'Toggle JavaScript Console', action: () => { window.maestro.devtools.toggle(); setQuickActionOpen(false); } },
|
||||
{ id: 'about', label: 'About Maestro', action: () => { setAboutModalOpen(true); setQuickActionOpen(false); } },
|
||||
{ id: 'discord', label: 'Join Discord', subtext: 'Join the Maestro community', action: () => { window.maestro.shell.openExternal('https://discord.gg/86crXbGb'); setQuickActionOpen(false); } },
|
||||
...(setUpdateCheckModalOpen ? [{ id: 'updateCheck', label: 'Check for Updates', action: () => { setUpdateCheckModalOpen(true); setQuickActionOpen(false); } }] : []),
|
||||
{ id: 'goToFiles', label: 'Go to Files Tab', action: () => { setRightPanelOpen(true); setActiveRightTab('files'); setQuickActionOpen(false); } },
|
||||
{ id: 'goToHistory', label: 'Go to History Tab', action: () => { setRightPanelOpen(true); setActiveRightTab('history'); setQuickActionOpen(false); } },
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Wand2, Plus, Settings, ChevronRight, ChevronDown, Activity, X, Keyboard,
|
||||
Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, Info, FileText, GitBranch, Bot, Clock,
|
||||
ScrollText, Cpu, Menu, Bookmark, Trophy, Trash2, Edit3, FolderInput, Download, Compass
|
||||
ScrollText, Cpu, Menu, Bookmark, Trophy, Trash2, Edit3, FolderInput, Download, Compass, Globe
|
||||
} from 'lucide-react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import type { Session, Group, Theme, Shortcut, AutoRunStats } from '../types';
|
||||
@@ -1167,6 +1167,17 @@ export function SessionList(props: SessionListProps) {
|
||||
<div className="text-xs" style={{ color: theme.colors.textDim }}>Get the latest version</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { window.maestro.shell.openExternal('https://runmaestro.ai'); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
|
||||
>
|
||||
<Globe className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium" style={{ color: theme.colors.textMain }}>Maestro Website</div>
|
||||
<div className="text-xs" style={{ color: theme.colors.textDim }}>Visit runmaestro.ai</div>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setAboutModalOpen(true); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
|
||||
@@ -1292,6 +1303,17 @@ export function SessionList(props: SessionListProps) {
|
||||
<div className="text-xs" style={{ color: theme.colors.textDim }}>Get the latest version</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { window.maestro.shell.openExternal('https://runmaestro.ai'); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
|
||||
>
|
||||
<Globe className="w-5 h-5" style={{ color: theme.colors.accent }} />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium" style={{ color: theme.colors.textMain }}>Maestro Website</div>
|
||||
<div className="text-xs" style={{ color: theme.colors.textDim }}>Visit runmaestro.ai</div>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4" style={{ color: theme.colors.textDim }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setAboutModalOpen(true); setMenuOpen(false); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-white/10 transition-colors text-left"
|
||||
@@ -1446,31 +1468,33 @@ export function SessionList(props: SessionListProps) {
|
||||
AUTO
|
||||
</div>
|
||||
)}
|
||||
{/* AI Status Indicator */}
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'connecting' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={
|
||||
session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' :
|
||||
session.state === 'idle' ? 'Ready and waiting' :
|
||||
session.state === 'busy' ? (session.cliActivity ? `CLI: Running playbook "${session.cliActivity.playbookName}"` : 'Agent is thinking') :
|
||||
session.state === 'connecting' ? 'Attempting to establish connection' :
|
||||
session.state === 'error' ? 'No connection with agent' :
|
||||
'Waiting for input'
|
||||
}
|
||||
/>
|
||||
{/* Unread Message Indicator */}
|
||||
{activeSessionId !== session.id && session.aiTabs?.some(tab => tab.hasUnread) && (
|
||||
{/* AI Status Indicator with Unread Badge */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: theme.colors.success }}
|
||||
title="Unread messages"
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'connecting' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={
|
||||
session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' :
|
||||
session.state === 'idle' ? 'Ready and waiting' :
|
||||
session.state === 'busy' ? (session.cliActivity ? `CLI: Running playbook "${session.cliActivity.playbookName}"` : 'Agent is thinking') :
|
||||
session.state === 'connecting' ? 'Attempting to establish connection' :
|
||||
session.state === 'error' ? 'No connection with agent' :
|
||||
'Waiting for input'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Unread Notification Badge */}
|
||||
{activeSessionId !== session.id && session.aiTabs?.some(tab => tab.hasUnread) && (
|
||||
<div
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.error }}
|
||||
title="Unread messages"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1742,31 +1766,33 @@ export function SessionList(props: SessionListProps) {
|
||||
AUTO
|
||||
</div>
|
||||
)}
|
||||
{/* AI Status Indicator */}
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'connecting' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={
|
||||
session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' :
|
||||
session.state === 'idle' ? 'Ready and waiting' :
|
||||
session.state === 'busy' ? (session.cliActivity ? `CLI: Running playbook "${session.cliActivity.playbookName}"` : 'Agent is thinking') :
|
||||
session.state === 'connecting' ? 'Attempting to establish connection' :
|
||||
session.state === 'error' ? 'No connection with agent' :
|
||||
'Waiting for input'
|
||||
}
|
||||
/>
|
||||
{/* Unread Message Indicator */}
|
||||
{activeSessionId !== session.id && session.aiTabs?.some(tab => tab.hasUnread) && (
|
||||
{/* AI Status Indicator with Unread Badge */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: theme.colors.success }}
|
||||
title="Unread messages"
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'connecting' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={
|
||||
session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' :
|
||||
session.state === 'idle' ? 'Ready and waiting' :
|
||||
session.state === 'busy' ? (session.cliActivity ? `CLI: Running playbook "${session.cliActivity.playbookName}"` : 'Agent is thinking') :
|
||||
session.state === 'connecting' ? 'Attempting to establish connection' :
|
||||
session.state === 'error' ? 'No connection with agent' :
|
||||
'Waiting for input'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Unread Notification Badge */}
|
||||
{activeSessionId !== session.id && session.aiTabs?.some(tab => tab.hasUnread) && (
|
||||
<div
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.error }}
|
||||
title="Unread messages"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1992,24 +2018,26 @@ export function SessionList(props: SessionListProps) {
|
||||
AUTO
|
||||
</div>
|
||||
)}
|
||||
{/* AI Status Indicator */}
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'busy' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' : undefined}
|
||||
/>
|
||||
{/* Unread Message Indicator */}
|
||||
{activeSessionId !== session.id && session.aiTabs?.some(tab => tab.hasUnread) && (
|
||||
{/* AI Status Indicator with Unread Badge */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: theme.colors.success }}
|
||||
title="Unread messages"
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'busy' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' : undefined}
|
||||
/>
|
||||
)}
|
||||
{/* Unread Notification Badge */}
|
||||
{activeSessionId !== session.id && session.aiTabs?.some(tab => tab.hasUnread) && (
|
||||
<div
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.error }}
|
||||
title="Unread messages"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2153,24 +2181,26 @@ export function SessionList(props: SessionListProps) {
|
||||
AUTO
|
||||
</div>
|
||||
)}
|
||||
{/* AI Status Indicator */}
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'busy' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' : undefined}
|
||||
/>
|
||||
{/* Unread Message Indicator */}
|
||||
{activeSessionId !== session.id && session.aiTabs?.some(tab => tab.hasUnread) && (
|
||||
{/* AI Status Indicator with Unread Badge */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full animate-pulse"
|
||||
style={{ backgroundColor: theme.colors.success }}
|
||||
title="Unread messages"
|
||||
className={`w-2 h-2 rounded-full ${session.state === 'busy' ? 'animate-pulse' : ''}`}
|
||||
style={
|
||||
session.toolType === 'claude' && !session.claudeSessionId
|
||||
? { border: `1.5px solid ${theme.colors.textDim}`, backgroundColor: 'transparent' }
|
||||
: { backgroundColor: getStatusColor(session.state, theme) }
|
||||
}
|
||||
title={session.toolType === 'claude' && !session.claudeSessionId ? 'No active Claude session' : undefined}
|
||||
/>
|
||||
)}
|
||||
{/* Unread Notification Badge */}
|
||||
{activeSessionId !== session.id && session.aiTabs?.some(tab => tab.hasUnread) && (
|
||||
<div
|
||||
className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.error }}
|
||||
title="Unread messages"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1623,6 +1623,18 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
Toast Notification Duration
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => props.setToastDuration(-1)}
|
||||
className={`flex-1 py-2 px-3 rounded border transition-all ${props.toastDuration === -1 ? 'ring-2' : ''}`}
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: props.toastDuration === -1 ? theme.colors.accentDim : 'transparent',
|
||||
ringColor: theme.colors.accent,
|
||||
color: theme.colors.textMain
|
||||
}}
|
||||
>
|
||||
Off
|
||||
</button>
|
||||
<button
|
||||
onClick={() => props.setToastDuration(5)}
|
||||
className={`flex-1 py-2 px-3 rounded border transition-all ${props.toastDuration === 5 ? 'ring-2' : ''}`}
|
||||
@@ -1685,7 +1697,7 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs opacity-50 mt-2">
|
||||
How long toast notifications remain on screen. "Never" means they stay until manually dismissed.
|
||||
How long toast notifications remain on screen. "Off" disables them entirely. "Never" means they stay until manually dismissed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1712,11 +1724,4 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparator: only re-render if key display props change
|
||||
// Callbacks are stable (wrapped in useCallback in App.tsx)
|
||||
return prevProps.isOpen === nextProps.isOpen &&
|
||||
prevProps.theme === nextProps.theme &&
|
||||
prevProps.activeThemeId === nextProps.activeThemeId &&
|
||||
prevProps.initialTab === nextProps.initialTab;
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ interface StandingOvationOverlayProps {
|
||||
recordTimeMs?: number;
|
||||
cumulativeTimeMs: number;
|
||||
onClose: () => void;
|
||||
onOpenLeaderboardRegistration?: () => void;
|
||||
isLeaderboardRegistered?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +32,8 @@ export function StandingOvationOverlay({
|
||||
recordTimeMs,
|
||||
cumulativeTimeMs,
|
||||
onClose,
|
||||
onOpenLeaderboardRegistration,
|
||||
isLeaderboardRegistered,
|
||||
}: StandingOvationOverlayProps) {
|
||||
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
||||
const layerIdRef = useRef<string>();
|
||||
@@ -616,6 +620,25 @@ export function StandingOvationOverlay({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Registration */}
|
||||
{onOpenLeaderboardRegistration && !isLeaderboardRegistered && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenLeaderboardRegistration();
|
||||
}}
|
||||
className="w-full py-2.5 rounded-lg font-medium transition-all flex items-center justify-center gap-2 hover:opacity-90"
|
||||
style={{
|
||||
backgroundColor: `${goldColor}20`,
|
||||
color: goldColor,
|
||||
border: `1px solid ${goldColor}60`,
|
||||
}}
|
||||
>
|
||||
<Trophy className="w-4 h-4" />
|
||||
Join Global Leaderboard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Search, Star } from 'lucide-react';
|
||||
import type { AITab, Theme, Shortcut } from '../types';
|
||||
import { fuzzyMatchWithScore } from '../utils/search';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
@@ -158,7 +158,7 @@ function ContextGauge({ percentage, theme, size = 36 }: { percentage: number; th
|
||||
);
|
||||
}
|
||||
|
||||
type ViewMode = 'open' | 'all-named';
|
||||
type ViewMode = 'open' | 'all-named' | 'starred';
|
||||
|
||||
/**
|
||||
* Tab Switcher Modal - Quick navigation between AI tabs with fuzzy search.
|
||||
@@ -281,15 +281,40 @@ export function TabSwitcherModal({
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
return sorted.map(tab => ({ type: 'open' as const, tab }));
|
||||
} else if (viewMode === 'starred') {
|
||||
// Starred mode - show all starred sessions (open or closed) for the current project
|
||||
const items: ListItem[] = [];
|
||||
|
||||
// Add starred open tabs
|
||||
for (const tab of tabs) {
|
||||
if (tab.starred && tab.claudeSessionId) {
|
||||
items.push({ type: 'open' as const, tab });
|
||||
}
|
||||
}
|
||||
|
||||
// Add starred closed sessions from the same project (not currently open)
|
||||
for (const session of namedSessions) {
|
||||
if (session.starred && session.projectPath === projectRoot && !openTabSessionIds.has(session.claudeSessionId)) {
|
||||
items.push({ type: 'named' as const, session });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by display name
|
||||
items.sort((a, b) => {
|
||||
const nameA = a.type === 'open' ? getTabDisplayName(a.tab).toLowerCase() : a.session.sessionName.toLowerCase();
|
||||
const nameB = b.type === 'open' ? getTabDisplayName(b.tab).toLowerCase() : b.session.sessionName.toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
return items;
|
||||
} else {
|
||||
// All Named mode - show sessions with claudeSessionId for the CURRENT PROJECT (including open ones)
|
||||
// All Named mode - show only sessions that have been given a custom name
|
||||
// For open tabs, use the 'open' type so we get usage stats; for closed ones use 'named'
|
||||
const items: ListItem[] = [];
|
||||
|
||||
// Add open tabs that have a Claude session (whether named or not)
|
||||
// This ensures all active sessions are searchable, not just those with custom names
|
||||
// Add open tabs that have a custom name (not just UUID-based display names)
|
||||
for (const tab of tabs) {
|
||||
if (tab.claudeSessionId) {
|
||||
if (tab.claudeSessionId && tab.name) {
|
||||
items.push({ type: 'open' as const, tab });
|
||||
}
|
||||
}
|
||||
@@ -353,7 +378,11 @@ export function TabSwitcherModal({
|
||||
}, [search, viewMode]);
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setViewMode(prev => prev === 'open' ? 'all-named' : 'open');
|
||||
setViewMode(prev => {
|
||||
if (prev === 'open') return 'all-named';
|
||||
if (prev === 'all-named') return 'starred';
|
||||
return 'open';
|
||||
});
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: ListItem) => {
|
||||
@@ -411,7 +440,7 @@ export function TabSwitcherModal({
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 bg-transparent outline-none text-lg placeholder-opacity-50"
|
||||
placeholder={viewMode === 'open' ? "Search open tabs..." : "Search named sessions..."}
|
||||
placeholder={viewMode === 'open' ? "Search open tabs..." : viewMode === 'starred' ? "Search starred sessions..." : "Search named sessions..."}
|
||||
style={{ color: theme.colors.textMain }}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
@@ -452,7 +481,18 @@ export function TabSwitcherModal({
|
||||
color: viewMode === 'all-named' ? theme.colors.accentForeground : theme.colors.textDim
|
||||
}}
|
||||
>
|
||||
All Named ({tabs.filter(t => t.name && t.claudeSessionId).length + namedSessions.filter(s => s.projectPath === projectRoot && !openTabSessionIds.has(s.claudeSessionId)).length})
|
||||
All Named ({tabs.filter(t => t.claudeSessionId && t.name).length + namedSessions.filter(s => s.projectPath === projectRoot && !openTabSessionIds.has(s.claudeSessionId)).length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('starred')}
|
||||
className="px-3 py-1 rounded-full text-xs font-medium transition-colors flex items-center gap-1"
|
||||
style={{
|
||||
backgroundColor: viewMode === 'starred' ? theme.colors.accent : theme.colors.bgMain,
|
||||
color: viewMode === 'starred' ? theme.colors.accentForeground : theme.colors.textDim
|
||||
}}
|
||||
>
|
||||
<Star className="w-3 h-3" style={{ fill: viewMode === 'starred' ? 'currentColor' : 'none' }} />
|
||||
Starred ({tabs.filter(t => t.starred && t.claudeSessionId).length + namedSessions.filter(s => s.starred && s.projectPath === projectRoot && !openTabSessionIds.has(s.claudeSessionId)).length})
|
||||
</button>
|
||||
<span className="text-[10px] opacity-50 ml-auto" style={{ color: theme.colors.textDim }}>
|
||||
Tab to switch
|
||||
@@ -634,7 +674,7 @@ export function TabSwitcherModal({
|
||||
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="px-4 py-4 text-center opacity-50 text-sm" style={{ color: theme.colors.textDim }}>
|
||||
{viewMode === 'open' ? 'No open tabs' : 'No named sessions found'}
|
||||
{viewMode === 'open' ? 'No open tabs' : viewMode === 'starred' ? 'No starred sessions' : 'No named sessions found'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -644,7 +684,7 @@ export function TabSwitcherModal({
|
||||
className="px-4 py-2 border-t text-xs flex items-center justify-between"
|
||||
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
|
||||
>
|
||||
<span>{filteredItems.length} {viewMode === 'open' ? 'tabs' : 'sessions'}</span>
|
||||
<span>{filteredItems.length} {viewMode === 'open' ? 'tabs' : viewMode === 'starred' ? 'starred' : 'sessions'}</span>
|
||||
<span>↑↓ navigate • Enter select • ⌘1-9 quick select</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useRef, useEffect, useMemo, forwardRef, useState, useCallback, memo } from 'react';
|
||||
import { Activity, X, ChevronDown, ChevronUp, Filter, PlusCircle, MinusCircle, Trash2, Copy, Volume2, Square, Check, ArrowDown, Eye, FileText, Clipboard } from 'lucide-react';
|
||||
import { Activity, X, ChevronDown, ChevronUp, Filter, PlusCircle, MinusCircle, Trash2, Copy, Volume2, Square, Check, ArrowDown, Eye, FileText, Clipboard, RotateCcw } from 'lucide-react';
|
||||
import type { Session, Theme, LogEntry } from '../types';
|
||||
import Convert from 'ansi-to-html';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -243,9 +243,11 @@ interface LogItemProps {
|
||||
audioFeedbackCommand?: string;
|
||||
// ANSI converter
|
||||
ansiConverter: Convert;
|
||||
// Markdown rendering mode for AI responses
|
||||
markdownRawMode: boolean;
|
||||
onToggleMarkdownRawMode: () => void;
|
||||
// Markdown rendering mode for AI responses (when true, shows raw text)
|
||||
markdownEditMode: boolean;
|
||||
onToggleMarkdownEditMode: () => void;
|
||||
// Replay message callback (AI mode only)
|
||||
onReplayMessage?: (text: string, images?: string[]) => void;
|
||||
}
|
||||
|
||||
const LogItemComponent = memo(({
|
||||
@@ -278,8 +280,9 @@ const LogItemComponent = memo(({
|
||||
speakingLogId,
|
||||
audioFeedbackCommand,
|
||||
ansiConverter,
|
||||
markdownRawMode,
|
||||
onToggleMarkdownRawMode,
|
||||
markdownEditMode,
|
||||
onToggleMarkdownEditMode,
|
||||
onReplayMessage,
|
||||
}: LogItemProps) => {
|
||||
// Ref for the log item container - used for scroll-into-view on expand
|
||||
const logItemRef = useRef<HTMLDivElement>(null);
|
||||
@@ -620,7 +623,7 @@ const LogItemComponent = memo(({
|
||||
// Content sanitized with DOMPurify above
|
||||
// Horizontal scroll for terminal output to preserve column alignment
|
||||
<div className="overflow-x-auto scrollbar-thin" dangerouslySetInnerHTML={{ __html: displayHtmlContent }} />
|
||||
) : isAIMode && !markdownRawMode ? (
|
||||
) : isAIMode && !markdownEditMode ? (
|
||||
// Collapsed markdown preview with rendered markdown
|
||||
// Note: prose styles are injected once at TerminalOutput container level for performance
|
||||
<div className="prose prose-sm max-w-none" style={{ color: theme.colors.textMain, lineHeight: 1.4, paddingLeft: '0.5em' }}>
|
||||
@@ -734,7 +737,7 @@ const LogItemComponent = memo(({
|
||||
</div>
|
||||
<div>{highlightMatches(filteredText, outputSearchQuery)}</div>
|
||||
</div>
|
||||
) : isAIMode && !markdownRawMode ? (
|
||||
) : isAIMode && !markdownEditMode ? (
|
||||
// Expanded markdown rendering
|
||||
// Note: prose styles are injected once at TerminalOutput container level for performance
|
||||
<div className="prose prose-sm max-w-none text-sm" style={{ color: theme.colors.textMain, lineHeight: 1.4, paddingLeft: '0.5em' }}>
|
||||
@@ -830,7 +833,7 @@ const LogItemComponent = memo(({
|
||||
{highlightMatches(filteredText, outputSearchQuery)}
|
||||
</div>
|
||||
</div>
|
||||
) : isAIMode && !markdownRawMode ? (
|
||||
) : isAIMode && !markdownEditMode ? (
|
||||
// Rendered markdown for AI responses
|
||||
// Note: prose styles are injected once at TerminalOutput container level for performance
|
||||
<div className="prose prose-sm max-w-none text-sm" style={{ color: theme.colors.textMain, lineHeight: 1.4, paddingLeft: '0.5em' }}>
|
||||
@@ -891,12 +894,12 @@ const LogItemComponent = memo(({
|
||||
{/* Markdown toggle button for AI responses */}
|
||||
{log.source !== 'user' && isAIMode && (
|
||||
<button
|
||||
onClick={onToggleMarkdownRawMode}
|
||||
onClick={onToggleMarkdownEditMode}
|
||||
className="p-1.5 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100"
|
||||
style={{ color: markdownRawMode ? theme.colors.accent : theme.colors.textDim }}
|
||||
title={markdownRawMode ? "Show formatted (⌘E)" : "Show plain text (⌘E)"}
|
||||
style={{ color: markdownEditMode ? theme.colors.accent : theme.colors.textDim }}
|
||||
title={markdownEditMode ? "Show formatted (⌘E)" : "Show plain text (⌘E)"}
|
||||
>
|
||||
{markdownRawMode ? <Eye className="w-4 h-4" /> : <FileText className="w-4 h-4" />}
|
||||
{markdownEditMode ? <Eye className="w-4 h-4" /> : <FileText className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Speak/Stop Button - only show for non-user messages when TTS is configured */}
|
||||
@@ -921,6 +924,17 @@ const LogItemComponent = memo(({
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{/* Replay button for user messages in AI mode */}
|
||||
{isUserMessage && isAIMode && onReplayMessage && (
|
||||
<button
|
||||
onClick={() => onReplayMessage(log.text, log.images)}
|
||||
className="p-1.5 rounded opacity-0 group-hover:opacity-50 hover:!opacity-100"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Replay message"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{/* Copy to Clipboard Button */}
|
||||
<button
|
||||
onClick={() => copyToClipboard(log.text)}
|
||||
@@ -989,6 +1003,7 @@ const LogItemComponent = memo(({
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison - only re-render if these specific props change
|
||||
// IMPORTANT: Include ALL props that affect visual rendering
|
||||
return (
|
||||
prevProps.log.id === nextProps.log.id &&
|
||||
prevProps.log.text === nextProps.log.text &&
|
||||
@@ -1004,7 +1019,8 @@ const LogItemComponent = memo(({
|
||||
prevProps.outputSearchQuery === nextProps.outputSearchQuery &&
|
||||
prevProps.theme === nextProps.theme &&
|
||||
prevProps.maxOutputLines === nextProps.maxOutputLines &&
|
||||
prevProps.markdownRawMode === nextProps.markdownRawMode
|
||||
prevProps.markdownEditMode === nextProps.markdownEditMode &&
|
||||
prevProps.fontFamily === nextProps.fontFamily
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1068,8 +1084,9 @@ interface TerminalOutputProps {
|
||||
audioFeedbackCommand?: string; // TTS command for speech synthesis
|
||||
onScrollPositionChange?: (scrollTop: number) => void; // Callback to save scroll position
|
||||
initialScrollTop?: number; // Initial scroll position to restore
|
||||
markdownRawMode: boolean; // Whether to show raw markdown or rendered markdown for AI responses
|
||||
setMarkdownRawMode: (value: boolean) => void; // Toggle markdown raw mode
|
||||
markdownEditMode: boolean; // Whether to show raw markdown or rendered markdown for AI responses
|
||||
setMarkdownEditMode: (value: boolean) => void; // Toggle markdown mode
|
||||
onReplayMessage?: (text: string, images?: string[]) => void; // Replay a user message
|
||||
}
|
||||
|
||||
export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((props, ref) => {
|
||||
@@ -1078,7 +1095,7 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
setOutputSearchOpen, setOutputSearchQuery, setActiveFocus, setLightboxImage,
|
||||
inputRef, logsEndRef, maxOutputLines, onDeleteLog, onRemoveQueuedItem, onInterrupt,
|
||||
audioFeedbackCommand, onScrollPositionChange, initialScrollTop,
|
||||
markdownRawMode, setMarkdownRawMode
|
||||
markdownEditMode, setMarkdownEditMode, onReplayMessage
|
||||
} = props;
|
||||
|
||||
// Use the forwarded ref if provided, otherwise create a local one
|
||||
@@ -1308,10 +1325,10 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
});
|
||||
}, [setLocalFilterQuery]);
|
||||
|
||||
// Callback to toggle markdown raw mode
|
||||
const toggleMarkdownRawMode = useCallback(() => {
|
||||
setMarkdownRawMode(!markdownRawMode);
|
||||
}, [markdownRawMode, setMarkdownRawMode]);
|
||||
// Callback to toggle markdown mode
|
||||
const toggleMarkdownEditMode = useCallback(() => {
|
||||
setMarkdownEditMode(!markdownEditMode);
|
||||
}, [markdownEditMode, setMarkdownEditMode]);
|
||||
|
||||
// Auto-focus on search input when opened
|
||||
useEffect(() => {
|
||||
@@ -1728,8 +1745,9 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
|
||||
speakingLogId={speakingLogId}
|
||||
audioFeedbackCommand={audioFeedbackCommand}
|
||||
ansiConverter={ansiConverter}
|
||||
markdownRawMode={markdownRawMode}
|
||||
onToggleMarkdownRawMode={toggleMarkdownRawMode}
|
||||
markdownEditMode={markdownEditMode}
|
||||
onToggleMarkdownEditMode={toggleMarkdownEditMode}
|
||||
onReplayMessage={onReplayMessage}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -86,6 +86,9 @@ export const MODAL_PRIORITIES = {
|
||||
/** Keyboard shortcuts help modal */
|
||||
SHORTCUTS_HELP: 650,
|
||||
|
||||
/** Leaderboard registration modal */
|
||||
LEADERBOARD_REGISTRATION: 620,
|
||||
|
||||
/** About/info modal */
|
||||
ABOUT: 600,
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const DEFAULT_SHORTCUTS: Record<string, Shortcut> = {
|
||||
goToHistory: { id: 'goToHistory', label: 'Go to History Tab', keys: ['Meta', 'Shift', 'h'] },
|
||||
goToAutoRun: { id: 'goToAutoRun', label: 'Go to Auto Run Tab', keys: ['Meta', 'Shift', '1'] },
|
||||
copyFilePath: { id: 'copyFilePath', label: 'Copy File Path (in Preview)', keys: ['Meta', 'p'] },
|
||||
toggleMarkdownMode: { id: 'toggleMarkdownMode', label: 'Toggle Markdown Raw/Preview', keys: ['Meta', 'e'] },
|
||||
toggleMarkdownMode: { id: 'toggleMarkdownMode', label: 'Toggle Edit/Preview', keys: ['Meta', 'e'] },
|
||||
focusInput: { id: 'focusInput', label: 'Focus Input Field', keys: ['Meta', '.'] },
|
||||
focusSidebar: { id: 'focusSidebar', label: 'Focus Left Panel', keys: ['Meta', 'Shift', 'a'] },
|
||||
viewGitDiff: { id: 'viewGitDiff', label: 'View Git Diff', keys: ['Meta', 'Shift', 'd'] },
|
||||
|
||||
@@ -56,6 +56,10 @@ export function ToastProvider({ children, defaultDuration: initialDuration = 20
|
||||
}, []);
|
||||
|
||||
const addToast = useCallback((toast: Omit<Toast, 'id' | 'timestamp'>) => {
|
||||
// If defaultDuration is -1, toasts are disabled entirely - skip showing toast UI
|
||||
// but still log, speak, and show OS notification
|
||||
const toastsDisabled = defaultDuration === -1;
|
||||
|
||||
const id = `toast-${Date.now()}-${toastIdCounter.current++}`;
|
||||
// Convert seconds to ms, use 0 for "never dismiss"
|
||||
const durationMs = toast.duration !== undefined
|
||||
@@ -69,7 +73,10 @@ export function ToastProvider({ children, defaultDuration: initialDuration = 20
|
||||
duration: durationMs,
|
||||
};
|
||||
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
// Only add to visible toast queue if not disabled
|
||||
if (!toastsDisabled) {
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
}
|
||||
|
||||
// Log toast to system logs
|
||||
window.maestro.logger.toast(toast.title, {
|
||||
@@ -122,8 +129,8 @@ export function ToastProvider({ children, defaultDuration: initialDuration = 20
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-remove after duration (only if duration > 0)
|
||||
if (durationMs > 0) {
|
||||
// Auto-remove after duration (only if duration > 0 and toasts are not disabled)
|
||||
if (!toastsDisabled && durationMs > 0) {
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, durationMs);
|
||||
|
||||
@@ -34,8 +34,11 @@ export function useActivityTracker(
|
||||
}, []);
|
||||
|
||||
// Tick every second to accumulate time, but only update state every 30 seconds
|
||||
// Pauses when window is hidden to save CPU
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const tick = () => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastActivity = now - lastActivityRef.current;
|
||||
|
||||
@@ -65,10 +68,39 @@ export function useActivityTracker(
|
||||
// Mark as inactive if timeout exceeded
|
||||
isActiveRef.current = false;
|
||||
}
|
||||
}, TICK_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const startInterval = () => {
|
||||
if (!interval) {
|
||||
interval = setInterval(tick, TICK_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const stopInterval = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
stopInterval();
|
||||
} else {
|
||||
startInterval();
|
||||
}
|
||||
};
|
||||
|
||||
// Start interval if visible
|
||||
if (!document.hidden) {
|
||||
startInterval();
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
stopInterval();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
// Flush any accumulated time when effect cleans up (e.g., session change)
|
||||
if (accumulatedTimeRef.current > 0 && activeSessionId) {
|
||||
const accumulatedTime = accumulatedTimeRef.current;
|
||||
@@ -94,16 +126,16 @@ export function useActivityTracker(
|
||||
};
|
||||
|
||||
// Listen for various user interactions
|
||||
// Note: mousemove is intentionally excluded - it fires hundreds of times per second
|
||||
// and would cause excessive CPU usage. mousedown/keydown are sufficient for activity detection.
|
||||
window.addEventListener('keydown', handleActivity);
|
||||
window.addEventListener('mousedown', handleActivity);
|
||||
window.addEventListener('mousemove', handleActivity);
|
||||
window.addEventListener('wheel', handleActivity);
|
||||
window.addEventListener('touchstart', handleActivity);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleActivity);
|
||||
window.removeEventListener('mousedown', handleActivity);
|
||||
window.removeEventListener('mousemove', handleActivity);
|
||||
window.removeEventListener('wheel', handleActivity);
|
||||
window.removeEventListener('touchstart', handleActivity);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { LLMProvider, ThemeId, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats } from '../types';
|
||||
import type { LLMProvider, ThemeId, Shortcut, CustomAICommand, GlobalStats, AutoRunStats, OnboardingStats, LeaderboardRegistration } from '../types';
|
||||
import { DEFAULT_SHORTCUTS } from '../constants/shortcuts';
|
||||
|
||||
// Default global stats
|
||||
@@ -122,10 +122,10 @@ export interface UseSettingsReturn {
|
||||
setDefaultSaveToHistory: (value: boolean) => void;
|
||||
leftSidebarWidth: number;
|
||||
rightPanelWidth: number;
|
||||
markdownRawMode: boolean;
|
||||
markdownEditMode: boolean;
|
||||
setLeftSidebarWidth: (value: number) => void;
|
||||
setRightPanelWidth: (value: number) => void;
|
||||
setMarkdownRawMode: (value: boolean) => void;
|
||||
setMarkdownEditMode: (value: boolean) => void;
|
||||
|
||||
// Terminal settings
|
||||
terminalWidth: number;
|
||||
@@ -208,6 +208,11 @@ export interface UseSettingsReturn {
|
||||
averageConversationExchanges: number;
|
||||
averagePhasesPerWizard: number;
|
||||
};
|
||||
|
||||
// Leaderboard Registration (persistent)
|
||||
leaderboardRegistration: LeaderboardRegistration | null;
|
||||
setLeaderboardRegistration: (value: LeaderboardRegistration | null) => void;
|
||||
isLeaderboardRegistered: boolean;
|
||||
}
|
||||
|
||||
export function useSettings(): UseSettingsReturn {
|
||||
@@ -240,7 +245,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const [defaultSaveToHistory, setDefaultSaveToHistoryState] = useState(true); // History toggle defaults to on
|
||||
const [leftSidebarWidth, setLeftSidebarWidthState] = useState(256);
|
||||
const [rightPanelWidth, setRightPanelWidthState] = useState(384);
|
||||
const [markdownRawMode, setMarkdownRawModeState] = useState(false);
|
||||
const [markdownEditMode, setMarkdownEditModeState] = useState(false);
|
||||
|
||||
// Terminal Config
|
||||
const [terminalWidth, setTerminalWidthState] = useState(100);
|
||||
@@ -287,6 +292,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
// Onboarding Stats (persistent, local-only analytics)
|
||||
const [onboardingStats, setOnboardingStatsState] = useState<OnboardingStats>(DEFAULT_ONBOARDING_STATS);
|
||||
|
||||
// Leaderboard Registration (persistent)
|
||||
const [leaderboardRegistration, setLeaderboardRegistrationState] = useState<LeaderboardRegistration | null>(null);
|
||||
|
||||
// Wrapper functions that persist to electron-store
|
||||
// PERF: All wrapped in useCallback to prevent re-renders
|
||||
const setLlmProvider = useCallback((value: LLMProvider) => {
|
||||
@@ -365,9 +373,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
window.maestro.settings.set('rightPanelWidth', width);
|
||||
}, []);
|
||||
|
||||
const setMarkdownRawMode = useCallback((value: boolean) => {
|
||||
setMarkdownRawModeState(value);
|
||||
window.maestro.settings.set('markdownRawMode', value);
|
||||
const setMarkdownEditMode = useCallback((value: boolean) => {
|
||||
setMarkdownEditModeState(value);
|
||||
window.maestro.settings.set('markdownEditMode', value);
|
||||
}, []);
|
||||
|
||||
const setShortcuts = useCallback((value: Record<string, Shortcut>) => {
|
||||
@@ -611,8 +619,11 @@ export function useSettings(): UseSettingsReturn {
|
||||
|
||||
// UI collapse state setters
|
||||
const setUngroupedCollapsed = useCallback((value: boolean) => {
|
||||
console.log('[useSettings] setUngroupedCollapsed called with:', value);
|
||||
setUngroupedCollapsedState(value);
|
||||
window.maestro.settings.set('ungroupedCollapsed', value);
|
||||
window.maestro.settings.set('ungroupedCollapsed', value)
|
||||
.then(() => console.log('[useSettings] ungroupedCollapsed persisted successfully'))
|
||||
.catch((err: unknown) => console.error('[useSettings] Failed to persist ungroupedCollapsed:', err));
|
||||
}, []);
|
||||
|
||||
// Onboarding setters
|
||||
@@ -792,6 +803,17 @@ export function useSettings(): UseSettingsReturn {
|
||||
onboardingStats.averagePhasesPerWizard,
|
||||
]);
|
||||
|
||||
// Leaderboard Registration setter
|
||||
const setLeaderboardRegistration = useCallback((value: LeaderboardRegistration | null) => {
|
||||
setLeaderboardRegistrationState(value);
|
||||
window.maestro.settings.set('leaderboardRegistration', value);
|
||||
}, []);
|
||||
|
||||
// Computed property for checking if registered
|
||||
const isLeaderboardRegistered = useMemo(() => {
|
||||
return leaderboardRegistration !== null && leaderboardRegistration.emailConfirmed;
|
||||
}, [leaderboardRegistration]);
|
||||
|
||||
// Load settings from electron-store on mount
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
@@ -810,7 +832,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const savedCustomFonts = await window.maestro.settings.get('customFonts');
|
||||
const savedLeftSidebarWidth = await window.maestro.settings.get('leftSidebarWidth');
|
||||
const savedRightPanelWidth = await window.maestro.settings.get('rightPanelWidth');
|
||||
const savedMarkdownRawMode = await window.maestro.settings.get('markdownRawMode');
|
||||
const savedMarkdownEditMode = await window.maestro.settings.get('markdownEditMode');
|
||||
const savedShortcuts = await window.maestro.settings.get('shortcuts');
|
||||
const savedActiveThemeId = await window.maestro.settings.get('activeThemeId');
|
||||
const savedTerminalWidth = await window.maestro.settings.get('terminalWidth');
|
||||
@@ -831,6 +853,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
const savedTourCompleted = await window.maestro.settings.get('tourCompleted');
|
||||
const savedFirstAutoRunCompleted = await window.maestro.settings.get('firstAutoRunCompleted');
|
||||
const savedOnboardingStats = await window.maestro.settings.get('onboardingStats');
|
||||
const savedLeaderboardRegistration = await window.maestro.settings.get('leaderboardRegistration');
|
||||
|
||||
if (savedEnterToSendAI !== undefined) setEnterToSendAIState(savedEnterToSendAI);
|
||||
if (savedEnterToSendTerminal !== undefined) setEnterToSendTerminalState(savedEnterToSendTerminal);
|
||||
@@ -847,7 +870,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (savedCustomFonts !== undefined) setCustomFontsState(savedCustomFonts);
|
||||
if (savedLeftSidebarWidth !== undefined) setLeftSidebarWidthState(Math.max(256, Math.min(600, savedLeftSidebarWidth)));
|
||||
if (savedRightPanelWidth !== undefined) setRightPanelWidthState(savedRightPanelWidth);
|
||||
if (savedMarkdownRawMode !== undefined) setMarkdownRawModeState(savedMarkdownRawMode);
|
||||
if (savedMarkdownEditMode !== undefined) setMarkdownEditModeState(savedMarkdownEditMode);
|
||||
if (savedActiveThemeId !== undefined) setActiveThemeIdState(savedActiveThemeId);
|
||||
if (savedTerminalWidth !== undefined) setTerminalWidthState(savedTerminalWidth);
|
||||
if (savedLogLevel !== undefined) setLogLevelState(savedLogLevel);
|
||||
@@ -950,6 +973,11 @@ export function useSettings(): UseSettingsReturn {
|
||||
setOnboardingStatsState({ ...DEFAULT_ONBOARDING_STATS, ...savedOnboardingStats });
|
||||
}
|
||||
|
||||
// Load leaderboard registration
|
||||
if (savedLeaderboardRegistration !== undefined) {
|
||||
setLeaderboardRegistrationState(savedLeaderboardRegistration as LeaderboardRegistration | null);
|
||||
}
|
||||
|
||||
// Mark settings as loaded
|
||||
setSettingsLoaded(true);
|
||||
};
|
||||
@@ -995,10 +1023,10 @@ export function useSettings(): UseSettingsReturn {
|
||||
setDefaultSaveToHistory,
|
||||
leftSidebarWidth,
|
||||
rightPanelWidth,
|
||||
markdownRawMode,
|
||||
markdownEditMode,
|
||||
setLeftSidebarWidth,
|
||||
setRightPanelWidth,
|
||||
setMarkdownRawMode,
|
||||
setMarkdownEditMode,
|
||||
terminalWidth,
|
||||
setTerminalWidth,
|
||||
logLevel,
|
||||
@@ -1050,6 +1078,9 @@ export function useSettings(): UseSettingsReturn {
|
||||
recordTourComplete,
|
||||
recordTourSkip,
|
||||
getOnboardingAnalytics,
|
||||
leaderboardRegistration,
|
||||
setLeaderboardRegistration,
|
||||
isLeaderboardRegistered,
|
||||
}), [
|
||||
// State values
|
||||
settingsLoaded,
|
||||
@@ -1068,7 +1099,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
defaultSaveToHistory,
|
||||
leftSidebarWidth,
|
||||
rightPanelWidth,
|
||||
markdownRawMode,
|
||||
markdownEditMode,
|
||||
terminalWidth,
|
||||
logLevel,
|
||||
maxLogBuffer,
|
||||
@@ -1104,7 +1135,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
setDefaultSaveToHistory,
|
||||
setLeftSidebarWidth,
|
||||
setRightPanelWidth,
|
||||
setMarkdownRawMode,
|
||||
setMarkdownEditMode,
|
||||
setTerminalWidth,
|
||||
setLogLevel,
|
||||
setMaxLogBuffer,
|
||||
@@ -1137,5 +1168,8 @@ export function useSettings(): UseSettingsReturn {
|
||||
recordTourComplete,
|
||||
recordTourSkip,
|
||||
getOnboardingAnalytics,
|
||||
leaderboardRegistration,
|
||||
setLeaderboardRegistration,
|
||||
isLeaderboardRegistered,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -451,3 +451,27 @@ export interface CustomAICommand {
|
||||
isBuiltIn?: boolean; // If true, cannot be deleted (only edited)
|
||||
}
|
||||
|
||||
// Leaderboard registration data for runmaestro.ai integration
|
||||
export interface LeaderboardRegistration {
|
||||
// Required fields
|
||||
email: string; // User's email (will be confirmed)
|
||||
displayName: string; // Display name on leaderboard
|
||||
// Optional social handles (without @)
|
||||
twitterHandle?: string; // X/Twitter handle
|
||||
githubUsername?: string; // GitHub username
|
||||
linkedinHandle?: string; // LinkedIn handle
|
||||
// Registration state
|
||||
registeredAt: number; // Timestamp when registered
|
||||
emailConfirmed: boolean; // Whether email has been confirmed
|
||||
lastSubmissionAt?: number; // Last successful submission timestamp
|
||||
}
|
||||
|
||||
// Response from leaderboard submission API
|
||||
export interface LeaderboardSubmitResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
requiresConfirmation?: boolean;
|
||||
confirmationUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user