diff --git a/.claude/skills/store-screenshots/SKILL.md b/.claude/skills/store-screenshots/SKILL.md new file mode 100644 index 0000000000..f0c319dd05 --- /dev/null +++ b/.claude/skills/store-screenshots/SKILL.md @@ -0,0 +1,19 @@ +--- +name: store-screenshots +description: Refresh the App Store and Google Play screenshots for phones and tablets. Use when the user says "store screenshots", "app store screenshots", "play store screenshots", "iPad screenshots", "recapture the screenshots", or wants the store panels changed. +user-invocable: true +--- + +# Store screenshots + +Follow `assets/store-screenshots/README.md`. It owns the panels, the creative direction, the render command and the recapture steps. Change copy, crops and layout in `assets/store-screenshots/render.mjs`, not in the exports. + +Working rules: + +- Capture at native density: `--pixel-density 3` on the iPhone 16 Pro Max, `--pixel-density 2` on the iPad Pro 13-inch. The default is 1x, which is too soft for the store. +- Every headline wraps to two lines and every subline fits on one. The renderer enforces this; rewrite the copy rather than shrink the type. +- Use SF Pro. Never substitute another font. +- Open `output/preview.png` and a full-size panel from each store before you report a render. Show the user the preview. +- Do not upload screenshots to a store, a PR, or anywhere public, and do not commit, unless the user asks. +- Stage the demo host on its own port and pick a free Metro port. Do not restart the main daemons. +- When you learn something the next capture needs, fix the README rather than this skill. diff --git a/assets/store-screenshots/.gitignore b/assets/store-screenshots/.gitignore new file mode 100644 index 0000000000..22908c2c7d --- /dev/null +++ b/assets/store-screenshots/.gitignore @@ -0,0 +1 @@ +output/preview.png diff --git a/assets/store-screenshots/README.md b/assets/store-screenshots/README.md new file mode 100644 index 0000000000..6db3d236ec --- /dev/null +++ b/assets/store-screenshots/README.md @@ -0,0 +1,98 @@ +# Store screenshots + +Five panels for the App Store and Google Play, in phone and tablet sets, made from native captures of the real app. Both sets tell the same story with the same copy; only the capture, the device frame and the callouts change. + +| # | Headline | Capture | Phone callout | Tablet callout | +| --- | --------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------- | +| 1 | Run many coding agents at once | Sidebar grouped by status: Needs input, Ready to review, Working, Done | — (agent marks in the background) | — (agent marks in the background) | +| 2 | Your full dev environment on your phone | Agent chat: reasoning, tool calls, final summary | — | — (Changes pane open beside the chat) | +| 3 | Ask an agent to change the app itself | A chat asking for a plugin, the turn collapsed, the new Deploys pill | The plugin's Preview deploys sheet | The plugin's Deploys popover | +| 4 | Review the diff before you merge | `daemon-client.ts` diff with removed and added lines | Jump to file: four changed files with counts | — (the full-width diff reads as is) | +| 5 | A real terminal in every workspace | `git status` and a vitest run in the same worktree | The passing test tree | The passing test tree | + +On tablets, panel 2 says "on your tablet" and panel 3 calls the popover a panel. A scene overrides its copy per device in `render.mjs`. + +## Creative direction + +The panels follow paseo.sh: + +- **Canvas:** the site's `#101615` background. +- **Type:** centred SF Pro headlines in sentence case, with no closing period. Every headline wraps to exactly two lines. +- **Sublines:** one plain grey sentence on one line that says what you can do. The renderer fails if a headline or subline wraps differently, because the device is placed from that fixed text height. +- **Agent marks:** panel 1 scatters faded, rotated, monochrome marks of the agents featured on the homepage behind the device. They are rendered from `packages/website/src/components/agent-icons.tsx`, so they follow the site. +- **Device:** dark-mode captures in a plain black frame; the iPhone frame has a Dynamic Island. The device starts a fixed gap below the text and runs just off the bottom edge, so a taller canvas gets a larger device instead of a larger gap. A canvas too narrow for that, like a 3:4 iPad on Google's 9:16 tablet canvas, shows the whole device at full width with the text and device centred. +- **Callouts:** a callout lifts one region of a capture out of the device, larger than the device around it, so the detail reads at store thumbnail size. On tablets, place callouts over empty screen so they do not hide the context they explain. + +Everything on screen is real app UI showing one story. The hero agent adds jitter to the reconnect backoff in this repository. Its diff, test run and terminal come from a real worktree. The other workspaces fill the sidebar's status groups. + +Panel 3 shows a real plugin. `demo/preview-deploys/` is a trusted local plugin that adds a Deploys composer pill; its popover opens as a sheet on phones and as a popover on tablets. The deploys it lists are sample data. The demo host installs it, and a scripted agent in the "Build a preview deploys plugin" workspace plays the turn that writes it. + +## Files + +- `raw/iphone/`: native 1320 × 2868 captures from an iPhone 16 Pro Max simulator. `files.png` and `plugin-sheet.png` are the sources for the Jump to file and Preview deploys callouts. +- `raw/ipad/`: native 2064 × 2752 captures from an iPad Pro 13-inch (M4) simulator. `plugin-sheet.png` is the source for the Deploys popover callout. +- `render.mjs`: the layout, copy and callout crops. Edit `SCENES`, `DEVICES` and `STORES` there. +- `demo/`: the staged demo host the captures come from. +- `output/apple/`: 1320 × 2868 for the iPhone 6.9-inch slot. +- `output/google/`: 1440 × 2560 (9:16) phone screenshots, reusing the iPhone captures. +- `output/apple-ipad/`: 2064 × 2752 for the iPad 13-inch slot. +- `output/google-tablet/`: 1800 × 3200 (9:16) tablet screenshots, reusing the iPad captures. +- `output/preview.png`: contact sheet with one row per store, for review only. It is ignored by Git; do not upload it to a store. + +All exports are opaque RGB PNGs. + +## Render + +```sh +SF_FONT_DIR=/path/to/fonts node assets/store-screenshots/render.mjs +``` + +`SF_FONT_DIR` must hold `SFNS.ttf` copied from a Mac's `/System/Library/Fonts`. On macOS the renderer finds it without the variable. SF Pro is not redistributable, so it is not in the repository. The renderer fails rather than fall back to another font. + +Each capture and callout is resized with Lanczos to the exact pixels it fills, and the page renders at device scale 1. Do not render at a higher scale and downsample: Chrome's image downscaling is what made earlier exports look soft and aliased. + +Open `output/preview.png` and at least one full-size panel per store before you call a render done. The line guard catches wrapping, not a callout that covers the wrong thing. + +Callout crops are in native capture pixels. To find them, overlay a 100 px grid on the capture with sharp and read the coordinates off it. + +## Recapture + +1. Stage the demo host. It runs an in-process daemon on port 7791 with scripted Claude Code and Codex clients, a local clone of this repository as the `paseo` project, real worktrees, and a terminal in the hero worktree: + + ```sh + npx tsx assets/store-screenshots/demo/stage.mts + ``` + + Each run re-clones the repository into the demo home, so stale worktrees from an earlier run do not block it. The hero agent is paced so it reports a realistic "Worked for" time. Wait for `READY` in its output. + +2. Start Metro against it on a free port. When the simulator runs on another Mac, forward Metro and the daemon to it. Other agents may already hold a Metro port; pick another one rather than stopping theirs. + + ```sh + EXPO_PUBLIC_LOCAL_DAEMON=127.0.0.1:7791 npm run start:expo --workspace=@getpaseo/app -- --port + ssh -o ExitOnForwardFailure=yes -R :127.0.0.1: -R 7791:127.0.0.1:7791 + ``` + +3. On the simulator, install and open the dev client (`sh.paseo.debug`) at `http://127.0.0.1:`. Use an iPhone 16 Pro Max for the phone set and an iPad Pro 13-inch (M4) in portrait for the tablet set. + - Set dark appearance and override the status bar to 9:41 with full battery and signal (`xcrun simctl status_bar override`). + - On first launch, accept the open-in-app dialog, decline notifications, turn off the dev client's Tools button, and close any error toast. `agent-device react-native dismiss-overlay` clears overlays. + - Name the host "MacBook Pro" and set the sidebar badge to Hidden. + - Group the sidebar by status. + - If you reset the simulator, reinstall the dev client. + +4. Capture the sidebar first. Opening a "Ready to review" workspace marks it done. Select a Working row before you capture. + +5. Capture every screen at native density. `agent-device screenshot` defaults to 1x, which is too soft for the store: + + ```sh + agent-device screenshot /tmp/sidebar.png --pixel-density 3 --session store # iPhone: 1320 × 2868 + agent-device screenshot /tmp/sidebar.png --pixel-density 2 --session store # iPad: 2064 × 2752 + ``` + + - **Chat:** expand the tool-call group and scroll to the bottom. On iPad, open Explorer → Changes beside the chat. + - **Diff:** on iPhone, Explorer → Changes, then use Jump to file to reach `daemon-client.ts`. On iPad, collapse the sidebar, open a Diff tab from the main area's **+** menu and scroll to the `daemon-client.ts` hunks. + - **Terminal:** the second workspace tab. Type the commands with `npm run cli -- terminal send-keys --host 127.0.0.1:7791 "" Enter` instead of the simulator keyboard, then dismiss the software keyboard. + - **Plugin:** the "Build a preview deploys plugin" workspace with the turn collapsed, then tap **Deploys** for `plugin-sheet.png`. After editing the plugin, reload it with `npm run cli -- plugin reload --host 127.0.0.1:7791 preview-deploys`. + +6. Copy the captures into `raw/iphone/` or `raw/ipad/` under the names `render.mjs` uses, render, and check the preview. + +Dimensions are checked against [Apple's screenshot specifications](https://developer.apple.com/help/app-store-connect/reference/app-information/screenshot-specifications/) and [Google Play's preview asset requirements](https://support.google.com/googleplay/android-developer/answer/9866151?hl=en). diff --git a/assets/store-screenshots/demo/change/apply.mjs b/assets/store-screenshots/demo/change/apply.mjs new file mode 100644 index 0000000000..ae5efe295b --- /dev/null +++ b/assets/store-screenshots/demo/change/apply.mjs @@ -0,0 +1,84 @@ +// Applies the demo change to a checkout of the Paseo repository. +import { copyFileSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = process.argv[2]; +const client = path.join(repo, "packages/client/src"); + +copyFileSync( + path.join(here, "reconnect-delay.ts"), + path.join(client, "connection/reconnect-delay.ts"), +); +copyFileSync( + path.join(here, "reconnect-delay.test.ts"), + path.join(client, "connection/reconnect-delay.test.ts"), +); + +function edit(file, pairs) { + let text = readFileSync(file, "utf8"); + for (const [from, to] of pairs) { + if (!text.includes(from)) throw new Error(`Missing anchor in ${file}: ${from.slice(0, 60)}`); + text = text.replace(from, to); + } + writeFileSync(file, text); +} + +edit(path.join(client, "daemon-client.ts"), [ + [ + `import { CreationClient } from "./creation/index.js";\n`, + `import { CreationClient } from "./creation/index.js";\nimport { getReconnectDelayMs } from "./connection/reconnect-delay.js";\n`, + ], + [ + ` private reconnectAttempt = 0;\n`, + ` private reconnectAttempt = 0;\n private nextReconnectDelay: { attempt: number; delayMs: number } | null = null;\n`, + ], + [ + ` nextRetryAt: new Date( + Date.now() + + Math.min( + (this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS) * + 2 ** this.reconnectAttempt, + this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + ), + ).toISOString(), +`, + ` nextRetryAt: new Date(Date.now() + this.getNextReconnectDelayMs()).toISOString(), +`, + ], + [ + ` private armReconnectTimer(): void { + const attempt = this.reconnectAttempt; + const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; + const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; + const delay = Math.min(baseDelay * 2 ** attempt, maxDelay); +`, + ` // The countdown and the timer must agree, so each attempt draws its jitter once. + private getNextReconnectDelayMs(): number { + const attempt = this.reconnectAttempt; + if (this.nextReconnectDelay?.attempt !== attempt) { + this.nextReconnectDelay = { + attempt, + delayMs: getReconnectDelayMs(attempt, { + baseDelayMs: this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS, + maxDelayMs: this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS, + }), + }; + } + return this.nextReconnectDelay.delayMs; + } + + private armReconnectTimer(): void { + const attempt = this.reconnectAttempt; + const delay = this.getNextReconnectDelayMs(); +`, + ], +]); + +edit(path.join(repo, "CHANGELOG.md"), [ + [ + `# Changelog\n\n`, + `# Changelog\n\n## Unreleased\n\n### Fixed\n\n- Spread reconnect attempts after a relay restart instead of retrying every client at once\n\n`, + ], +]); diff --git a/assets/store-screenshots/demo/change/reconnect-delay.test.ts b/assets/store-screenshots/demo/change/reconnect-delay.test.ts new file mode 100644 index 0000000000..3b5367784c --- /dev/null +++ b/assets/store-screenshots/demo/change/reconnect-delay.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { getReconnectDelayMs } from "./reconnect-delay"; + +const options = { baseDelayMs: 1500, maxDelayMs: 30_000 }; + +describe("getReconnectDelayMs", () => { + it("doubles the ceiling on each attempt", () => { + const top = () => 1; + expect(getReconnectDelayMs(0, options, top)).toBe(1500); + expect(getReconnectDelayMs(1, options, top)).toBe(3000); + expect(getReconnectDelayMs(2, options, top)).toBe(6000); + }); + + it("never waits longer than the max delay", () => { + expect(getReconnectDelayMs(12, options, () => 1)).toBe(30_000); + }); + + it("spreads retries between half and the full ceiling", () => { + expect(getReconnectDelayMs(3, options, () => 0)).toBe(6000); + expect(getReconnectDelayMs(3, options, () => 0.5)).toBe(9000); + expect(getReconnectDelayMs(3, options, () => 1)).toBe(12_000); + }); + + it("keeps the first retry under two seconds", () => { + for (let i = 0; i < 100; i += 1) { + expect(getReconnectDelayMs(0, options)).toBeLessThanOrEqual(1500); + } + }); +}); diff --git a/assets/store-screenshots/demo/change/reconnect-delay.ts b/assets/store-screenshots/demo/change/reconnect-delay.ts new file mode 100644 index 0000000000..88de867aa6 --- /dev/null +++ b/assets/store-screenshots/demo/change/reconnect-delay.ts @@ -0,0 +1,18 @@ +export interface ReconnectDelayOptions { + baseDelayMs: number; + maxDelayMs: number; +} + +/** + * Exponential backoff with equal jitter. Half of each delay is fixed and half + * is random, so a relay restart spreads reconnects out instead of every client + * retrying on the same tick. + */ +export function getReconnectDelayMs( + attempt: number, + options: ReconnectDelayOptions, + random: () => number = Math.random, +): number { + const ceiling = Math.min(options.baseDelayMs * 2 ** attempt, options.maxDelayMs); + return Math.round(ceiling / 2 + random() * (ceiling / 2)); +} diff --git a/assets/store-screenshots/demo/preview-deploys/index.client.tsx b/assets/store-screenshots/demo/preview-deploys/index.client.tsx new file mode 100644 index 0000000000..72ab9a709a --- /dev/null +++ b/assets/store-screenshots/demo/preview-deploys/index.client.tsx @@ -0,0 +1,204 @@ +// The plugin the demo agent "builds" in the store screenshots: a Deploys pill on +// every composer that opens the latest preview deploy for each branch. The +// deploys are fixed sample data; the UI is a real plugin surface. +import type { + PluginButtonContentProps, + PluginButtonRegistration, + PluginClientContext, +} from "@getpaseo/plugin/client"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; + +type DeployStatus = "ready" | "building" | "failed"; + +interface Deploy { + branch: string; + status: DeployStatus; + detail: string; + age: string; + progress?: number; +} + +const DEPLOYS: Deploy[] = [ + { branch: "fix/reconnect-jitter", status: "ready", detail: "Built in 48s", age: "2m" }, + { + branch: "perf/provider-snapshot-cache", + status: "building", + detail: "Bundling app", + age: "now", + progress: 0.64, + }, + { branch: "feat/remember-terminal", status: "ready", detail: "Built in 51s", age: "18m" }, + { + branch: "i18n/ko-settings", + status: "failed", + detail: "Missing key settings.notifications", + age: "26m", + }, +]; + +function DeploysSheet({ theme, layout }: PluginButtonContentProps) { + const mono = layout.platform === "ios" ? "Menlo" : "monospace"; + const styles = useMemo( + () => ({ + // Popovers on tablets and desktop are narrow by default; give the + // branch names room. Phones show the sheet at full width. + body: { gap: 14, width: layout.compact ? undefined : 400 }, + production: { + padding: 14, + gap: 6, + borderRadius: 12, + backgroundColor: theme.colors.surface2, + }, + productionTop: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 }, + label: { color: theme.colors.foregroundMuted, fontSize: 13 }, + version: { color: theme.colors.foreground, fontSize: 22, fontWeight: "600" as const }, + live: { color: theme.colors.statusSuccess, fontSize: 13, fontWeight: "600" as const }, + heading: { color: theme.colors.foregroundMuted, fontSize: 13, marginTop: 4 }, + list: { + borderRadius: 12, + borderWidth: 1, + borderColor: theme.colors.border, + overflow: "hidden" as const, + }, + row: { + flexDirection: "row" as const, + alignItems: "center" as const, + gap: 12, + paddingVertical: 12, + paddingHorizontal: 14, + }, + divider: { height: 1, backgroundColor: theme.colors.border }, + text: { flex: 1, gap: 4 }, + branch: { color: theme.colors.foreground, fontSize: 14, fontFamily: mono }, + detail: { color: theme.colors.foregroundMuted, fontSize: 13 }, + failed: { color: theme.colors.statusDanger, fontSize: 13 }, + age: { color: theme.colors.foregroundMuted, fontSize: 13 }, + track: { + height: 4, + marginTop: 4, + borderRadius: 2, + backgroundColor: theme.colors.surface2, + overflow: "hidden" as const, + }, + }), + [theme, mono, layout.compact], + ); + const dotColor: Record = { + ready: theme.colors.statusSuccess, + building: theme.colors.statusWarning, + failed: theme.colors.statusDanger, + }; + + return ( + + + + + Live + Production · main + + v0.14.0-beta.1 + Deployed 3h ago + + Previews + + {DEPLOYS.map((deploy, index) => ( + + {index > 0 ? : null} + + + + + {deploy.branch} + + + {deploy.detail} + + {deploy.progress !== undefined ? ( + + ) : null} + + {deploy.age} + + + ))} + + + ); +} + +function Progress({ value, track, color }: { value: number; track: object; color: string }) { + const fill = useMemo( + () => ({ width: `${value * 100}%` as const, height: 4, backgroundColor: color }), + [value, color], + ); + return ( + + + + ); +} + +function Dot({ color }: { color: string }) { + const style = useMemo( + () => ({ width: 8, height: 8, borderRadius: 4, backgroundColor: color }), + [color], + ); + return ; +} + +export default function contribute(client: PluginClientContext) { + const pills = new Map(); + const lifetime = new AbortController(); + const register = (agent: { id: string; workspaceId?: string | null }) => { + if (lifetime.signal.aborted || !agent.workspaceId) return; + pills.get(agent.id)?.remove(); + pills.set( + agent.id, + client.addComposerPill({ + id: "deploys", + workspaceId: agent.workspaceId, + agentId: agent.id, + button: { + title: "Preview deploys", + icon: "Rocket", + label: "Deploys", + behavior: { kind: "popover", Content: DeploysSheet }, + }, + }), + ); + }; + void client.paseo.agents + .list({ subscribe: {}, signal: lifetime.signal }) + .then(({ subscription }) => { + subscription.subscribe({ + snapshot: ({ entries }) => { + for (const pill of pills.values()) pill.remove(); + pills.clear(); + for (const { agent } of entries) register(agent); + }, + update: (message) => { + if (message.type !== "agent_update") return; + const update = message.payload; + if (update.kind === "remove") { + pills.get(update.agentId)?.remove(); + pills.delete(update.agentId); + } else register(update.agent); + }, + }); + return undefined; + }) + .catch((error) => { + if (!lifetime.signal.aborted) console.error("Agent observation failed", error); + }); + return () => { + lifetime.abort(); + for (const pill of pills.values()) pill.remove(); + pills.clear(); + }; +} diff --git a/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json b/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json new file mode 100644 index 0000000000..0da849e8f7 --- /dev/null +++ b/assets/store-screenshots/demo/preview-deploys/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "preview-deploys", + "requirements": { "paseo": ">=0.8.0" } +} diff --git a/assets/store-screenshots/demo/scripted-agents.mts b/assets/store-screenshots/demo/scripted-agents.mts new file mode 100644 index 0000000000..603017f323 --- /dev/null +++ b/assets/store-screenshots/demo/scripted-agents.mts @@ -0,0 +1,281 @@ +// Scripted Claude Code and Codex clients for staging store screenshots. +// Each agent replays a fixed conversation through the real daemon, so the app +// renders real timeline rows, permission prompts, and running states. +import { randomUUID } from "node:crypto"; +import type { + AgentCapabilityFlags, + AgentClient, + AgentMode, + AgentModelDefinition, + AgentPermissionRequest, + AgentPermissionResponse, + AgentPersistenceHandle, + AgentPromptInput, + AgentRunResult, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, + AgentTimelineItem, + ToolCallDetail, +} from "../../../packages/server/src/server/agent/agent-sdk-types.js"; +import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; + +export type ScriptStep = + | { kind: "say"; text: string } + | { kind: "think"; text: string } + | { kind: "tool"; name: string; detail: ToolCallDetail } + | { kind: "todo"; items: { text: string; completed: boolean }[] } + | { kind: "wait"; ms: number } + | { kind: "running-tool"; name: string; detail: ToolCallDetail } + | { kind: "ask"; name: string; title: string; detail: ToolCallDetail }; + +export interface AgentScript { + /** Matched against the start of the prompt that opens the turn. */ + prompt: string; + steps: ScriptStep[]; + /** Leave the turn open after the last step. */ + keepRunning?: boolean; + /** Pause between steps, so "Worked for" reflects a believable duration. */ + stepDelayMs?: number; +} + +const CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: true, + supportsToolInvocations: true, + supportsRewindConversation: false, + supportsRewindFiles: false, + supportsRewindBoth: false, +}; + +const MODELS: Record = { + claude: [ + { + provider: "claude", + id: "claude-opus-5-5", + label: "Opus 5.5", + isDefault: true, + thinkingOptions: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + { provider: "claude", id: "claude-sonnet-5-5", label: "Sonnet 5.5" }, + ], + codex: [ + { + provider: "codex", + id: "gpt-5.6-sol", + label: "GPT-5.6-Sol", + isDefault: true, + thinkingOptions: [ + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "medium", + }, + ], +}; + +function promptText(prompt: AgentPromptInput): string { + if (typeof prompt === "string") return prompt; + return prompt + .map((block) => ("text" in block && typeof block.text === "string" ? block.text : "")) + .join(""); +} + +class ScriptedSession implements AgentSession { + readonly capabilities = CAPABILITIES; + readonly id = randomUUID(); + private readonly listeners = new Set<(event: AgentStreamEvent) => void>(); + private readonly history: AgentStreamEvent[] = []; + private pending: AgentPermissionRequest[] = []; + private activeTurnId: string | null = null; + + constructor( + readonly provider: string, + private readonly config: AgentSessionConfig, + private readonly scripts: AgentScript[], + ) {} + + private emit(event: AgentStreamEvent): void { + const tagged = this.activeTurnId ? { ...event, turnId: this.activeTurnId } : event; + this.history.push(tagged); + for (const listener of this.listeners) listener(tagged); + } + + private timeline(item: AgentTimelineItem): void { + this.emit({ type: "timeline", provider: this.provider, item }); + } + + async run(): Promise { + return { sessionId: this.id, finalText: "", timeline: [] }; + } + + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { + const turnId = randomUUID(); + this.activeTurnId = turnId; + const text = promptText(prompt); + const script = this.scripts.find((candidate) => text.startsWith(candidate.prompt)); + setTimeout(() => void this.play(text, script), 50); + return { turnId }; + } + + private async play(text: string, script: AgentScript | undefined): Promise { + this.emit({ type: "turn_started", provider: this.provider }); + this.timeline({ type: "user_message", text, messageId: randomUUID() }); + const delay = script?.stepDelayMs ?? 400; + for (const step of script?.steps ?? [{ kind: "say", text: "Done." } as const]) { + await new Promise((resolve) => setTimeout(resolve, step.kind === "wait" ? step.ms : delay)); + this.playStep(step); + if (step.kind === "ask") return; + } + if (script?.keepRunning) return; + this.emit({ + type: "turn_completed", + provider: this.provider, + usage: { inputTokens: 48_210, outputTokens: 3_904 }, + }); + this.activeTurnId = null; + } + + private playStep(step: ScriptStep): void { + switch (step.kind) { + case "say": + this.timeline({ type: "assistant_message", text: step.text, messageId: randomUUID() }); + return; + case "think": + this.timeline({ type: "reasoning", text: step.text }); + return; + case "todo": + this.timeline({ type: "todo", items: step.items }); + return; + case "wait": + return; + case "tool": + case "running-tool": { + const callId = randomUUID(); + this.timeline({ + type: "tool_call", + callId, + name: step.name, + status: "running", + error: null, + detail: step.detail, + }); + if (step.kind === "tool") { + this.timeline({ + type: "tool_call", + callId, + name: step.name, + status: "completed", + error: null, + detail: step.detail, + }); + } + return; + } + case "ask": { + const request: AgentPermissionRequest = { + id: randomUUID(), + provider: this.provider, + name: step.name, + kind: "tool", + title: step.title, + detail: step.detail, + }; + this.pending = [request]; + this.emit({ type: "permission_requested", provider: this.provider, request }); + } + } + } + + subscribe(callback: (event: AgentStreamEvent) => void): () => void { + this.listeners.add(callback); + return () => this.listeners.delete(callback); + } + + async *streamHistory(): AsyncGenerator { + yield* this.history; + } + + async getRuntimeInfo() { + return { + provider: this.provider, + sessionId: this.id, + model: this.config.model ?? MODELS[this.provider]?.[0]?.id ?? null, + thinkingOptionId: this.config.thinkingOptionId ?? "medium", + modeId: this.config.modeId ?? null, + }; + } + + async getAvailableModes(): Promise { + return getAgentProviderDefinition(this.provider).modes; + } + + async getCurrentMode(): Promise { + return this.config.modeId ?? null; + } + + async setMode(): Promise {} + + getPendingPermissions(): AgentPermissionRequest[] { + return this.pending; + } + + async respondToPermission(requestId: string, resolution: AgentPermissionResponse) { + this.pending = []; + this.emit({ type: "permission_resolved", provider: this.provider, requestId, resolution }); + } + + describePersistence(): AgentPersistenceHandle { + return { provider: this.provider, sessionId: this.id }; + } + + async interrupt(): Promise { + if (!this.activeTurnId) return; + this.emit({ type: "turn_canceled", provider: this.provider, reason: "Interrupted" }); + this.activeTurnId = null; + } + + async close(): Promise {} +} + +export class ScriptedAgentClient implements AgentClient { + readonly capabilities = CAPABILITIES; + + constructor( + readonly provider: string, + private readonly scripts: AgentScript[], + ) {} + + async createSession(config: AgentSessionConfig): Promise { + return new ScriptedSession(this.provider, config, this.scripts); + } + + async resumeSession( + _handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + return new ScriptedSession( + this.provider, + { provider: this.provider, cwd: overrides?.cwd ?? process.cwd(), ...overrides }, + this.scripts, + ); + } + + async fetchCatalog() { + return { + models: MODELS[this.provider] ?? [], + modes: getAgentProviderDefinition(this.provider).modes, + }; + } + + async isAvailable(): Promise { + return true; + } +} diff --git a/assets/store-screenshots/demo/stage.mts b/assets/store-screenshots/demo/stage.mts new file mode 100644 index 0000000000..3c6a3cadf0 --- /dev/null +++ b/assets/store-screenshots/demo/stage.mts @@ -0,0 +1,556 @@ +// Stages the demo host used for store screenshots. +// +// npx tsx assets/store-screenshots/demo/stage.mts +// +// Starts an in-process daemon on DEMO_PORT (default 7791) with scripted Claude +// Code and Codex clients, adds a local clone of this repository as the `paseo` +// project, cuts real worktrees, applies a real change to the hero worktree, and +// opens a terminal there. Leave it running while you capture. +import { execFileSync, execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createTestPaseoDaemon } from "../../../packages/server/src/server/test-utils/paseo-daemon.js"; +import { DaemonClient } from "../../../packages/server/src/server/test-utils/daemon-client.js"; +import { type AgentScript, ScriptedAgentClient } from "./scripted-agents.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, "../../.."); +const demoRoot = path.join(repoRoot, ".dev/store-capture/demo"); +const codeRoot = path.join(demoRoot, "code"); +const pluginDir = path.join(here, "preview-deploys"); +const port = Number(process.env.DEMO_PORT ?? 7791); +const minute = 60_000; + +const claudeScripts: AgentScript[] = []; +const codexScripts: AgentScript[] = []; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", ["-c", "user.name=Mo", "-c", "user.email=mo@paseo.sh", ...args], { + cwd, + encoding: "utf8", + }); +} + +function run(cwd: string, command: string): string { + return execSync(command, { cwd, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0" } }); +} + +async function prepareRepos(): Promise { + // A fresh clone each run: worktrees from an earlier run stay registered in it. + // Worktree setup (npm ci, builds) is dropped so new worktrees open instantly. + const paseo = path.join(codeRoot, "paseo"); + await rm(paseo, { recursive: true, force: true }); + await mkdir(codeRoot, { recursive: true }); + execFileSync("git", ["clone", "-q", "--local", repoRoot, paseo]); + git(paseo, "checkout", "-q", "-B", "main"); + git(paseo, "remote", "remove", "origin"); + const config = JSON.parse(await readFile(path.join(paseo, "paseo.json"), "utf8")); + delete config.worktree; + await writeFile(path.join(paseo, "paseo.json"), `${JSON.stringify(config, null, 2)}\n`); + git(paseo, "commit", "-qam", "chore: skip worktree setup in demo"); + const dotfiles = path.join(codeRoot, "dotfiles"); + if (!existsSync(dotfiles)) { + await mkdir(dotfiles, { recursive: true }); + await writeFile(path.join(dotfiles, "tmux.conf"), "set -g mouse on\nset -g base-index 1\n"); + await writeFile(path.join(dotfiles, "zshrc"), "export EDITOR=nvim\nalias g=git\n"); + git(dotfiles, "init", "-q", "-b", "main"); + git(dotfiles, "add", "."); + git(dotfiles, "commit", "-qm", "Initial dotfiles"); + } +} + +async function main(): Promise { + await prepareRepos(); + const home = path.join(demoRoot, "home"); + await rm(home, { recursive: true, force: true }); + await mkdir(home, { recursive: true }); + + const daemon = await createTestPaseoDaemon({ + agentClients: { + claude: new ScriptedAgentClient("claude", claudeScripts), + codex: new ScriptedAgentClient("codex", codexScripts), + }, + listen: "127.0.0.1", + listenPort: port, + paseoHomeRoot: home, + mcpEnabled: false, + cleanup: false, + pluginsEnabled: true, + plugins: { + "preview-deploys": { source: "directory", path: pluginDir, enabled: true }, + }, + }); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.14.0", + }); + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + + const paseo = await client.addProject(path.join(codeRoot, "paseo")); + const dotfiles = await client.addProject(path.join(codeRoot, "dotfiles")); + const paseoProjectId = paseo.project!.projectId; + + async function worktree(title: string, branchName: string) { + const result = await client.createWorkspace({ + title, + source: { + kind: "worktree", + projectId: paseoProjectId, + action: "branch-off", + baseBranch: "main", + branchName, + }, + }); + if (!result.workspace) throw new Error(`No workspace for ${title}: ${JSON.stringify(result)}`); + return result.workspace; + } + + async function agent(input: { + provider: "claude" | "codex"; + workspace: { id: string; workspaceDirectory?: string | null }; + cwd: string; + title: string; + script: AgentScript; + }) { + (input.provider === "claude" ? claudeScripts : codexScripts).push(input.script); + return client.createAgent({ + provider: input.provider, + cwd: input.cwd, + workspaceId: input.workspace.id, + title: input.title, + initialPrompt: input.script.prompt, + }); + } + + const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + // Done: finished earlier and already read. + const release = await worktree("Release v0.14.0-beta.1", "release/0.14.0-beta.1"); + const releaseAgent = await agent({ + provider: "codex", + workspace: release, + cwd: release.workspaceDirectory!, + title: "Release v0.14.0-beta.1", + script: { + prompt: "Cut 0.14.0-beta.1 from main and draft the release notes.", + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: "npm run release:beta", + output: "Tagged v0.14.0-beta.1\n", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Tagged `v0.14.0-beta.1` and drafted the release notes. CI is building the desktop and mobile artifacts.", + }, + ], + }, + }); + const dotfilesWorkspace = ( + await client.createWorkspace({ + title: "Add a tmux status line", + source: { + kind: "directory", + path: path.join(codeRoot, "dotfiles"), + projectId: dotfiles.project!.projectId, + }, + }) + ).workspace!; + const tmuxAgent = await agent({ + provider: "claude", + workspace: dotfilesWorkspace, + cwd: path.join(codeRoot, "dotfiles"), + title: "Add a tmux status line", + script: { + prompt: "Add a minimal tmux status line with the session name and battery.", + steps: [ + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "tmux.conf", + oldString: "", + newString: "set -g status-right '#S · #{battery_percentage}'", + }, + }, + { + kind: "say", + text: "Added a status line with the session name on the left and battery on the right.", + }, + ], + }, + }); + await pause(3000); + await client.clearAgentAttention([releaseAgent.id, tmuxAgent.id]); + + // Working: turns that stay open while you capture. + const ipad = await worktree( + "Keep the composer above the iPad keyboard", + "fix/ipad-keyboard-inset", + ); + await agent({ + provider: "codex", + workspace: ipad, + cwd: ipad.workspaceDirectory!, + title: "Keep the composer above the iPad keyboard", + script: { + prompt: + "On iPad with a hardware keyboard attached, the composer jumps up as if the software keyboard were open. Find out why and fix it.", + keepRunning: true, + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: 'rg -n "keyboardHeight" packages/app/src', + output: + "packages/app/src/keyboard/shift/internal/provider.ts:41: const keyboardHeight = useSharedValue(0);\n", + exitCode: 0, + }, + }, + { + kind: "tool", + name: "read", + detail: { + type: "read", + filePath: "packages/app/src/keyboard/shift/internal/provider.ts", + }, + }, + { + kind: "say", + text: "The shift provider trusts the keyboard frame event. With a hardware keyboard, iPadOS still reports the shortcuts bar as a keyboard frame, so we lift the composer by its full height. Checking the frame against the screen bottom next.", + }, + { + kind: "running-tool", + name: "shell", + detail: { type: "shell", command: "npx vitest run packages/app/src/keyboard" }, + }, + ], + }, + }); + const snapshots = await worktree( + "Cache provider snapshots on disk", + "perf/provider-snapshot-cache", + ); + await agent({ + provider: "claude", + workspace: snapshots, + cwd: snapshots.workspaceDirectory!, + title: "Cache provider snapshots on disk", + script: { + prompt: + "Cold start spends about two seconds probing providers. Cache the last provider snapshot on disk and refresh it in the background.", + keepRunning: true, + steps: [ + { + kind: "todo", + items: [ + { text: "Find where snapshots are built", completed: true }, + { text: "Persist the last snapshot per host", completed: false }, + { text: "Refresh in the background after boot", completed: false }, + ], + }, + { + kind: "tool", + name: "Read", + detail: { + type: "read", + filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts", + }, + }, + { + kind: "running-tool", + name: "Edit", + detail: { + type: "edit", + filePath: "packages/server/src/server/agent/provider-snapshot-manager.ts", + }, + }, + ], + }, + }); + + // Needs input: waiting on a permission. + const korean = await worktree("Korean translations for Settings", "i18n/ko-settings"); + await agent({ + provider: "claude", + workspace: korean, + cwd: korean.workspaceDirectory!, + title: "Korean translations for Settings", + script: { + prompt: "Translate the new Settings strings into Korean and open a PR.", + steps: [ + { + kind: "tool", + name: "Read", + detail: { type: "read", filePath: "packages/app/src/i18n/resources/en.ts" }, + }, + { + kind: "tool", + name: "Edit", + detail: { type: "edit", filePath: "packages/app/src/i18n/resources/ko.ts" }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "npm run test --workspace=@getpaseo/app -- i18n", + output: "Test Files 3 passed (3)\n Tests 41 passed (41)\n", + exitCode: 0, + }, + }, + { kind: "say", text: "Translated 18 strings. I'll push the branch so I can open the PR." }, + { + kind: "ask", + name: "Bash", + title: "Bash", + detail: { type: "shell", command: "git push -u origin i18n/ko-settings" }, + }, + ], + }, + }); + + // Ready to review: a second finished agent. + const terminals = await worktree( + "Remember the last terminal per workspace", + "feat/remember-terminal", + ); + await agent({ + provider: "codex", + workspace: terminals, + cwd: terminals.workspaceDirectory!, + title: "Remember the last terminal per workspace", + script: { + prompt: + "When I switch back to a workspace, open the terminal I was using last instead of the first one.", + steps: [ + { + kind: "tool", + name: "shell", + detail: { + type: "shell", + command: 'rg -n "activeTerminalId" packages/app/src', + output: "", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Workspaces now remember their last focused terminal and reopen it when you switch back. The choice is stored per host, so it survives restarts.", + }, + ], + }, + }); + + // Plugin: the agent "built" the preview-deploys plugin this daemon runs. + const plugin = await worktree("Build a preview deploys plugin", "plugins/preview-deploys"); + const pluginSource = (file: string) => run(pluginDir, `cat ${file}`); + await agent({ + provider: "claude", + workspace: plugin, + cwd: plugin.workspaceDirectory!, + title: "Build a preview deploys plugin", + script: { + prompt: "Build me a plugin that shows our preview deploys right next to the composer.", + stepDelayMs: 6_000, + steps: [ + { kind: "tool", name: "Read", detail: { type: "read", filePath: "docs/plugins.md" } }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "plugins/preview-deploys/paseo-plugin.json", + content: pluginSource("paseo-plugin.json"), + }, + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "plugins/preview-deploys/index.client.tsx", + content: pluginSource("index.client.tsx"), + }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "paseo plugin install ./plugins/preview-deploys", + output: "Installed preview-deploys\n", + exitCode: 0, + }, + }, + { + kind: "say", + text: "Done. **Deploys** now sits above the composer in every workspace. Tap it to see production and the latest preview for each branch, with build progress and failures.", + }, + ], + }, + }); + + // Hero: Claude finished a real change in a real worktree. + const jitter = await worktree("Add jitter to reconnect backoff", "fix/reconnect-jitter"); + const cwd = jitter.workspaceDirectory!; + await symlink(path.join(repoRoot, "node_modules"), path.join(cwd, "node_modules")); + execFileSync("node", [path.join(here, "change/apply.mjs"), cwd]); + const clientDiff = git(cwd, "diff", "--", "packages/client/src/daemon-client.ts"); + const vitest = run( + cwd, + "npx vitest run packages/client/src/connection/reconnect-delay.test.ts", + ).replace(/^ RUN .*\n/m, ""); + const readExcerpt = run(cwd, "sed -n '7067,7086p' packages/client/src/daemon-client.ts"); + await agent({ + provider: "claude", + workspace: jitter, + cwd, + title: "Add jitter to reconnect backoff", + script: { + prompt: + 'When the relay restarts, every phone reconnects at the same moment and we get a spike. Add jitter to the reconnect backoff, keep the 30s cap, and make sure the "Retrying in" countdown still matches the real delay.', + stepDelayMs: 7_000, + steps: [ + { + kind: "tool", + name: "Grep", + detail: { + type: "search", + toolName: "grep", + query: "reconnectAttempt", + filePaths: ["packages/client/src/daemon-client.ts"], + numFiles: 1, + numMatches: 9, + mode: "files_with_matches", + }, + }, + { + kind: "tool", + name: "Read", + detail: { + type: "read", + filePath: "packages/client/src/daemon-client.ts", + offset: 7040, + limit: 60, + content: readExcerpt, + }, + }, + { + kind: "say", + text: "The delay is calculated twice: once for `nextRetryAt`, which drives the countdown, and again when the timer is armed. Both use plain doubling, so every client retries on the same schedule. If I add randomness in both places the countdown will be wrong, so I'll draw the jitter once per attempt and share it.", + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "packages/client/src/connection/reconnect-delay.ts", + content: run(cwd, "cat packages/client/src/connection/reconnect-delay.ts"), + }, + }, + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "packages/client/src/daemon-client.ts", + unifiedDiff: clientDiff, + }, + }, + { + kind: "tool", + name: "Write", + detail: { + type: "write", + filePath: "packages/client/src/connection/reconnect-delay.test.ts", + content: run(cwd, "cat packages/client/src/connection/reconnect-delay.test.ts"), + }, + }, + { + kind: "tool", + name: "Bash", + detail: { + type: "shell", + command: "npx vitest run packages/client/src/connection/reconnect-delay.test.ts", + output: vitest, + exitCode: 0, + }, + }, + { + kind: "tool", + name: "Edit", + detail: { + type: "edit", + filePath: "CHANGELOG.md", + unifiedDiff: git(cwd, "diff", "--", "CHANGELOG.md"), + }, + }, + { + kind: "say", + text: [ + "Reconnects now back off with jitter.", + "", + "- `getReconnectDelayMs` picks a delay between half and the full backoff, still capped at 30s.", + '- `DaemonClient` draws the delay once per attempt, so the "Retrying in" countdown matches the actual retry.', + "- Four new tests cover the doubling, the cap, the jitter range, and a quick first retry.", + "", + "Tests pass. After a relay restart, clients now spread their reconnects across the backoff window instead of arriving together.", + ].join("\n"), + }, + ], + }, + }); + + const terminal = await client.createTerminal(cwd, "zsh", undefined, { + workspaceId: jitter.id, + command: "bash", + args: ["--rcfile", path.join(here, "terminal-rc.sh"), "-i"], + }); + const terminalId = terminal.terminal?.id; + if (terminalId) { + await pause(1500); + for (const line of [ + "git status --short", + "cd packages/client", + "npx vitest run reconnect --reporter=tree", + ]) { + client.sendTerminalInput(terminalId, { type: "input", data: `${line}\r` }); + await pause(4000); + } + } + + console.log(`READY port=${daemon.port} home=${daemon.paseoHome}`); + await writeFile( + path.join(demoRoot, "state.json"), + JSON.stringify( + { port: daemon.port, home: daemon.paseoHome, hero: { workspaceId: jitter.id, cwd } }, + null, + 2, + ), + ); + const stop = async () => { + await client.close(); + await daemon.close(); + process.exit(0); + }; + process.on("SIGTERM", stop); + process.on("SIGINT", stop); + void minute; +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/assets/store-screenshots/demo/terminal-rc.sh b/assets/store-screenshots/demo/terminal-rc.sh new file mode 100644 index 0000000000..ab33d79466 --- /dev/null +++ b/assets/store-screenshots/demo/terminal-rc.sh @@ -0,0 +1,19 @@ +# Shell profile for the demo terminal: a Pure-style two-line prompt that fits a +# phone-width terminal, and vitest output without the absolute checkout path +# or the long timing breakdown. Vitest drops its colors when piped, so the +# wrapper puts its usual green checks and grey timings back. +export FORCE_COLOR=1 +__where() { local top; top=$(git rev-parse --show-toplevel 2>/dev/null); printf '~/paseo%s' "${PWD#"$top"}"; } +__branch() { git branch --show-current 2>/dev/null; } +__dirty() { [ -n "$(git status --porcelain 2>/dev/null)" ] && printf '*'; } +PROMPT_COMMAND='printf "\e]0;zsh\a"' +PS1='\n\[\e[34m\]$(__where)\[\e[0m\] \[\e[90m\]$(__branch)$(__dirty)\[\e[0m\]\n\[\e[35m\]❯\[\e[0m\] ' +npx() { + command npx "$@" 2>&1 | sed -E \ + -e '1{/^$/d}' -e '/ RUN /d' -e '/Start at/d' -e 's/ \(transform.*\)//' \ + -e $'s/✓/\e[32m✓\e[0m/' \ + -e $'s/ ([0-9]+ms)$/ \e[90m\\1\e[0m/' \ + -e $'s/ (\\([0-9]+( tests)?\\))/ \e[90m\\1\e[0m/' \ + -e $'s/([0-9]+ passed)/\e[1;32m\\1\e[0m/' +} +clear diff --git a/assets/store-screenshots/output/apple-ipad/01-sidebar.png b/assets/store-screenshots/output/apple-ipad/01-sidebar.png new file mode 100644 index 0000000000..03f1d8ed61 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/01-sidebar.png differ diff --git a/assets/store-screenshots/output/apple-ipad/02-chat.png b/assets/store-screenshots/output/apple-ipad/02-chat.png new file mode 100644 index 0000000000..1c56073112 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/02-chat.png differ diff --git a/assets/store-screenshots/output/apple-ipad/03-plugins.png b/assets/store-screenshots/output/apple-ipad/03-plugins.png new file mode 100644 index 0000000000..87063634d3 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/03-plugins.png differ diff --git a/assets/store-screenshots/output/apple-ipad/04-review.png b/assets/store-screenshots/output/apple-ipad/04-review.png new file mode 100644 index 0000000000..80e8cf8a52 Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/04-review.png differ diff --git a/assets/store-screenshots/output/apple-ipad/05-terminal.png b/assets/store-screenshots/output/apple-ipad/05-terminal.png new file mode 100644 index 0000000000..01b8d93c2a Binary files /dev/null and b/assets/store-screenshots/output/apple-ipad/05-terminal.png differ diff --git a/assets/store-screenshots/output/apple/01-sidebar.png b/assets/store-screenshots/output/apple/01-sidebar.png new file mode 100644 index 0000000000..f2a7ab849c Binary files /dev/null and b/assets/store-screenshots/output/apple/01-sidebar.png differ diff --git a/assets/store-screenshots/output/apple/02-chat.png b/assets/store-screenshots/output/apple/02-chat.png new file mode 100644 index 0000000000..b9afa6607d Binary files /dev/null and b/assets/store-screenshots/output/apple/02-chat.png differ diff --git a/assets/store-screenshots/output/apple/03-plugins.png b/assets/store-screenshots/output/apple/03-plugins.png new file mode 100644 index 0000000000..6f74f4ce53 Binary files /dev/null and b/assets/store-screenshots/output/apple/03-plugins.png differ diff --git a/assets/store-screenshots/output/apple/04-review.png b/assets/store-screenshots/output/apple/04-review.png new file mode 100644 index 0000000000..9b34d6cce0 Binary files /dev/null and b/assets/store-screenshots/output/apple/04-review.png differ diff --git a/assets/store-screenshots/output/apple/05-terminal.png b/assets/store-screenshots/output/apple/05-terminal.png new file mode 100644 index 0000000000..fae3170671 Binary files /dev/null and b/assets/store-screenshots/output/apple/05-terminal.png differ diff --git a/assets/store-screenshots/output/google-tablet/01-sidebar.png b/assets/store-screenshots/output/google-tablet/01-sidebar.png new file mode 100644 index 0000000000..8560682610 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/01-sidebar.png differ diff --git a/assets/store-screenshots/output/google-tablet/02-chat.png b/assets/store-screenshots/output/google-tablet/02-chat.png new file mode 100644 index 0000000000..a8f2342ab3 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/02-chat.png differ diff --git a/assets/store-screenshots/output/google-tablet/03-plugins.png b/assets/store-screenshots/output/google-tablet/03-plugins.png new file mode 100644 index 0000000000..3fe70263ca Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/03-plugins.png differ diff --git a/assets/store-screenshots/output/google-tablet/04-review.png b/assets/store-screenshots/output/google-tablet/04-review.png new file mode 100644 index 0000000000..ae54bfa798 Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/04-review.png differ diff --git a/assets/store-screenshots/output/google-tablet/05-terminal.png b/assets/store-screenshots/output/google-tablet/05-terminal.png new file mode 100644 index 0000000000..9921c839dd Binary files /dev/null and b/assets/store-screenshots/output/google-tablet/05-terminal.png differ diff --git a/assets/store-screenshots/output/google/01-sidebar.png b/assets/store-screenshots/output/google/01-sidebar.png new file mode 100644 index 0000000000..abed766b66 Binary files /dev/null and b/assets/store-screenshots/output/google/01-sidebar.png differ diff --git a/assets/store-screenshots/output/google/02-chat.png b/assets/store-screenshots/output/google/02-chat.png new file mode 100644 index 0000000000..bd42e5df49 Binary files /dev/null and b/assets/store-screenshots/output/google/02-chat.png differ diff --git a/assets/store-screenshots/output/google/03-plugins.png b/assets/store-screenshots/output/google/03-plugins.png new file mode 100644 index 0000000000..e26ecb407e Binary files /dev/null and b/assets/store-screenshots/output/google/03-plugins.png differ diff --git a/assets/store-screenshots/output/google/04-review.png b/assets/store-screenshots/output/google/04-review.png new file mode 100644 index 0000000000..38e9287c6a Binary files /dev/null and b/assets/store-screenshots/output/google/04-review.png differ diff --git a/assets/store-screenshots/output/google/05-terminal.png b/assets/store-screenshots/output/google/05-terminal.png new file mode 100644 index 0000000000..1445744fcb Binary files /dev/null and b/assets/store-screenshots/output/google/05-terminal.png differ diff --git a/assets/store-screenshots/raw/ipad/chat.png b/assets/store-screenshots/raw/ipad/chat.png new file mode 100644 index 0000000000..f8abc88b6c Binary files /dev/null and b/assets/store-screenshots/raw/ipad/chat.png differ diff --git a/assets/store-screenshots/raw/ipad/diff.png b/assets/store-screenshots/raw/ipad/diff.png new file mode 100644 index 0000000000..030946b7ae Binary files /dev/null and b/assets/store-screenshots/raw/ipad/diff.png differ diff --git a/assets/store-screenshots/raw/ipad/plugin-sheet.png b/assets/store-screenshots/raw/ipad/plugin-sheet.png new file mode 100644 index 0000000000..2550f0eecd Binary files /dev/null and b/assets/store-screenshots/raw/ipad/plugin-sheet.png differ diff --git a/assets/store-screenshots/raw/ipad/plugin.png b/assets/store-screenshots/raw/ipad/plugin.png new file mode 100644 index 0000000000..1fd3fbe2d8 Binary files /dev/null and b/assets/store-screenshots/raw/ipad/plugin.png differ diff --git a/assets/store-screenshots/raw/ipad/sidebar.png b/assets/store-screenshots/raw/ipad/sidebar.png new file mode 100644 index 0000000000..f348372425 Binary files /dev/null and b/assets/store-screenshots/raw/ipad/sidebar.png differ diff --git a/assets/store-screenshots/raw/ipad/terminal.png b/assets/store-screenshots/raw/ipad/terminal.png new file mode 100644 index 0000000000..3c65d7aabf Binary files /dev/null and b/assets/store-screenshots/raw/ipad/terminal.png differ diff --git a/assets/store-screenshots/raw/iphone/chat.png b/assets/store-screenshots/raw/iphone/chat.png new file mode 100644 index 0000000000..5ba23b3282 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/chat.png differ diff --git a/assets/store-screenshots/raw/iphone/diff.png b/assets/store-screenshots/raw/iphone/diff.png new file mode 100644 index 0000000000..eab57a68d5 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/diff.png differ diff --git a/assets/store-screenshots/raw/iphone/files.png b/assets/store-screenshots/raw/iphone/files.png new file mode 100644 index 0000000000..d2ebaf96b2 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/files.png differ diff --git a/assets/store-screenshots/raw/iphone/plugin-sheet.png b/assets/store-screenshots/raw/iphone/plugin-sheet.png new file mode 100644 index 0000000000..012e3877ea Binary files /dev/null and b/assets/store-screenshots/raw/iphone/plugin-sheet.png differ diff --git a/assets/store-screenshots/raw/iphone/plugin.png b/assets/store-screenshots/raw/iphone/plugin.png new file mode 100644 index 0000000000..95beb12d67 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/plugin.png differ diff --git a/assets/store-screenshots/raw/iphone/sidebar.png b/assets/store-screenshots/raw/iphone/sidebar.png new file mode 100644 index 0000000000..d0601f3f2f Binary files /dev/null and b/assets/store-screenshots/raw/iphone/sidebar.png differ diff --git a/assets/store-screenshots/raw/iphone/terminal.png b/assets/store-screenshots/raw/iphone/terminal.png new file mode 100644 index 0000000000..b96b192e17 Binary files /dev/null and b/assets/store-screenshots/raw/iphone/terminal.png differ diff --git a/assets/store-screenshots/render.mjs b/assets/store-screenshots/render.mjs new file mode 100644 index 0000000000..9e32384a60 --- /dev/null +++ b/assets/store-screenshots/render.mjs @@ -0,0 +1,396 @@ +// Renders the App Store and Google Play screenshots from the native captures in +// raw/. Every image is resized with Lanczos to the exact pixel size it occupies +// on the canvas, and the page renders at device scale 1, so the browser never +// resamples a capture. +// +// node assets/store-screenshots/render.mjs +// +// SF Pro is not redistributable. On macOS it comes from /System/Library/Fonts; +// elsewhere point SF_FONT_DIR at a folder holding SFNS.ttf copied from a Mac. + +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { build } from "esbuild"; +import { chromium } from "playwright"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import sharp from "sharp"; + +const root = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(root, "../.."); +const raw = (device, name) => path.join(root, "raw", device, `${name}.png`); +const output = path.join(root, "output"); + +// Native captures per device: iPhone 16 Pro Max at 3x and iPad Pro 13-inch at +// 2x. The frame's screen radius and Dynamic Island are in capture pixels. +const DEVICES = { + iphone: { + capture: { width: 1320, height: 2868 }, + radius: 186, + bezel: 22, + island: { width: 378, height: 112, top: 33 }, + }, + ipad: { capture: { width: 2064, height: 2752 }, radius: 44, bezel: 30, island: null }, +}; + +// Every title wraps to two lines and every subline fits on one; the renderer +// fails otherwise, because the device is placed from that fixed text height. +// +// Each scene names its capture per device, and a device entry may override the +// title or subline where the copy depends on the form factor. +// +// A callout lifts a region of a capture out of the device. `crop` is in native +// capture pixels, `zoom` is its size relative to the device screen (capped to +// the canvas width), and the callout is centred on the spot the crop comes +// from, moved by `shift` native pixels down the screen. +const SCENES = [ + { + id: "sidebar", + backdrop: "agents", + title: "Run many coding agents at once", + body: "See which agents are working and which need you.", + iphone: { capture: "sidebar" }, + ipad: { capture: "sidebar" }, + }, + { + id: "chat", + title: "Your full dev environment on your phone", + body: "Start agents and follow every edit and command.", + iphone: { capture: "chat" }, + ipad: { capture: "chat", title: "Your full dev environment on your tablet" }, + }, + { + id: "plugins", + title: "Ask an agent to change the app itself", + body: "This Deploys sheet is a plugin an agent wrote.", + iphone: { + capture: "plugin", + callout: { from: "plugin-sheet", crop: [0, 1340, 1320, 1400], zoom: 1.04, shift: -1400 }, + }, + ipad: { + capture: "plugin", + body: "This Deploys panel is a plugin an agent wrote.", + callout: { from: "plugin-sheet", crop: [670, 1486, 844, 970], zoom: 1.3, shift: -900 }, + }, + }, + { + id: "review", + title: "Review the diff before you merge", + body: "Every file the agent changed, on its own branch.", + iphone: { + capture: "diff", + callout: { from: "files", crop: [0, 1060, 1320, 870], zoom: 1.26, shift: 110 }, + }, + ipad: { capture: "diff" }, + }, + { + id: "terminal", + title: "A real terminal in every workspace", + body: "Run the tests yourself, on your own machine.", + iphone: { + capture: "terminal", + callout: { from: "terminal", crop: [0, 950, 1320, 460], zoom: 1.35, shift: 110 }, + }, + ipad: { + capture: "terminal", + callout: { from: "terminal", crop: [596, 640, 1010, 412], zoom: 1.35, shift: 650 }, + }, + }, +]; + +// `textTop` is where the text starts when the device is sized by the canvas +// height. A canvas too narrow for that, like a tablet on 9:16, sizes the device +// by its width and centres the text and device instead. +const STORES = { + apple: { + device: "iphone", + width: 1320, + height: 2868, + textTop: 230, + titleSize: 100, + bodySize: 46, + textWidth: 1120, + }, + google: { + device: "iphone", + width: 1440, + height: 2560, + textTop: 150, + titleSize: 96, + bodySize: 44, + textWidth: 1200, + }, + "apple-ipad": { + device: "ipad", + width: 2064, + height: 2752, + textTop: 200, + titleSize: 124, + bodySize: 56, + textWidth: 1400, + }, + "google-tablet": { + device: "ipad", + width: 1800, + height: 3200, + textTop: 200, + titleSize: 136, + bodySize: 60, + textWidth: 1560, + }, +}; + +const TITLE_LINE = 1.06; +const BODY_LINE = 1.3; +const BODY_GAP = 30; +// The device starts this far below the text and runs BLEED off the bottom. +const GAP = 140; +const BLEED = 40; +const MARGIN = 56; +const COLORS = { canvas: "#101615", title: "#ffffff", body: "rgba(255, 255, 255, 0.58)" }; + +function findFont() { + const dirs = [process.env.SF_FONT_DIR, "/System/Library/Fonts", "/Library/Fonts"].filter(Boolean); + for (const dir of dirs) { + const file = path.join(dir, "SFNS.ttf"); + if (existsSync(file)) return file; + } + throw new Error("SF Pro not found. Set SF_FONT_DIR to a folder containing SFNS.ttf from macOS."); +} + +// The device fills the canvas from GAP below the text to just past the bottom +// edge, so a taller canvas gets a larger device rather than a larger gap. When +// that would be wider than the canvas, the device takes the full width and the +// text and the whole device sit centred, still GAP apart. +function deviceLayout(store, device) { + const { capture, bezel } = device; + const textHeight = 2 * store.titleSize * TITLE_LINE + BODY_GAP + store.bodySize * BODY_LINE; + const tallest = store.height + BLEED - (store.textTop + textHeight + GAP) - 2 * bezel; + const widest = store.width - 2 * MARGIN - 2 * bezel; + const screenWidth = Math.min(Math.round((tallest * capture.width) / capture.height), widest); + const screenHeight = Math.round((screenWidth * capture.height) / capture.width); + const scale = screenWidth / capture.width; + const width = screenWidth + 2 * bezel; + const height = screenHeight + 2 * bezel; + const textTop = + screenWidth < widest + ? store.height + BLEED - height - GAP - textHeight + : (store.height - height - GAP - textHeight) / 2; + const island = device.island && { + width: Math.round(device.island.width * scale), + height: Math.round(device.island.height * scale), + top: Math.round(device.island.top * scale), + }; + return { + textTop: Math.round(textTop), + left: Math.round((store.width - width) / 2), + top: Math.round(textTop + textHeight + GAP), + width, + height, + bezel, + screenWidth, + screenHeight, + scale, + radius: Math.round(device.radius * scale), + island, + }; +} + +function calloutLayout(callout, frame, store) { + const [cropX, cropY, cropWidth, cropHeight] = callout.crop; + const scale = Math.min(frame.scale * callout.zoom, (store.width - 2 * MARGIN) / cropWidth); + const width = Math.round(cropWidth * scale); + const height = Math.round(cropHeight * scale); + const centreX = frame.left + frame.bezel + (cropX + cropWidth / 2) * frame.scale; + const centreY = frame.top + frame.bezel + (cropY + cropHeight / 2 + callout.shift) * frame.scale; + const clamp = (value, max) => Math.round(Math.min(Math.max(value, MARGIN), max - MARGIN)); + return { + width, + height, + left: clamp(centreX - width / 2, store.width - width), + top: clamp(centreY - height / 2, store.height - height), + }; +} + +async function resize(source, crop, width, height, file) { + let image = sharp(source); + if (crop) image = image.extract({ left: crop[0], top: crop[1], width: crop[2], height: crop[3] }); + await image.resize(width, height, { kernel: "lanczos3" }).png().toFile(file); + return pathToFileURL(file).href; +} + +// Agent marks scattered behind the first panel, from the homepage's featured +// agents. Each entry is [mark, centre x, centre y, size, rotation], with the +// centre as a fraction of the canvas and the size as a fraction of its width. +const AGENT_MARKS = [ + ["ClaudeCodeIcon", 0.11, 0.075, 0.12, -14], + ["CodexIcon", 0.89, 0.1, 0.13, 12], + ["PiIcon", 0.07, 0.34, 0.1, 10], + ["OmpIcon", 0.93, 0.37, 0.11, -10], + ["OpenCodeIcon", 0.08, 0.6, 0.11, -8], + ["CursorIcon", 0.92, 0.63, 0.1, 16], + ["MuseCodeIcon", 0.07, 0.85, 0.11, 14], + ["AntigravityIcon", 0.92, 0.88, 0.12, -12], +]; + +async function loadAgentMarks(work) { + const bundle = path.join(work, "agent-icons.mjs"); + await build({ + entryPoints: [path.join(repo, "packages/website/src/components/agent-icons.tsx")], + outfile: bundle, + bundle: true, + format: "esm", + platform: "node", + jsx: "automatic", + }); + const icons = await import(pathToFileURL(bundle).href); + return Object.fromEntries( + AGENT_MARKS.map(([name]) => [name, renderToStaticMarkup(createElement(icons[name]))]), + ); +} + +function backdrop(store, marks) { + return AGENT_MARKS.map(([name, x, y, size, rotation]) => { + const side = Math.round(size * store.width); + const left = Math.round(x * store.width - side / 2); + const top = Math.round(y * store.height - side / 2); + return `
${marks[name]}
`; + }).join(""); +} + +function page({ store, shot, frame, screen, callout, calloutImage, font, marks }) { + const behind = shot.backdrop === "agents" ? backdrop(store, marks) : ""; + const { island } = frame; + const notch = island + ? `
` + : ""; + const lifted = callout + ? `` + : ""; + return ` + + +${behind} +

