',
+ '> Running: pnpm vitest run dashboard',
+ ' ✓ renders metrics on mobile viewport (11ms)',
+ '✓ 1 test passed — ready for review',
+ ],
+ 'notif-batching': [
+ '> Reading src/notifications/index.ts',
+ '> Notifications sent individually — batching opportunity',
+ '> Editing src/notifications/index.ts',
+ '+ export function notifyBatch(msgs: string[]) { ... }',
+ '> Running: pnpm vitest run notifications',
+ ' ✓ batches notifications within 200ms window (6ms)',
+ '✓ 1 test passed — ready for review',
+ ],
+};
+
+/** Node script (spawned via `node -e`) that prints a canned per-worktree transcript, then idles. */
+function agentScript(): string {
+ return `
+ const transcripts = ${JSON.stringify(AGENT_TRANSCRIPTS)};
+ const name = process.env.SPROUTGIT_WORKTREE_NAME || '';
+ const lines = transcripts[name] || ['> Working...', '> Done.'];
+ let i = 0;
+ (function next() {
+ if (i < lines.length) { console.log(lines[i]); i++; setTimeout(next, 550); }
+ else { setInterval(() => {}, 1000); }
+ })();
+ `;
+}
+
+const TEST_AGENT_CONFIG = {
+ command: 'node',
+ args: ['-e', agentScript()],
+ mode: 'terminal' as const,
+};
+
+async function seedTestAgent(): Promise
{
+ await browser.executeAsync(
+ (config: unknown, done: (err?: string) => void) => {
+ (window as unknown as { api: { saveAgentConfig: (c: unknown) => Promise } })
+ .api.saveAgentConfig(config)
+ .then(() => done(), (e: unknown) => done(String(e)));
+ },
+ TEST_AGENT_CONFIG
+ );
+}
+
+async function closeAllTerminals(): Promise {
+ await browser.executeAsync((done: (err?: string) => void) => {
+ (window as unknown as { api: { closeAllTerminals: () => Promise } })
+ .api.closeAllTerminals()
+ .then(() => done(), (e: unknown) => done(String(e)));
+ });
+}
+
+// ── UI helpers ────────────────────────────────────────────────────────────────
+
+async function openWorkspace(repoPath: string): Promise {
+ await browser.execute((p: string) => { window.location.hash = `/workspace?path=${encodeURIComponent(p)}`; }, repoPath);
+ await browser.pause(200);
+ await $('//*[contains(@class,"sg-tab") and contains(.,"Graph")]').waitForDisplayed({ timeout: UI_TIMEOUT });
+}
+
+async function openTab(label: 'Graph' | 'Changes' | 'Terminal'): Promise {
+ const tab = $(`//*[contains(@class,"sg-tab") and contains(.,"${label}")]`);
+ await tab.waitForDisplayed({ timeout: UI_TIMEOUT });
+ await tab.click();
+}
+
+async function frame(name: string, holdMs = 0): Promise {
+ if (holdMs > 0) await browser.pause(holdMs);
+ await captureNamedScreenshot(`demo/${name}`);
+}
+
+async function createWorktreeViaUi(branch: string): Promise {
+ await $('[data-testid="btn-open-create-worktree"]').click();
+ const input = $('[data-testid="input-new-branch"]');
+ await input.waitForDisplayed({ timeout: UI_TIMEOUT });
+ await input.setValue(branch);
+ await $('[data-testid="btn-create-worktree"]').click();
+ await $(`[data-testid="worktree-item"][data-branch="${branch}"]`).waitForDisplayed({ timeout: UI_TIMEOUT });
+}
+
+async function switchToWorktree(branch: string): Promise {
+ await $(`[data-testid="worktree-item"][data-branch="${branch}"]`).click();
+ await browser.pause(150);
+}
+
+async function launchAgentForActiveWorktree(branch: string): Promise {
+ await $(`[data-testid="worktree-item"][data-branch="${branch}"] [data-testid="btn-launch-agent"]`).click();
+ await $('[data-testid="terminal-session-tab"][data-session-label="AI Agent"]').waitForDisplayed({ timeout: UI_TIMEOUT });
+}
+
+// ── Test suite ────────────────────────────────────────────────────────────────
+
+describe('Demo GIF frame capture @demo-gif', () => {
+ before(function () {
+ if (!process.env['CAPTURE_SCREENSHOTS']) {
+ this.skip();
+ }
+ });
+
+ let repoPath: string;
+
+ before(async () => {
+ repoPath = createDemoRepo();
+ await seedTestAgent();
+ });
+
+ after(async () => {
+ await closeAllTerminals();
+ if (repoPath) await rmWithRetry(repoPath);
+ });
+
+ it('captures the parallel-worktrees + agents + diff-review + merge story', async function () {
+ await openWorkspace(repoPath);
+ await forceTheme('light');
+
+ // ── Beat 1: the starting point — a single worktree, real history ─────────
+ await openTab('Graph');
+ await $('[data-testid^="commit-row-"]').waitForDisplayed({ timeout: UI_TIMEOUT });
+ await frame('01-graph-initial', 400);
+
+ // ── Beat 2: spin up three worktrees for three parallel tasks ─────────────
+ await $('[data-testid="btn-open-create-worktree"]').click();
+ await $('[data-testid="input-new-branch"]').waitForDisplayed({ timeout: UI_TIMEOUT });
+ await frame('02-new-worktree-dialog', 250);
+ await $('[data-testid="input-new-branch"]').setValue('auth-refresh');
+ await frame('03-new-worktree-typed', 200);
+ await $('[data-testid="btn-create-worktree"]').click();
+ await $('[data-testid="worktree-item"][data-branch="auth-refresh"]').waitForDisplayed({ timeout: UI_TIMEOUT });
+ await frame('04-worktree-auth-created', 300);
+
+ await createWorktreeViaUi('dashboard-polish');
+ await frame('05-worktree-dashboard-created', 300);
+
+ await createWorktreeViaUi('notif-batching');
+ await frame('06-worktree-notif-created', 400);
+
+ // ── Beat 3: launch a parallel agent session in each worktree ─────────────
+ await switchToWorktree('auth-refresh');
+ await launchAgentForActiveWorktree('auth-refresh');
+ await frame('07-agent-auth-start', 700);
+ await frame('08-agent-auth-progress', 1400);
+
+ await switchToWorktree('dashboard-polish');
+ await launchAgentForActiveWorktree('dashboard-polish');
+ await frame('09-agent-dashboard-start', 700);
+ await frame('10-agent-dashboard-progress', 1400);
+
+ await switchToWorktree('notif-batching');
+ await launchAgentForActiveWorktree('notif-batching');
+ await frame('11-agent-notif-progress', 1600);
+
+ // ── Beat 4: the payoff shot — three agents live at once ───────────────────
+ await openTab('Graph');
+ await frame('12-three-agents-parallel', 400);
+
+ // ── Beat 5: review a diff from one of the finished sessions ──────────────
+ const authWtPath = join(repoPath, '.sproutgit', 'worktrees', 'auth-refresh');
+ writeFile(
+ authWtPath,
+ 'src/auth/jwt.ts',
+ 'export function authenticate(token: string) { return !!token; }\n' +
+ 'export function refreshToken(token: string): string {\n return token;\n}\n'
+ );
+ await switchToWorktree('auth-refresh');
+ await openTab('Changes');
+ const unstagedRow = $('[data-testid="staging-unstaged-file-row"]');
+ await unstagedRow.waitForDisplayed({ timeout: UI_TIMEOUT });
+ await unstagedRow.click();
+ await frame('13-diff-review', 600);
+
+ // ── Beat 6: land the finished work — commit + merge two of the three ─────
+ const authPath = join(repoPath, '.sproutgit', 'worktrees', 'auth-refresh');
+ const dashboardPath = join(repoPath, '.sproutgit', 'worktrees', 'dashboard-polish');
+ commitAll(authPath, 'feat(auth): add refresh token support', 6);
+ writeFile(dashboardPath, 'src/ui/dashboard.tsx',
+ 'export function Dashboard() {\n return
;\n}\n');
+ commitAll(dashboardPath, 'feat(ui): responsive dashboard layout', 7);
+ // Opening a plain repo as a workspace migrates it into SproutGit's
+ // managed layout (`.sproutgit/root` bare repo + per-branch worktrees) —
+ // `main` becomes a worktree in its own right rather than living at
+ // `repoPath` itself, so that's where the merge must run.
+ const mainWtPath = join(repoPath, '.sproutgit', 'worktrees', 'main');
+ mergeNoFf(mainWtPath, 'auth-refresh', 'Merge auth-refresh', 8);
+ mergeNoFf(mainWtPath, 'dashboard-polish', 'Merge dashboard-polish', 9);
+
+ await closeAllTerminals();
+ await openWorkspace(repoPath);
+ await $('[title="Refresh"]').click();
+ await openTab('Graph');
+ await $('[data-testid^="commit-row-"]').waitForDisplayed({ timeout: UI_TIMEOUT });
+ await frame('14-graph-merged', 500);
+ await frame('15-graph-merged-hold', 900);
+ });
+});
diff --git a/packages/git/src/__tests__/staging.test.ts b/packages/git/src/__tests__/staging.test.ts
new file mode 100644
index 00000000..85f5c863
--- /dev/null
+++ b/packages/git/src/__tests__/staging.test.ts
@@ -0,0 +1,85 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { execSync } from 'node:child_process';
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+import { getWorktreeStatus } from '../staging.js';
+
+const createdDirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of createdDirs.splice(0)) {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+function tempDir(prefix: string): string {
+ const dir = realpathSync.native(mkdtempSync(join(tmpdir(), prefix)));
+ createdDirs.push(dir);
+ return dir;
+}
+
+function createRepoWithOneTrackedFile(dir: string): void {
+ mkdirSync(dir, { recursive: true });
+ execSync('git init -b main', { cwd: dir, stdio: 'ignore' });
+ execSync('git config user.email "test@sproutgit.test"', { cwd: dir, stdio: 'ignore' });
+ execSync('git config user.name "SproutGit Test"', { cwd: dir, stdio: 'ignore' });
+ writeFileSync(join(dir, 'a.ts'), 'export const a = 1;\n');
+ execSync('git add .', { cwd: dir, stdio: 'ignore' });
+ execSync('git commit -m "initial commit"', { cwd: dir, stdio: 'ignore' });
+}
+
+describe('getWorktreeStatus', () => {
+ // Regression: `git status --porcelain=v1` prefixes an unstaged-only entry
+ // with a literal leading space (" M path"). Porcelain status/index codes
+ // are column-positional, so if that first line's leading space is ever
+ // trimmed off, every column shifts — the file misreports as staged and its
+ // path loses its first character. This only shows up on the very first
+ // status line (a whole-blob `.trim()` doesn't touch interior lines), which
+ // is also the common case of "exactly one file changed".
+ it('reports a single unstaged modification as unstaged, with the full path intact', async () => {
+ const repoPath = tempDir('sg-status-single-unstaged-');
+ createRepoWithOneTrackedFile(repoPath);
+ writeFileSync(join(repoPath, 'a.ts'), 'export const a = 2;\n');
+
+ const { files } = await getWorktreeStatus(repoPath);
+
+ expect(files).toHaveLength(1);
+ expect(files[0]).toMatchObject({
+ path: 'a.ts',
+ staged: false,
+ indexStatus: ' ',
+ workTreeStatus: 'M',
+ });
+ });
+
+ it('reports a single staged addition as staged', async () => {
+ const repoPath = tempDir('sg-status-single-staged-');
+ createRepoWithOneTrackedFile(repoPath);
+ writeFileSync(join(repoPath, 'b.ts'), 'export const b = 1;\n');
+ execSync('git add b.ts', { cwd: repoPath, stdio: 'ignore' });
+
+ const { files } = await getWorktreeStatus(repoPath);
+
+ expect(files).toHaveLength(1);
+ expect(files[0]).toMatchObject({
+ path: 'b.ts',
+ staged: true,
+ indexStatus: 'A',
+ });
+ });
+
+ it('reports both an unstaged and a staged file together, each with the correct path', async () => {
+ const repoPath = tempDir('sg-status-mixed-');
+ createRepoWithOneTrackedFile(repoPath);
+ writeFileSync(join(repoPath, 'a.ts'), 'export const a = 3;\n');
+ writeFileSync(join(repoPath, 'c.ts'), 'export const c = 1;\n');
+ execSync('git add c.ts', { cwd: repoPath, stdio: 'ignore' });
+
+ const { files } = await getWorktreeStatus(repoPath);
+ const byPath = Object.fromEntries(files.map(f => [f.path, f]));
+
+ expect(byPath['a.ts']).toMatchObject({ staged: false, workTreeStatus: 'M' });
+ expect(byPath['c.ts']).toMatchObject({ staged: true, indexStatus: 'A' });
+ });
+});
diff --git a/packages/git/src/staging.ts b/packages/git/src/staging.ts
index eb85f7f9..dec12d27 100644
--- a/packages/git/src/staging.ts
+++ b/packages/git/src/staging.ts
@@ -97,13 +97,25 @@ export async function resetWorktreeBranch(
function parsePorcelainStatus(raw: string): StatusFileEntry[] {
return raw
- .trim()
.split('\n')
.filter(Boolean)
.map(line => {
- const indexStatus = line[0] ?? ' ';
- const worktreeStatus = line[1] ?? ' ';
- const filePath = line.slice(3).trim();
+ // A well-formed porcelain=v1 line is "XY path" — two status columns
+ // then a separating space at index 2. But `gitForPath()` constructs
+ // its SimpleGit client with `trimmed: true`, which trims the *entire*
+ // raw output string (not just its trailing newline) before we ever see
+ // it here. When the first-listed file is unstaged-only (index column
+ // is a literal space, e.g. " M path"), that leading space is the first
+ // character of the whole blob and gets eaten, shifting every column
+ // left by one ("M path") — misreporting it as staged and truncating
+ // its path. Detect the shift the same way simple-git's own built-in
+ // status parser does: a genuine separator lands at index 2; if it
+ // instead lands at index 1, the line lost its leading (empty) index
+ // column and needs it added back.
+ const shifted = line.charAt(2) !== ' ' && line.charAt(1) === ' ';
+ const indexStatus = shifted ? ' ' : (line[0] ?? ' ');
+ const worktreeStatus = shifted ? line.charAt(0) : (line[1] ?? ' ');
+ const filePath = (shifted ? line.slice(2) : line.slice(3)).trim();
// Staged = index column is not a space or '?'
const staged = indexStatus !== ' ' && indexStatus !== '?';
diff --git a/scripts/build-demo-gif.sh b/scripts/build-demo-gif.sh
new file mode 100755
index 00000000..87242fa3
--- /dev/null
+++ b/scripts/build-demo-gif.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+# Regenerates website/public/demo.gif from the WebdriverIO demo-gif spec.
+#
+# Prerequisites: the app must be built (`pnpm --filter app build`), and
+# `ffmpeg` must be on PATH (`brew install ffmpeg`).
+#
+# Usage: ./scripts/build-demo-gif.sh
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+# Mirrors e2e/helpers/screenshots.ts's PLATFORM_FOLDER logic — captured
+# frames are nested under a platform subfolder named for the OS that ran
+# the capture (mac/windows/linux), not necessarily "mac".
+case "$(uname -s)" in
+ Darwin) PLATFORM_FOLDER="mac" ;;
+ MINGW*|MSYS*|CYGWIN*) PLATFORM_FOLDER="windows" ;;
+ *) PLATFORM_FOLDER="linux" ;;
+esac
+
+FRAMES_DIR="$ROOT_DIR/e2e/test-results/demo-gif-frames/$PLATFORM_FOLDER/demo"
+OUT_GIF="$ROOT_DIR/website/public/demo.gif"
+WORK_DIR="$(mktemp -d)"
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+echo "==> Capturing frames via WebdriverIO"
+rm -rf "$ROOT_DIR/e2e/test-results/demo-gif-frames"
+(cd "$ROOT_DIR/e2e" && pnpm demo-gif-frames)
+
+# name:duration(seconds) — pacing for each captured beat. Edit alongside
+# demo-gif.spec.ts's frame() calls if the story there changes.
+FRAMES=(
+ "01-graph-initial:1.4"
+ "02-new-worktree-dialog:0.6"
+ "03-new-worktree-typed:0.6"
+ "04-worktree-auth-created:0.7"
+ "05-worktree-dashboard-created:0.7"
+ "06-worktree-notif-created:0.9"
+ "07-agent-auth-start:0.7"
+ "08-agent-auth-progress:1.3"
+ "09-agent-dashboard-start:0.7"
+ "10-agent-dashboard-progress:1.3"
+ "11-agent-notif-progress:1.3"
+ "12-three-agents-parallel:1.6"
+ "13-diff-review:1.8"
+ "14-graph-merged:0.8"
+ "15-graph-merged-hold:1.8"
+)
+
+LIST="$WORK_DIR/frames.txt"
+: > "$LIST"
+for entry in "${FRAMES[@]}"; do
+ name="${entry%%:*}"
+ dur="${entry##*:}"
+ echo "file '$FRAMES_DIR/$name.png'" >> "$LIST"
+ echo "duration $dur" >> "$LIST"
+done
+# The concat demuxer ignores the last entry's duration unless the final file
+# is repeated once more without one.
+last_name="${FRAMES[-1]%%:*}"
+echo "file '$FRAMES_DIR/$last_name.png'" >> "$LIST"
+
+echo "==> Generating palette"
+ffmpeg -y -f concat -safe 0 -i "$LIST" \
+ -vf "fps=12,scale=880:-1:flags=lanczos,palettegen=stats_mode=diff" \
+ "$WORK_DIR/palette.png"
+
+echo "==> Encoding GIF"
+ffmpeg -y -f concat -safe 0 -i "$LIST" -i "$WORK_DIR/palette.png" \
+ -lavfi "fps=12,scale=880:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3" \
+ "$OUT_GIF"
+
+echo "==> Done: $OUT_GIF ($(du -h "$OUT_GIF" | cut -f1))"
diff --git a/website/public/demo.gif b/website/public/demo.gif
new file mode 100644
index 00000000..c58ef7ff
Binary files /dev/null and b/website/public/demo.gif differ
diff --git a/website/src/components/DemoGif.astro b/website/src/components/DemoGif.astro
new file mode 100644
index 00000000..35a89ec2
--- /dev/null
+++ b/website/src/components/DemoGif.astro
@@ -0,0 +1,31 @@
+---
+---
+
+
+
+
+
+ See it in action
+
+
+ Parallel worktrees, parallel agents, one window.
+
+
+
+
+

+
+
+
diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro
index 21f7eeb4..98b6a2b3 100644
--- a/website/src/pages/index.astro
+++ b/website/src/pages/index.astro
@@ -1,6 +1,7 @@
---
import Base from '../layouts/Base.astro';
import NavHero from '../components/NavHero.astro';
+import DemoGif from '../components/DemoGif.astro';
import ScreenshotSlider from '../components/ScreenshotSlider.astro';
import Why from '../components/Why.astro';
import Features from '../components/Features.astro';
@@ -11,6 +12,7 @@ import '../styles/global.css';
+