## CHANGES

- Generate playbooks mid-session with the new Inline Wizard flow 🧙
- Add confidence gauge and organized wizard subfolders for generated docs 📈
- Launch **Maestro Symphony** to donate tokens and ship OSS PRs 🎵
- Browse/filter/search Symphony projects and issues directly in-app 🔎
- Auto-clone repos, run Auto Run docs, and open draft PRs automatically 🚀
- Track Symphony contributions with active/history/stats dashboards 📊
- Make leaderboard submissions multi-device safe using delta-based aggregation 🔁
- Stop image flicker with synchronous cache-backed Markdown image loading 🖼️
- Debounce Auto Run preview scroll persistence to cut noisy re-renders ⏱️
- Rename/delete in File Explorer updates tree instantly—no full refresh 
This commit is contained in:
Pedram Amini
2026-01-08 19:31:41 -06:00
parent bff79b56fe
commit a927584207
25 changed files with 741 additions and 79 deletions

View File

@@ -58,6 +58,19 @@ Save your batch configurations for reuse:
![Playbooks](./screenshots/autorun-2.png)
### Inline Wizard
Generate new playbooks from within an existing session using the **Inline Wizard**:
1. Type `/wizard` in any AI tab (or click the Wizard button in the Auto Run panel)
2. Have a conversation with the AI about your project goals
3. Watch the confidence gauge build as the AI understands your requirements
4. At 80%+ confidence, the AI generates detailed Auto Run documents
![Inline Wizard](./screenshots/wizard-inline.png)
The Inline Wizard creates documents in a unique subfolder under your Auto Run folder, keeping generated playbooks organized. When complete, your tab is renamed to reflect the project and you can immediately start running the generated tasks.
### Playbook Exchange
Looking for pre-built playbooks? The [Playbook Exchange](./playbook-exchange) offers community-contributed playbooks for common workflows like security audits, code reviews, and documentation generation. Open it via Quick Actions (`Cmd+K`) or click the Exchange button in the Auto Run panel.

View File

@@ -65,6 +65,7 @@
"usage-dashboard",
"autorun-playbooks",
"playbook-exchange",
"symphony",
"git-worktrees",
"group-chat",
"remote-access",

View File