${shot.title}

${shot.body}

+
${notch}
+${lifted}`; +} + +async function renderStore(browser, name, store, font, marks, work) { + const dir = path.join(output, name); + await mkdir(dir, { recursive: true }); + const view = await browser.newPage({ + viewport: { width: store.width, height: store.height }, + deviceScaleFactor: 1, + }); + const device = DEVICES[store.device]; + const frame = deviceLayout(store, device); + const files = []; + for (const [index, scene] of SCENES.entries()) { + const stem = `${name}-${scene.id}`; + const shot = { ...scene, ...scene[store.device] }; + const screen = await resize( + raw(store.device, shot.capture), + null, + frame.screenWidth, + frame.screenHeight, + path.join(work, `${stem}-screen.png`), + ); + const callout = shot.callout ? calloutLayout(shot.callout, frame, store) : null; + const calloutImage = callout + ? await resize( + raw(store.device, shot.callout.from), + shot.callout.crop, + callout.width, + callout.height, + path.join(work, `${stem}-callout.png`), + ) + : null; + const html = path.join(work, `${stem}.html`); + await writeFile(html, page({ store, shot, frame, screen, callout, calloutImage, font, marks })); + await view.goto(pathToFileURL(html).href); + await view.evaluate(async () => { + await document.fonts.ready; + await Promise.all(Array.from(document.images, (image) => image.decode())); + }); + await expectLines(view, "h1", 2, `${stem} title`); + await expectLines(view, "p", 1, `${stem} subline`); + const file = path.join(dir, `${String(index + 1).padStart(2, "0")}-${scene.id}.png`); + await sharp(await view.screenshot()) + .removeAlpha() + .png() + .toFile(file); + files.push(file); + console.log(path.relative(repo, file)); + } + await view.close(); + return files; +} + +async function expectLines(view, selector, expected, label) { + const lines = await view.$eval(selector, (element) => + Math.round(element.clientHeight / parseFloat(getComputedStyle(element).lineHeight)), + ); + if (lines !== expected) throw new Error(`${label} wraps to ${lines} lines, expected ${expected}`); +} + +async function renderPreview(rows) { + const tile = 440; + const gap = 40; + const resized = []; + let top = gap; + let width = 0; + for (const files of rows) { + let left = gap; + let rowHeight = 0; + for (const file of files) { + const meta = await sharp(file).metadata(); + const height = Math.round((meta.height * tile) / meta.width); + const input = await sharp(file).resize(tile, height, { kernel: "lanczos3" }).toBuffer(); + resized.push({ input, left, top }); + left += tile + gap; + rowHeight = Math.max(rowHeight, height); + } + width = Math.max(width, left); + top += rowHeight + gap; + } + const file = path.join(output, "preview.png"); + await sharp({ create: { width, height: top, channels: 3, background: "#070a09" } }) + .composite(resized) + .png() + .toFile(file); + console.log(path.relative(repo, file)); +} + +const font = findFont(); +const work = await mkdtemp(path.join(os.tmpdir(), "paseo-store-")); +const browser = await chromium.launch({ headless: true }); +try { + const marks = await loadAgentMarks(work); + const rows = []; + for (const [name, store] of Object.entries(STORES)) { + rows.push(await renderStore(browser, name, store, font, marks, work)); + } + await renderPreview(rows); +} finally { + await browser.close(); + await rm(work, { recursive: true, force: true }); +}