diff --git a/README.md b/README.md index 980ce745..d254723c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ License

+

+ SproutGit demo: creating worktrees, launching parallel agent sessions, reviewing a diff, and merging — all from one window +

+ --- > [!NOTE] diff --git a/app/electron.vite.config.ts b/app/electron.vite.config.ts index 9bf959b4..7f185084 100644 --- a/app/electron.vite.config.ts +++ b/app/electron.vite.config.ts @@ -12,6 +12,7 @@ const WORKSPACE_PACKAGES = [ '@sproutgit/git', '@sproutgit/terminal', '@sproutgit/provider-github', + '@sproutgit/fs-watch', ]; /** diff --git a/e2e/helpers/screenshots.ts b/e2e/helpers/screenshots.ts index 9cf57eef..a0a38a01 100644 --- a/e2e/helpers/screenshots.ts +++ b/e2e/helpers/screenshots.ts @@ -144,7 +144,7 @@ const DARK_XTERM_THEME = { brightWhite: '#a6adc8', }; -async function forceTheme(theme: 'light' | 'dark'): Promise { +export async function forceTheme(theme: 'light' | 'dark'): Promise { const cssVars = theme === 'dark' ? DARK_CSS_VARS : LIGHT_CSS_VARS; const termBg = theme === 'dark' ? '#1e1e2e' : '#eff1f5'; const styleContent = `:root{${cssVars}} [data-sg-terminal]{background-color:${termBg}!important}`; diff --git a/e2e/package.json b/e2e/package.json index 7c4822a6..efcac3ef 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -5,7 +5,8 @@ "scripts": { "test": "wdio run wdio.conf.ts", "test:headed": "wdio run wdio.conf.ts --headless false", - "screenshots": "CAPTURE_SCREENSHOTS=1 SCREENSHOT_TARGET=../website/src/assets/screenshots wdio run wdio.conf.ts --spec specs/hero-screenshots.spec.ts" + "screenshots": "CAPTURE_SCREENSHOTS=1 SCREENSHOT_TARGET=../website/src/assets/screenshots wdio run wdio.conf.ts --spec specs/hero-screenshots.spec.ts", + "demo-gif-frames": "CAPTURE_SCREENSHOTS=1 SCREENSHOT_TARGET=../e2e/test-results/demo-gif-frames wdio run wdio.conf.ts --spec specs/demo-gif.spec.ts" }, "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/e2e/specs/demo-gif.spec.ts b/e2e/specs/demo-gif.spec.ts new file mode 100644 index 00000000..7b71730b --- /dev/null +++ b/e2e/specs/demo-gif.spec.ts @@ -0,0 +1,319 @@ +/** + * Demo GIF spec — drives the real Electron app through the core pitch + * (create worktrees, launch parallel agent sessions, review a diff, merge) + * while capturing a numbered sequence of screenshots. The frames are later + * stitched into a GIF by `scripts/build-demo-gif.sh` (ffmpeg). + * + * Agent sessions are simulated: the configured "agent" is a short Node + * script (see `agentScript()`) that prints a canned, per-worktree transcript + * based on the `SPROUTGIT_WORKTREE_NAME` env var the app injects when + * launching an agent — no real coding-agent CLI is required to produce a + * plausible-looking terminal session. + * + * ─── How to run ────────────────────────────────────────────────────────────── + * CAPTURE_SCREENSHOTS=1 SCREENSHOT_TARGET=../e2e/test-results/demo-gif-frames \ + * npx wdio run wdio.conf.ts --spec specs/demo-gif.spec.ts + * ───────────────────────────────────────────────────────────────────────────── + */ + +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { tmpdir } from 'node:os'; +import { rmWithRetry } from '../helpers.js'; +import { captureNamedScreenshot, forceTheme } from '../helpers/screenshots.js'; + +const UI_TIMEOUT = 20_000; + +// ── Git helpers (same pattern as hero-screenshots.spec.ts) ──────────────────── + +const BASE_GIT_ENV = { + GIT_AUTHOR_NAME: 'SproutGit Test', + GIT_AUTHOR_EMAIL: 'test@sproutgit.test', + GIT_COMMITTER_NAME: 'SproutGit Test', + GIT_COMMITTER_EMAIL: 'test@sproutgit.test', + GIT_TERMINAL_PROMPT: '0', +}; + +function gitEnv(sequence: number) { + const daysAgo = 20 * (1 - sequence / 10); + const d = new Date(Date.now() - daysAgo * 86_400_000); + d.setHours(9 + (sequence % 8), (sequence * 7) % 60, 0, 0); + const iso = d.toISOString(); + return { ...BASE_GIT_ENV, GIT_AUTHOR_DATE: iso, GIT_COMMITTER_DATE: iso }; +} + +function runGit(cwd: string, args: string[], env: Record = {}) { + const clean = { ...process.env }; + delete clean['GIT_DIR']; + delete clean['GIT_WORK_TREE']; + delete clean['GIT_INDEX_FILE']; + delete clean['GIT_COMMON_DIR']; + const result = spawnSync('git', args, { + cwd, + env: { ...clean, ...BASE_GIT_ENV, ...env }, + stdio: 'pipe', + encoding: 'utf8', + }); + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed:\n${result.stderr || result.error?.message || '(no output)'}`); + } +} + +function writeFile(repoPath: string, relativePath: string, content: string) { + const abs = join(repoPath, relativePath); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, content); +} + +function commitAll(repoPath: string, message: string, seq: number) { + runGit(repoPath, ['add', '--all'], gitEnv(seq)); + runGit(repoPath, ['commit', '-m', message], gitEnv(seq)); +} + +function mergeNoFf(repoPath: string, ref: string, message: string, seq: number) { + runGit(repoPath, ['merge', '--no-ff', ref, '-m', message], gitEnv(seq)); +} + +/** A small but non-trivial repo: a couple of base commits plus one already-merged feature. */ +function createDemoRepo(): string { + const repoPath = mkdtempSync(join(tmpdir(), 'sg-demo-repo-')); + runGit(repoPath, ['init', '-b', 'main']); + runGit(repoPath, ['config', 'user.email', 'test@sproutgit.test']); + runGit(repoPath, ['config', 'user.name', 'SproutGit Test']); + + writeFile(repoPath, 'README.md', '# Axiom\n\nA modern TypeScript application framework.\n'); + writeFile(repoPath, 'src/index.ts', 'export { createApp } from "./app";\n'); + writeFile(repoPath, 'src/app.ts', 'export function createApp() { return { version: "0.1.0" }; }\n'); + writeFile(repoPath, 'src/auth/index.ts', 'export { authenticate } from "./jwt";\n'); + writeFile(repoPath, 'src/auth/jwt.ts', 'export function authenticate(token: string) { return !!token; }\n'); + writeFile(repoPath, 'src/ui/dashboard.tsx', 'export function Dashboard() { return
Dashboard
; }\n'); + writeFile(repoPath, 'src/ui/components/MetricsPanel.tsx', 'export function MetricsPanel() { return
; }\n'); + writeFile(repoPath, 'src/notifications/index.ts', 'export function notify(msg: string) { console.log(msg); }\n'); + commitAll(repoPath, 'Initial commit', 0); + writeFile(repoPath, 'package.json', '{"name":"axiom","version":"0.1.0","private":true}\n'); + commitAll(repoPath, 'Add project scaffolding', 1); + + runGit(repoPath, ['checkout', '-b', 'feature/onboarding']); + writeFile(repoPath, 'src/onboarding.ts', 'export function onboard() { return "welcome"; }\n'); + commitAll(repoPath, 'feat: add onboarding flow', 2); + runGit(repoPath, ['checkout', 'main']); + mergeNoFf(repoPath, 'feature/onboarding', 'Merge feature/onboarding', 3); + + return repoPath; +} + +// ── Simulated agent ─────────────────────────────────────────────────────────── + +const AGENT_TRANSCRIPTS: Record = { + 'auth-refresh': [ + '> Reading src/auth/jwt.ts', + '> Legacy token check has no refresh support', + '> Editing src/auth/jwt.ts', + '+ export function refreshToken(token: string): string { ... }', + '> Running: pnpm vitest run auth', + ' ✓ validates JWT (8ms)', + ' ✓ refreshes an expiring token (5ms)', + '✓ 2 tests passed — ready for review', + ], + 'dashboard-polish': [ + '> Reading src/ui/dashboard.tsx', + '> Dashboard has no responsive layout below 768px', + '> Editing src/ui/dashboard.tsx', + '+
', + '> 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. +

+
+ +
+ Demo: creating worktrees, launching parallel AI agent sessions in each one, reviewing a diff, and merging — all from the same 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'; +