@@ -9,6 +9,7 @@ icon: sparkles
- 🌳 **[Git Worktrees](./git-worktrees)** - Run AI agents in parallel on isolated branches. Create worktree sub-agents from the git branch menu, each operating in their own directory. Work interactively in the main repo while sub-agents process tasks independently — then create PRs with one click. True parallel development without conflicts.
- 🤖 **[Auto Run & Playbooks](./autorun-playbooks)** - File-system-based task runner that batch-processes markdown checklists through AI agents. Create playbooks for repeatable workflows, run in loops, and track progress with full history. Each task gets its own AI session for clean conversation context.
- 🏪 **[Playbook Exchange](./playbook-exchange)** - Browse and import community-contributed playbooks directly into your Auto Run folder. Categories, search, and one-click import get you started with proven workflows for security audits, code reviews, documentation, and more.
- 🎵 **[Maestro Symphony](./symphony)** - Contribute to open source by donating AI tokens. Browse registered projects, select GitHub issues, and let Maestro clone, process Auto Run docs, and create PRs automatically. Distributed computing for AI-assisted development.
- 💬 **[Group Chat](./group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions.
- 🌐 **[Remote Access](./remote-access)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere.
- 🔗 **[SSH Remote Execution](./ssh-remote-execution)** - Run AI agents on remote hosts via SSH. Leverage powerful cloud VMs, access tools not installed locally, or work with projects requiring specific environments — all while controlling everything from your local Maestro instance.

View File

@@ -24,6 +24,8 @@ Press `Cmd+Shift+N` / `Ctrl+Shift+N` to launch the **Onboarding Wizard**, which
3. Having a discovery conversation where the AI learns about your project
4. Generating an initial Auto Run Playbook with tasks
![Wizard Document Generation](./screenshots/wizard-doc-generation.png)
The Wizard creates a fully configured agent with an Auto Run document folder ready to go. Generated documents are saved to an `Initiation/` subfolder within `Auto Run Docs/` to keep them organized separately from documents you create later.
### Introductory Tour

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1015 KiB

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

159
docs/symphony.md Normal file
View File

@@ -0,0 +1,159 @@
---
title: Maestro Symphony
description: Contribute to open source projects by donating AI tokens through Maestro Symphony.
icon: music
---
Maestro Symphony is a community-driven program that connects Maestro users with open source repository maintainers seeking contributions. Think of it like distributed computing for AI-assisted development — instead of donating CPU cycles, users donate LLM tokens to advance open source.
## How It Works
### For Contributors
1. **Browse available projects** directly within Maestro
2. **Select a GitHub issue** that matches your interests
3. **Create a dedicated agent session** — choose your AI provider and model
4. **Single-click contribution** — Maestro clones the repo, runs Auto Run docs, and creates a PR
![Maestro Symphony Projects](./screenshots/symphony-list.png)
### For Repository Owners
1. **Define contribution opportunities** as Auto Run documents in your repository
2. **Open a GitHub issue** listing the document paths
3. **Add the `runmaestro.ai` label** to make it visible to the Maestro community
4. **Receive quality pull requests** generated by AI-assisted workflows
## Opening Symphony
| Method | Description |
|--------|-------------|
| `Cmd+Shift+Y` / `Ctrl+Shift+Y` | Keyboard shortcut |
| Quick Actions → "Maestro Symphony" | Command palette (`Cmd+K`) |
| Hamburger Menu → "Maestro Symphony" | Menu item |
## Browsing Projects
The Symphony modal shows registered open source projects in a tiled grid:
- **Category filters** — Filter by AI/ML, Developer Tools, etc.
- **Search** — Find projects by name or description
- **Project tiles** — Click to view available issues
![Symphony Project Details](./screenshots/symphony-details.png)
Select a repository to see its available GitHub issues. Each issue shows:
- Issue title and number
- Number of Auto Run documents to process
- Document previews (expandable)
## Starting a Contribution
Click **Start Symphony** on an available issue to open the agent creation dialog:
![Create Agent Session](./screenshots/symphony-create-agent.png)
Configure your contribution:
- **AI Provider** — Choose Claude Code, Codex, OpenCode, etc.
- **Session Name** — Pre-filled as "Symphony: owner/repo #123"
- **Working Directory** — Where the repository will be cloned
Click **Create Agent** and Maestro will:
1. Clone the repository to `~/Maestro-Symphony/{owner}-{repo}/`
2. Create a new branch for your contribution
3. Open a draft PR (claims the issue so others know it's in progress)
4. Copy the Auto Run documents to your session
5. Begin processing tasks automatically
## Tracking Contributions
### Active Tab
View your in-progress Symphony sessions:
- Links to jump to the agent session
- Progress indicators for current document
- Links to draft PRs
- Cancel/abort controls
### History Tab
Review completed contributions:
- PR links with merge status
- Per-contribution statistics
- Time and tokens spent
### Stats Tab
Track your overall impact:
- Total contributions and merged PRs
- Tokens donated and estimated cost
- Issues resolved and repositories contributed to
- Streak tracking and achievement badges
## Session Integration
Symphony sessions appear in the Left Bar like any other session:
- Named "Symphony: owner/repo #123"
- Optionally grouped under a "Symphony" group
- Full access to AI Terminal, Command Terminal, and Auto Run controls
You can interact with Symphony sessions just like regular sessions — pause Auto Run, make manual edits, or switch to the Command Terminal for custom commands.
## Creating Symphony-Ready Issues
Repository owners create issues with the `runmaestro.ai` label:
```markdown
## Add unit tests for auth module
We need comprehensive test coverage for the authentication module.
### Auto Run Documents
- `docs/symphony/auth-tests-1.md`
- `docs/symphony/auth-tests-2.md`
- `docs/symphony/auth-tests-3.md`
### Context
The auth module is at `src/auth/index.ts`. Tests should use Jest.
```
Each document path should point to an Auto Run-compatible markdown file with task checkboxes.
## Task Claiming
When you click **Start Symphony**, Maestro immediately creates a draft PR. This claims the task so other contributors know it's being worked on:
- **No draft PR** → Task is available
- **Draft PR exists** → Task is in progress
First to create a draft PR wins — this prevents duplicate work.
## Registering Your Project
To add your open source project to Symphony:
1. Fork the [Maestro repository](https://github.com/pedramamini/Maestro)
2. Add your project to `symphony-registry.json`
3. Submit a pull request
Your project entry should include:
```json
{
"slug": "owner/repo",
"name": "Project Name",
"description": "Brief description",
"url": "https://github.com/owner/repo",
"category": "developer-tools",
"tags": ["tag1", "tag2"],
"maintainer": {
"name": "Your Name",
"url": "https://github.com/yourusername"
},
"isActive": true
}
```
---
**Maestro Symphony — Advancing open source, together.**

View File

@@ -2024,6 +2024,9 @@ describe('Scroll Position Persistence', () => {
const preview = screen.getByTestId('react-markdown').parentElement!;
fireEvent.scroll(preview);
// onStateChange is debounced by 500ms, so we need to advance timers
await vi.advanceTimersByTimeAsync(500);
// onStateChange should be called with scroll position
expect(onStateChange).toHaveBeenCalled();
});

View File

@@ -415,6 +415,85 @@ describe('UpdateCheckModal', () => {
expect(screen.queryByText('- v1.1.0')).not.toBeInTheDocument();
});
it('strips version prefix from release name with pipe separator', async () => {
(window.maestro as any).updates.check.mockResolvedValue(
createMockUpdateResult({
releases: [
createMockRelease({
tag_name: 'v1.1.0',
name: 'v1.1.0 | Doc Graphs, SSH Agents',
}),
],
})
);
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
// Should show just the description, not the version again
expect(screen.getByText('- Doc Graphs, SSH Agents')).toBeInTheDocument();
});
// Should not show the full original name with duplicate version
expect(screen.queryByText('- v1.1.0 | Doc Graphs, SSH Agents')).not.toBeInTheDocument();
});
it('strips version prefix from release name with dash separator', async () => {
(window.maestro as any).updates.check.mockResolvedValue(
createMockUpdateResult({
releases: [
createMockRelease({
tag_name: 'v1.1.0',
name: 'v1.1.0 - Major Update',
}),
],
})
);
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
expect(screen.getByText('- Major Update')).toBeInTheDocument();
});
});
it('keeps release name when it does not start with version', async () => {
(window.maestro as any).updates.check.mockResolvedValue(
createMockUpdateResult({
releases: [
createMockRelease({
tag_name: 'v1.1.0',
name: 'Big Feature Release',
}),
],
})
);
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('v1.1.0')).toBeInTheDocument();
expect(screen.getByText('- Big Feature Release')).toBeInTheDocument();
});
});
it('shows fallback text when no release body', async () => {
(window.maestro as any).updates.check.mockResolvedValue(
createMockUpdateResult({

View File

@@ -1691,8 +1691,10 @@ contextBridge.exposeInMainWorld('maestro', {
discordUsername?: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
// Stats fields are optional for profile-only submissions (multi-device safe)
// When omitted, server keeps existing values instead of overwriting
cumulativeTimeMs?: number;
totalRuns?: number;
longestRunMs?: number;
longestRunDate?: string;
currentRunMs?: number;
@@ -2901,8 +2903,9 @@ export interface MaestroAPI {
discordUsername?: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
// Stats fields are optional for profile-only submissions (multi-device safe)
cumulativeTimeMs?: number;
totalRuns?: number;
longestRunMs?: number;
longestRunDate?: string;
currentRunMs?: number;
@@ -2915,6 +2918,10 @@ export interface MaestroAPI {
keyboardCoveragePercent?: number;
keyboardKeysUnlocked?: number;
keyboardTotalKeys?: number;
// Delta mode for multi-device aggregation
deltaMs?: number;
deltaRuns?: number;
clientTotalTimeMs?: number;
}) => Promise<{
success: boolean;
message: string;

View File

@@ -4309,6 +4309,11 @@ You are taking over this conversation. Based on the context above, provide a bri
if (!leaderboardRegistration.authToken) {
console.warn('Leaderboard submission skipped: no auth token');
} else {
// Auto Run completion submission: Use delta mode for multi-device aggregation
// API behavior:
// - If deltaMs > 0 is present: Server adds deltaMs to running total (delta mode)
// - If only cumulativeTimeMs (no deltaMs): Server replaces value (legacy mode)
// We send deltaMs to trigger delta mode, ensuring proper aggregation across devices.
window.maestro.leaderboard.submit({
email: leaderboardRegistration.email,
displayName: leaderboardRegistration.displayName,
@@ -4317,6 +4322,7 @@ You are taking over this conversation. Based on the context above, provide a bri
linkedinHandle: leaderboardRegistration.linkedinHandle,
badgeLevel: updatedBadgeLevel,
badgeName: updatedBadgeName,
// Legacy fields (server ignores when deltaMs is present)
cumulativeTimeMs: updatedCumulativeTimeMs,
totalRuns: updatedTotalRuns,
longestRunMs: updatedLongestRunMs,
@@ -4324,10 +4330,10 @@ You are taking over this conversation. Based on the context above, provide a bri
currentRunMs: info.elapsedTimeMs,
theme: activeThemeId,
authToken: leaderboardRegistration.authToken,
// Delta mode for multi-device aggregation
// Delta mode: Server adds these to running totals
deltaMs: info.elapsedTimeMs,
deltaRuns: 1,
// Client's total time for multi-device discrepancy detection
// Client's local total for discrepancy detection
clientTotalTimeMs: updatedCumulativeTimeMs,
}).then(result => {
if (result.success) {

View File

@@ -104,8 +104,56 @@ export interface AutoRunHandle {
getCompletedTaskCount: () => number;
}
// Helper to compute initial image state synchronously from cache
// This prevents flickering when ReactMarkdown rebuilds the component tree
function getInitialImageState(src: string | undefined, folderPath: string | null) {
if (!src) {
return { dataUrl: null, loading: false, filename: null };
}
const decodedSrc = decodeURIComponent(src);
// Check cache for relative paths
if (decodedSrc.startsWith('images/') && folderPath) {
const cacheKey = `${folderPath}:${decodedSrc}`;
if (imageCache.has(cacheKey)) {
return {
dataUrl: imageCache.get(cacheKey)!,
loading: false,
filename: decodedSrc.split('/').pop() || decodedSrc,
};
}
}
// Data URLs are ready immediately
if (src.startsWith('data:')) {
return { dataUrl: src, loading: false, filename: null };
}
// HTTP URLs are ready immediately (browser handles loading)
if (src.startsWith('http://') || src.startsWith('https://')) {
return { dataUrl: src, loading: false, filename: null };
}
// Check cache for other relative paths
if (folderPath) {
const cacheKey = `${folderPath}:${src}`;
if (imageCache.has(cacheKey)) {
return {
dataUrl: imageCache.get(cacheKey)!,
loading: false,
filename: src.split('/').pop() || null,
};
}
}
// Need to load - return loading state
return { dataUrl: null, loading: true, filename: src.split('/').pop() || null };
}
// Custom image component that loads images from the Auto Run folder or external URLs
function AttachmentImage({
// Memoized to prevent re-renders and image reloading when parent updates
const AttachmentImage = memo(function AttachmentImage({
src,
alt,
folderPath,
@@ -120,12 +168,30 @@ function AttachmentImage({
theme: any;
onImageClick?: (filename: string) => void;
}) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
// Compute initial state synchronously from cache to prevent flicker
const initialState = useMemo(
() => getInitialImageState(src, folderPath),
[src, folderPath]
);
const [dataUrl, setDataUrl] = useState<string | null>(initialState.dataUrl);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [filename, setFilename] = useState<string | null>(null);
const [loading, setLoading] = useState(initialState.loading);
const [filename, setFilename] = useState<string | null>(initialState.filename);
// Use ref for onImageClick to avoid re-running effect when callback changes
const onImageClickRef = useRef(onImageClick);
onImageClickRef.current = onImageClick;
useEffect(() => {
// If we already have data from cache (initialState), skip loading
if (initialState.dataUrl) {
return;
}
// Track whether this effect is stale (component unmounted or src changed)
let isStale = false;
if (!src) {
setLoading(false);
return;
@@ -140,7 +206,7 @@ function AttachmentImage({
setFilename(fname);
const cacheKey = `${folderPath}:${decodedSrc}`;
// Check cache first
// Double-check cache (in case it was populated after initial render)
if (imageCache.has(cacheKey)) {
setDataUrl(imageCache.get(cacheKey)!);
setLoading(false);
@@ -151,6 +217,7 @@ function AttachmentImage({
const absolutePath = `${folderPath}/${decodedSrc}`;
window.maestro.fs.readFile(absolutePath, sshRemoteId)
.then((result) => {
if (isStale) return;
if (result.startsWith('data:')) {
imageCache.set(cacheKey, result);
setDataUrl(result);
@@ -160,24 +227,16 @@ function AttachmentImage({
setLoading(false);
})
.catch((err) => {
if (isStale) return;
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
setLoading(false);
});
} else if (src.startsWith('data:')) {
// Already a data URL
setDataUrl(src);
setFilename(null);
setLoading(false);
} else if (src.startsWith('http://') || src.startsWith('https://')) {
// External URL - just use it directly
setDataUrl(src);
setFilename(null);
setLoading(false);
} else if (src.startsWith('/')) {
// Absolute file path - load via IPC
setFilename(src.split('/').pop() || null);
window.maestro.fs.readFile(src, sshRemoteId)
.then((result) => {
if (isStale) return;
if (result.startsWith('data:')) {
setDataUrl(result);
} else {
@@ -186,16 +245,30 @@ function AttachmentImage({
setLoading(false);
})
.catch((err) => {
if (isStale) return;
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
setLoading(false);
});
} else {
// Other relative path - try to load as file from folderPath if available
setFilename(src.split('/').pop() || null);
const cacheKey = folderPath ? `${folderPath}:${src}` : src;
// Double-check cache
if (imageCache.has(cacheKey)) {
setDataUrl(imageCache.get(cacheKey)!);
setLoading(false);
return;
}
const pathToLoad = folderPath ? `${folderPath}/${src}` : src;
window.maestro.fs.readFile(pathToLoad, sshRemoteId)
.then((result) => {
if (isStale) return;
if (result.startsWith('data:')) {
if (folderPath) {
imageCache.set(cacheKey, result);
}
setDataUrl(result);
} else {
setError('Invalid image data');
@@ -203,11 +276,16 @@ function AttachmentImage({
setLoading(false);
})
.catch((err) => {
if (isStale) return;
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
setLoading(false);
});
}
}, [src, folderPath, sshRemoteId]);
return () => {
isStale = true;
};
}, [src, folderPath, sshRemoteId, initialState.dataUrl]);
if (loading) {
return (
@@ -243,7 +321,7 @@ function AttachmentImage({
return (
<span
className="inline-block align-middle mx-1 my-1 cursor-pointer group relative"
onClick={() => onImageClick?.(decodedSrcForClick)}
onClick={() => onImageClickRef.current?.(decodedSrcForClick)}
title={filename ? `Click to enlarge: ${filename}` : 'Click to enlarge'}
>
<img
@@ -266,7 +344,7 @@ function AttachmentImage({
</span>
</span>
);
}
})
// Image preview thumbnail for staged images in edit mode
function ImagePreview({
@@ -794,20 +872,39 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
}
};
const handlePreviewScroll = () => {
// Debounced preview scroll handler to avoid triggering re-renders on every scroll event
// We only save scroll position to ref immediately (for local use), but delay parent notification
const previewScrollDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handlePreviewScroll = useCallback(() => {
if (previewRef.current) {
// Save to ref for persistence across re-renders
// Save to ref immediately for local persistence
previewScrollPosRef.current = previewRef.current.scrollTop;
if (onStateChange) {
onStateChange({
mode,
cursorPosition: textareaRef.current?.selectionStart || 0,
editScrollPos: textareaRef.current?.scrollTop || 0,
previewScrollPos: previewRef.current.scrollTop
});
// Debounce the parent state update to avoid cascading re-renders
if (previewScrollDebounceRef.current) {
clearTimeout(previewScrollDebounceRef.current);
}
previewScrollDebounceRef.current = setTimeout(() => {
if (onStateChange && previewRef.current) {
onStateChange({
mode,
cursorPosition: textareaRef.current?.selectionStart || 0,
editScrollPos: textareaRef.current?.scrollTop || 0,
previewScrollPos: previewRef.current.scrollTop
});
}
}, 500); // Only notify parent after 500ms of no scrolling
}
};
}, [mode, onStateChange]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (previewScrollDebounceRef.current) {
clearTimeout(previewScrollDebounceRef.current);
}
};
}, []);
// Handle refresh for empty state with animation
const handleEmptyStateRefresh = useCallback(async () => {

View File

@@ -5,6 +5,7 @@ import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, Ey
import type { Session, Theme, FocusArea } from '../types';
import type { FileNode } from '../types/fileTree';
import type { FileTreeChanges } from '../utils/fileExplorer';
import { removeNodeFromTree, renameNodeInTree, findNodeInTree, countNodesInTree } from '../utils/fileExplorer';
import { getFileIcon } from '../utils/theme';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
@@ -544,14 +545,38 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
const parentDir = renameModal.absolutePath.substring(0, renameModal.absolutePath.lastIndexOf('/'));
const newPath = `${parentDir}/${newName}`;
await window.maestro.fs.rename(renameModal.absolutePath, newPath, sshRemoteId);
// Update tree locally instead of full refresh
const newTree = renameNodeInTree(session.fileTree || [], renameModal.path, newName);
// Calculate the new path for expanded folder updates
const oldPath = renameModal.path;
const pathParts = oldPath.split('/');
pathParts[pathParts.length - 1] = newName;
const newRelativePath = pathParts.join('/');
setSessions(prev => prev.map(s => {
if (s.id !== session.id) return s;
return {
...s,
fileTree: newTree,
// Update expanded folder paths if renamed item was a folder
fileExplorerExpanded: renameModal.node.type === 'folder'
? (s.fileExplorerExpanded || []).map(p => {
if (p === oldPath) return newRelativePath;
if (p.startsWith(oldPath + '/')) return newRelativePath + p.slice(oldPath.length);
return p;
})
: s.fileExplorerExpanded
};
}));
setRenameModal(null);
// Refresh file tree
refreshFileTree(session.id);
onShowFlash?.(`Renamed to "${newName}"`);
} catch (error) {
setRenameError(error instanceof Error ? error.message : 'Rename failed');
}
}, [renameModal, renameValue, refreshFileTree, session.id, onShowFlash, sshRemoteId]);
}, [renameModal, renameValue, session.id, session.fileTree, onShowFlash, sshRemoteId, setSessions]);
// Open delete confirmation modal
const handleOpenDelete = useCallback(async () => {
@@ -585,16 +610,52 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
setIsDeleting(true);
try {
await window.maestro.fs.delete(deleteModal.absolutePath, { sshRemoteId });
// Get the node being deleted to count its contents for stats update
const deletedNode = findNodeInTree(session.fileTree || [], deleteModal.path);
let deletedFileCount = 0;
let deletedFolderCount = 0;
if (deletedNode) {
if (deletedNode.type === 'folder') {
deletedFolderCount = 1;
if (deletedNode.children) {
const childCounts = countNodesInTree(deletedNode.children);
deletedFileCount = childCounts.fileCount;
deletedFolderCount += childCounts.folderCount;
}
} else {
deletedFileCount = 1;
}
}
// Update tree locally instead of full refresh
const newTree = removeNodeFromTree(session.fileTree || [], deleteModal.path);
setSessions(prev => prev.map(s => {
if (s.id !== session.id) return s;
return {
...s,
fileTree: newTree,
fileTreeStats: s.fileTreeStats ? {
...s.fileTreeStats,
fileCount: Math.max(0, s.fileTreeStats.fileCount - deletedFileCount),
folderCount: Math.max(0, s.fileTreeStats.folderCount - deletedFolderCount),
} : undefined,
// Also remove from expanded folders if it was a folder
fileExplorerExpanded: deleteModal.node.type === 'folder'
? (s.fileExplorerExpanded || []).filter(p => !p.startsWith(deleteModal.path))
: s.fileExplorerExpanded
};
}));
setDeleteModal(null);
// Refresh file tree
refreshFileTree(session.id);
onShowFlash?.(`Deleted "${deleteModal.node.name}"`);
} catch (error) {
onShowFlash?.(`Delete failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsDeleting(false);
}
}, [deleteModal, refreshFileTree, session.id, onShowFlash, sshRemoteId]);
}, [deleteModal, session.id, session.fileTree, onShowFlash, sshRemoteId, setSessions]);
// Close context menu on Escape key
useEffect(() => {

View File

@@ -219,6 +219,17 @@ export function LeaderboardRegistrationModal({
longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0];
}
// IMPORTANT: For multi-device support, we use delta mode for stats updates.
// Profile-only submissions (when user has no new stats to report) should omit
// cumulative fields to avoid overwriting server totals from other devices.
// Stats are updated via delta mode only when Auto Runs complete in App.tsx.
//
// API behavior:
// - If deltaMs > 0 is present: Delta mode - adds to server totals
// - If only cumulativeTimeMs (no deltaMs): Legacy mode - REPLACES server value
//
// We send local cumulative as clientTotalTimeMs for discrepancy detection,
// but NOT as the primary cumulativeTimeMs to avoid overwrites.
const result = await window.maestro.leaderboard.submit({
email: email.trim(),
displayName: displayName.trim(),
@@ -228,8 +239,10 @@ export function LeaderboardRegistrationModal({
discordUsername: discordUsername.trim() || undefined,
badgeLevel,
badgeName,
cumulativeTimeMs: autoRunStats.cumulativeTimeMs,
totalRuns: autoRunStats.totalRuns,
// Don't send cumulativeTimeMs/totalRuns in legacy mode - they would overwrite server!
// For new registrations, server creates with 0 values.
// For existing users, server keeps its current totals.
// Only send longestRun if it might be a new record (server can compare).
longestRunMs: autoRunStats.longestRunMs || undefined,
longestRunDate,
theme: theme.id,
@@ -241,7 +254,7 @@ export function LeaderboardRegistrationModal({
keyboardCoveragePercent,
keyboardKeysUnlocked,
keyboardTotalKeys,
// Client's total time for multi-device discrepancy detection
// Client's local total for discrepancy detection (server won't use this to overwrite)
clientTotalTimeMs: autoRunStats.cumulativeTimeMs,
});
@@ -269,7 +282,8 @@ export function LeaderboardRegistrationModal({
startPolling(clientToken);
} else {
setSubmitState('success');
setSuccessMessage('Your stats have been submitted! Your entry is now queued for manual approval.');
// Profile submitted - stats sync via delta mode from Auto Runs or Pull Down
setSuccessMessage('Profile submitted! Stats are synced via Auto Runs. Use "Pull Down" to sync from other devices.');
}
} else if (result.authTokenRequired) {
// Email is confirmed but auth token is missing/invalid - try to recover it automatically
@@ -302,6 +316,7 @@ export function LeaderboardRegistrationModal({
longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0];
}
// Retry submission with recovered token - same multi-device safe approach
const retryResult = await window.maestro.leaderboard.submit({
email: email.trim(),
displayName: displayName.trim(),
@@ -311,8 +326,7 @@ export function LeaderboardRegistrationModal({
discordUsername: discordUsername.trim() || undefined,
badgeLevel,
badgeName,
cumulativeTimeMs: autoRunStats.cumulativeTimeMs,
totalRuns: autoRunStats.totalRuns,
// Don't send cumulativeTimeMs/totalRuns - avoid overwriting server totals
longestRunMs: autoRunStats.longestRunMs || undefined,
longestRunDate,
theme: theme.id,
@@ -324,7 +338,7 @@ export function LeaderboardRegistrationModal({
keyboardCoveragePercent,
keyboardKeysUnlocked,
keyboardTotalKeys,
// Client's total time for multi-device discrepancy detection
// Client's local total for discrepancy detection
clientTotalTimeMs: autoRunStats.cumulativeTimeMs,
});
@@ -386,6 +400,7 @@ export function LeaderboardRegistrationModal({
longestRunDate = new Date(autoRunStats.longestRunTimestamp).toISOString().split('T')[0];
}
// Manual token submission - same multi-device safe approach
const result = await window.maestro.leaderboard.submit({
email: email.trim(),
displayName: displayName.trim(),
@@ -395,8 +410,7 @@ export function LeaderboardRegistrationModal({
discordUsername: discordUsername.trim() || undefined,
badgeLevel,
badgeName,
cumulativeTimeMs: autoRunStats.cumulativeTimeMs,
totalRuns: autoRunStats.totalRuns,
// Don't send cumulativeTimeMs/totalRuns - avoid overwriting server totals
longestRunMs: autoRunStats.longestRunMs || undefined,
longestRunDate,
theme: theme.id,
@@ -408,13 +422,13 @@ export function LeaderboardRegistrationModal({
keyboardCoveragePercent,
keyboardKeysUnlocked,
keyboardTotalKeys,
// Client's total time for multi-device discrepancy detection
// Client's local total for discrepancy detection
clientTotalTimeMs: autoRunStats.cumulativeTimeMs,
});
if (result.success) {
setSubmitState('success');
setSuccessMessage('Your stats have been submitted! Your entry is now queued for manual approval.');
setSuccessMessage('Your profile has been updated! Use "Pull Down" to sync stats from the server.');
} else {
setSubmitState('error');
setErrorMessage(result.error || result.message || 'Submission failed. Please check your auth token.');

View File

@@ -15,6 +15,9 @@ import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';
// LocalImage - Loads local images via IPC
// ============================================================================
// Module-level cache for local images to prevent flicker on re-render
const localImageCache = new Map<string, string>();
interface LocalImageProps {
src?: string;
alt?: string;
@@ -23,44 +26,76 @@ interface LocalImageProps {
sshRemoteId?: string; // SSH remote ID for remote file operations
}
// Helper to compute initial image state synchronously from cache
// This prevents flickering when ReactMarkdown rebuilds the component tree
function getLocalImageInitialState(src: string | undefined) {
if (!src) {
return { dataUrl: null, loading: false };
}
// Data URLs are ready immediately
if (src.startsWith('data:')) {
return { dataUrl: src, loading: false };
}
// HTTP URLs are ready immediately (browser handles loading)
if (src.startsWith('http://') || src.startsWith('https://')) {
return { dataUrl: src, loading: false };
}
// Check cache for file paths
let filePath = src;
if (src.startsWith('file://')) {
filePath = decodeURIComponent(src.replace('file://', ''));
}
if (localImageCache.has(filePath)) {
return { dataUrl: localImageCache.get(filePath)!, loading: false };
}
// Need to load
return { dataUrl: null, loading: true };
}
const LocalImage = memo(({ src, alt, theme, width, sshRemoteId }: LocalImageProps) => {
const [dataUrl, setDataUrl] = useState<string | null>(null);
// Compute initial state synchronously from cache to prevent flicker
const initialState = useMemo(() => getLocalImageInitialState(src), [src]);
const [dataUrl, setDataUrl] = useState<string | null>(initialState.dataUrl);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(initialState.loading);
useEffect(() => {
setError(null);
setDataUrl(null);
// If we already have data from cache, skip loading
if (initialState.dataUrl) {
return;
}
let isStale = false;
if (!src) {
setLoading(false);
return;
}
// If it's already a data URL, use it directly
if (src.startsWith('data:')) {
setDataUrl(src);
setLoading(false);
return;
}
// If it's an HTTP(S) URL, use it directly (browser will handle)
if (src.startsWith('http://') || src.startsWith('https://')) {
setDataUrl(src);
setLoading(false);
return;
}
// For file:// URLs, extract the path and load via IPC
let filePath = src;
if (src.startsWith('file://')) {
filePath = decodeURIComponent(src.replace('file://', ''));
}
setLoading(true);
// Double-check cache
if (localImageCache.has(filePath)) {
setDataUrl(localImageCache.get(filePath)!);
setLoading(false);
return;
}
window.maestro.fs.readFile(filePath, sshRemoteId)
.then((result) => {
if (isStale) return;
if (result.startsWith('data:')) {
localImageCache.set(filePath, result);
setDataUrl(result);
} else {
setError('Invalid image data');
@@ -68,10 +103,15 @@ const LocalImage = memo(({ src, alt, theme, width, sshRemoteId }: LocalImageProp
setLoading(false);
})
.catch((err) => {
if (isStale) return;
setError(`Failed to load image: ${err.message || 'Unknown error'}`);
setLoading(false);
});
}, [src, sshRemoteId]);
return () => {
isStale = true;
};
}, [src, sshRemoteId, initialState.dataUrl]);
if (loading) {
return (

View File

@@ -261,11 +261,39 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
<span className="font-mono font-bold text-sm" style={{ color: theme.colors.accent }}>
{release.tag_name}
</span>
{release.name && release.name !== release.tag_name && (
<span className="text-xs" style={{ color: theme.colors.textDim }}>
- {release.name}
</span>
)}
{(() => {
// Strip version prefix if name starts with it (e.g., "v0.14.2 | Description" -> "Description")
if (!release.name || release.name === release.tag_name) return null;
const name = release.name;
const tag = release.tag_name;
let displayName: string | null = name;
// Check for patterns like "v0.14.2 | Description" or "v0.14.2 - Description"
const pipeIndex = name.indexOf('|');
const dashIndex = name.indexOf(' - ');
if (pipeIndex !== -1 && name.substring(0, pipeIndex).trim().toLowerCase() === tag.toLowerCase()) {
displayName = name.substring(pipeIndex + 1).trim();
} else if (dashIndex !== -1 && name.substring(0, dashIndex).trim().toLowerCase() === tag.toLowerCase()) {
displayName = name.substring(dashIndex + 3).trim();
} else if (name.toLowerCase().startsWith(tag.toLowerCase())) {
// If name just starts with the tag, strip it
const remainder = name.substring(tag.length).trim();
// Remove leading separator if present
if (remainder.startsWith('|') || remainder.startsWith('-')) {
displayName = remainder.substring(1).trim();
} else {
displayName = remainder || null;
}
}
if (!displayName) return null;
return (
<span className="text-xs" style={{ color: theme.colors.textDim }}>
- {displayName}
</span>
);
})()}
</div>
<span className="text-xs" style={{ color: theme.colors.textDim }}>
{formatDate(release.published_at)}
@@ -273,7 +301,7 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
</button>
{expandedReleases.has(release.tag_name) && (
<div
className="p-3 border-t text-xs prose prose-sm prose-invert max-w-none"
className="py-3 px-5 border-t text-xs prose prose-sm prose-invert max-w-none"
style={{ borderColor: theme.colors.border, color: theme.colors.textDim }}
>
<ReactMarkdown

View File

@@ -1350,8 +1350,10 @@ interface MaestroAPI {
discordUsername?: string;
badgeLevel: number;
badgeName: string;
cumulativeTimeMs: number;
totalRuns: number;
// Stats fields are optional for profile-only submissions (multi-device safe)
// When omitted, server keeps existing values instead of overwriting
cumulativeTimeMs?: number;
totalRuns?: number;
longestRunMs?: number;
longestRunDate?: string;
currentRunMs?: number;

View File

@@ -279,3 +279,152 @@ export function compareFileTrees(
removedFolders
};
}
/**
* Remove a node from the file tree at the given path.
* Returns a new tree with the node removed.
* @param tree - The file tree to modify
* @param relativePath - Path relative to tree root (e.g., "folder/file.txt")
* @returns New tree with the node removed, or original tree if path not found
*/
export function removeNodeFromTree(
tree: FileTreeNode[],
relativePath: string
): FileTreeNode[] {
const parts = relativePath.split('/').filter(Boolean);
if (parts.length === 0) return tree;
const targetName = parts[parts.length - 1];
const parentParts = parts.slice(0, -1);
// If at root level, filter out the target
if (parentParts.length === 0) {
return tree.filter(node => node.name !== targetName);
}
// Navigate to parent and remove from there
return tree.map(node => {
if (node.name === parentParts[0]) {
if (parentParts.length === 1) {
// This node is the parent - remove target from children
return {
...node,
children: node.children?.filter(child => child.name !== targetName)
};
}
// Keep navigating
return {
...node,
children: node.children ? removeNodeFromTree(node.children, parentParts.slice(1).concat(targetName).join('/')) : undefined
};
}
return node;
});
}
/**
* Rename a node in the file tree at the given path.
* Returns a new tree with the node renamed and re-sorted.
* @param tree - The file tree to modify
* @param relativePath - Path relative to tree root (e.g., "folder/oldname.txt")
* @param newName - The new name for the node
* @returns New tree with the node renamed, or original tree if path not found
*/
export function renameNodeInTree(
tree: FileTreeNode[],
relativePath: string,
newName: string
): FileTreeNode[] {
const parts = relativePath.split('/').filter(Boolean);
if (parts.length === 0) return tree;
const targetName = parts[parts.length - 1];
const parentParts = parts.slice(0, -1);
const sortNodes = (nodes: FileTreeNode[]): FileTreeNode[] => {
return [...nodes].sort((a, b) => {
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return a.name.localeCompare(b.name);
});
};
// If at root level, rename and re-sort
if (parentParts.length === 0) {
const renamed = tree.map(node =>
node.name === targetName ? { ...node, name: newName } : node
);
return sortNodes(renamed);
}
// Navigate to parent and rename there
return tree.map(node => {
if (node.name === parentParts[0]) {
if (parentParts.length === 1) {
// This node is the parent - rename target in children
const renamed = node.children?.map(child =>
child.name === targetName ? { ...child, name: newName } : child
);
return {
...node,
children: renamed ? sortNodes(renamed) : undefined
};
}
// Keep navigating
return {
...node,
children: node.children ? renameNodeInTree(node.children, parentParts.slice(1).concat(targetName).join('/'), newName) : undefined
};
}
return node;
});
}
/**
* Count files and folders in a tree node recursively.
* Used to update stats when a node is removed.
*/
export function countNodesInTree(nodes: FileTreeNode[]): { fileCount: number; folderCount: number } {
let fileCount = 0;
let folderCount = 0;
const count = (nodeList: FileTreeNode[]) => {
for (const node of nodeList) {
if (node.type === 'folder') {
folderCount++;
if (node.children) {
count(node.children);
}
} else {
fileCount++;
}
}
};
count(nodes);
return { fileCount, folderCount };
}
/**
* Find a node in the tree by path.
* @param tree - The file tree to search
* @param relativePath - Path relative to tree root
* @returns The node if found, undefined otherwise
*/
export function findNodeInTree(
tree: FileTreeNode[],
relativePath: string
): FileTreeNode | undefined {
const parts = relativePath.split('/').filter(Boolean);
if (parts.length === 0) return undefined;
let current: FileTreeNode[] = tree;
for (let i = 0; i < parts.length; i++) {
const node = current.find(n => n.name === parts[i]);
if (!node) return undefined;
if (i === parts.length - 1) return node;
if (!node.children) return undefined;
current = node.children;
}
return undefined;
}