Merge pull request #23 from pedramamini/leaderboard

Leaderboard Integration with RunMaestro.ai
This commit is contained in:
Pedram Amini
2025-12-12 15:47:38 -06:00
committed by GitHub
40 changed files with 2409 additions and 479 deletions

View File

@@ -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.

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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');
});

View File

@@ -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();
});

View File

@@ -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');
});
});

View File

@@ -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()}
/>
);

View File

@@ -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(),

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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);

View File

@@ -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();
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});
});

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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);
});
});

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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);
}}
/>
)}

View File

@@ -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">

View File

@@ -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);

View File

@@ -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
);
});

View File

@@ -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 }}>

View File

@@ -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

View File

@@ -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

View 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&apos;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;

View File

@@ -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>

View File

@@ -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); } },

View File

@@ -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>
);

View File

@@ -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;
});

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}
/>
))}

View File

@@ -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,

View File

@@ -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'] },

View File

@@ -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);

View File

@@ -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);
};

View File

@@ -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,
]);
}

View File

@@ -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;
}