mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 12:54:58 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781cb8d5b0 | ||
|
|
116b0a9190 | ||
|
|
5a7a60ae50 | ||
|
|
dd06bc0833 | ||
|
|
2eba291a8e | ||
|
|
96a143efe9 | ||
|
|
fe59a6c1a2 |
+4
-3
@@ -873,9 +873,10 @@ await paseo.agents.ref(agentId).timeline.append({
|
||||
The daemon stamps the runtime `pluginId`; plugin code never supplies it. Reusing the same `id`
|
||||
replaces the previous row from that plugin on live clients and fresh timeline fetches. Rows live in
|
||||
the daemon's in-memory timeline and survive scroll, refetch, and reconnect, but not a daemon
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. Serialized `data`
|
||||
must be at most 64 KiB; the daemon rejects a larger append instead of storing a payload that cannot
|
||||
be rendered intact. The daemon advertises this RPC through
|
||||
restart. A row without an installed renderer shows an unavailable placeholder. `kind` must match
|
||||
`/^[a-z][a-z0-9-]*$/`, the rule `addTimelineRenderer` enforces; the daemon rejects any other kind.
|
||||
Serialized `data` must be at most 64 KiB; the daemon rejects a larger append instead of storing a
|
||||
payload that cannot be rendered intact. The daemon advertises this RPC through
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Contribute client slash commands
|
||||
|
||||
@@ -20,6 +20,21 @@ function explorerSidebar(page: Parameters<typeof ensureExplorerSidebar>[0]) {
|
||||
return page.getByTestId("workspace-explorer-sidebar").filter({ visible: true });
|
||||
}
|
||||
|
||||
const LAYOUT_VIEWPORTS = {
|
||||
compact: { width: 390, height: 844 },
|
||||
wide: { width: 1280, height: 900 },
|
||||
};
|
||||
|
||||
// The previous layout stays on screen for a few renders after the resize. Wait for the shell to
|
||||
// switch so the next step reads the layout it resized to.
|
||||
async function resizeToLayout(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
layout: keyof typeof LAYOUT_VIEWPORTS,
|
||||
) {
|
||||
await page.setViewportSize(LAYOUT_VIEWPORTS[layout]);
|
||||
await expect(page.getByTestId(`${layout}-shell`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectExplorerActiveTabForeground(
|
||||
page: Parameters<typeof ensureExplorerSidebar>[0],
|
||||
) {
|
||||
@@ -244,7 +259,7 @@ test("tablet prototype keeps Explorer tabs when resizing down to a phone", async
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-951.png") });
|
||||
await openExplorerAgentDraft(page);
|
||||
await page.screenshot({ path: testInfo.outputPath("tablet-agent-draft.png") });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await revealPhoneExplorer(page);
|
||||
await expect(
|
||||
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
|
||||
@@ -289,10 +304,10 @@ test("retains the Explorer host through appearance changes and folding", async (
|
||||
.poll(() => explorer.evaluate((element) => getComputedStyle(element).backgroundColor))
|
||||
.not.toBe(previousColor);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
await expect(explorer).toBeVisible();
|
||||
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
|
||||
} finally {
|
||||
@@ -327,7 +342,7 @@ test("reorders compact Explorer tabs with a pointer without changing selection",
|
||||
const workspace = await seedWorkspace({ repoPrefix: "explorer-compact-drag-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await openFilesPanel(page);
|
||||
await reorderCompactExplorerTabs(page);
|
||||
await expect(
|
||||
@@ -394,9 +409,9 @@ test("updates populated code and diff appearance without losing the editor insta
|
||||
.getByTestId("file-source-editor")
|
||||
.elementHandle();
|
||||
await text.fill(`${source}\n// retained Explorer edit`);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
await expect(text).toContainText("retained Explorer edit");
|
||||
} finally {
|
||||
@@ -461,9 +476,9 @@ test("retains populated Explorer chat and terminal attachments through appearanc
|
||||
const terminalNode = await terminal.elementHandle();
|
||||
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
await ensureExplorerSidebar(page);
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await resizeToLayout(page, "wide");
|
||||
await cycleWorkspaceTheme(page);
|
||||
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
|
||||
expect(attaches).toHaveLength(1);
|
||||
@@ -483,7 +498,7 @@ test("compact file sheets cover Explorer and return to its retained file list",
|
||||
try {
|
||||
await writeFile(`${workspace.repoPath}/sheet-marker.txt`, "Explorer sheet marker\n");
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await resizeToLayout(page, "compact");
|
||||
const explorer = await ensureExplorerSidebar(page);
|
||||
await openFilesPanel(page);
|
||||
const host = await explorer.elementHandle();
|
||||
|
||||
@@ -91,10 +91,13 @@ async function selectWorkspaceTab(tab: Locator): Promise<void> {
|
||||
export async function ensureExplorerSidebar(page: Page): Promise<Locator> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30_000 });
|
||||
const explorer = explorerSidebar(page);
|
||||
if ((await explorer.count()) === 0) {
|
||||
// Decide from the toggle, which reports the open state. The sidebar's own visibility lags it:
|
||||
// a compact layout draws its closed Explorer for a frame before hiding it.
|
||||
if ((await toggle.getAttribute("aria-expanded")) !== "true") {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
const explorer = explorerSidebar(page);
|
||||
await expect(explorer).toBeVisible({ timeout: 30_000 });
|
||||
return explorer;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/plugin-config";
|
||||
|
||||
export const PLUGIN_TIMELINE_DATA_MAX_BYTES = 64 * 1024;
|
||||
|
||||
@@ -14,6 +15,15 @@ const MARKER_COST = 32;
|
||||
const SHORT_STRING = 1024;
|
||||
const MAX_DEPTH = 64;
|
||||
|
||||
// The app registers renderer kinds under the plugin ID rule, so stored kinds must use that format.
|
||||
export function assertPluginTimelineKind(kind: string): void {
|
||||
if (!PluginIdSchema.safeParse(kind).success) {
|
||||
throw new Error(
|
||||
`Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertPluginTimelineDataSize(data: JsonValue): void {
|
||||
const serializedBytes = Buffer.byteLength(JSON.stringify(data), "utf8");
|
||||
if (serializedBytes > PLUGIN_TIMELINE_DATA_MAX_BYTES) {
|
||||
|
||||
+27
-2
@@ -1,5 +1,12 @@
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
HookCallback,
|
||||
Query,
|
||||
SDKMessage,
|
||||
SDKUserMessage,
|
||||
SessionCronSummary,
|
||||
StopHookInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -884,6 +891,7 @@ test("Claude session runtime safety observes pumped background work and autonomo
|
||||
wake?.();
|
||||
};
|
||||
let nativeInput!: AsyncIterator<SDKUserMessage>;
|
||||
let stopHook: HookCallback | undefined;
|
||||
let connected!: () => void;
|
||||
const connection = new Promise<void>((resolve) => {
|
||||
connected = resolve;
|
||||
@@ -908,9 +916,10 @@ test("Claude session runtime safety observes pumped background work and autonomo
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
queryFactory: ({ prompt }) => {
|
||||
queryFactory: ({ prompt, options }) => {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
nativeInput = prompt[Symbol.asyncIterator]();
|
||||
stopHook = options?.hooks?.Stop?.[0]?.hooks[0];
|
||||
connected();
|
||||
return query;
|
||||
},
|
||||
@@ -957,6 +966,22 @@ test("Claude session runtime safety observes pumped background work and autonomo
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
// A session-only cron lives in this process, and Claude reports it only to the Stop hook.
|
||||
const stop = (session_crons: SessionCronSummary[]) => {
|
||||
const stopInput: StopHookInput = {
|
||||
hook_event_name: "Stop",
|
||||
session_id: "runtime-safety",
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
stop_hook_active: false,
|
||||
session_crons,
|
||||
};
|
||||
return stopHook!(stopInput, undefined, { signal: new AbortController().signal });
|
||||
};
|
||||
await stop([{ id: "99eb0b69", schedule: "0 */6 * * *", recurring: true, prompt: "say hi" }]);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await stop([]);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.j
|
||||
import {
|
||||
type AgentDefinition,
|
||||
type CanUseTool,
|
||||
type HookCallback,
|
||||
type McpServerConfig as ClaudeSdkMcpServerConfig,
|
||||
type PermissionMode,
|
||||
type PermissionResult,
|
||||
@@ -3804,7 +3805,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
// subagents keep running. Without this declaration an interrupt kills every one of them.
|
||||
// Claude stops a helper itself with its TaskStop tool.
|
||||
perTaskStopAffordance: true,
|
||||
hooks: this.buildSubagentEffortHooks(),
|
||||
hooks: { ...this.buildSubagentEffortHooks(), Stop: [{ hooks: [this.observeStopHook] }] },
|
||||
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
|
||||
env: sdkEnv,
|
||||
};
|
||||
@@ -5480,6 +5481,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
// Observation-only, like the effort hooks: it never alters turn control.
|
||||
private readonly observeStopHook: HookCallback = async (input) => {
|
||||
if (input.hook_event_name === "Stop") this.runtimeSafety.observeStop(input.session_crons);
|
||||
return {};
|
||||
};
|
||||
|
||||
private notifySubscribers(event: AgentStreamEvent): void {
|
||||
const turnId =
|
||||
getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id;
|
||||
|
||||
@@ -46,3 +46,47 @@ test("enriches only live tasks with their correlated launch metadata", () => {
|
||||
runtime.observe(message({ type: "system", subtype: "background_tasks_changed", tasks: [] })),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("keeps the runtime while Claude's last Stop hook lists session crons", () => {
|
||||
const runtime = new ClaudeRuntimeSafety();
|
||||
runtime.observe(message({ type: "system", subtype: "init", claude_code_version: "2.1.321" }));
|
||||
const cron = { id: "99eb0b69", schedule: "0 */6 * * *", recurring: true, prompt: "say hi" };
|
||||
|
||||
runtime.observeStop([cron]);
|
||||
expect(runtime.canStopRuntime()).toBe(false);
|
||||
|
||||
runtime.observeStop(undefined);
|
||||
expect(runtime.canStopRuntime()).toBe(false);
|
||||
|
||||
runtime.observeStop([]);
|
||||
expect(runtime.canStopRuntime()).toBe(true);
|
||||
|
||||
runtime.observeStop([cron]);
|
||||
runtime.reset();
|
||||
runtime.observe(message({ type: "system", subtype: "init", claude_code_version: "2.1.321" }));
|
||||
expect(runtime.canStopRuntime()).toBe(true);
|
||||
});
|
||||
|
||||
test("holds the runtime when a turn that called a cron tool ends without a Stop hook", () => {
|
||||
const runtime = new ClaudeRuntimeSafety();
|
||||
runtime.observe(message({ type: "system", subtype: "init", claude_code_version: "2.1.321" }));
|
||||
const callTool = (name: string) =>
|
||||
runtime.observe(
|
||||
message({
|
||||
type: "assistant",
|
||||
message: { content: [{ type: "tool_use", id: `${name}-1`, name, input: {} }] },
|
||||
}),
|
||||
);
|
||||
|
||||
// The turn schedules a cron and is interrupted, so no Stop hook reports it.
|
||||
callTool("CronCreate");
|
||||
expect(runtime.canStopRuntime()).toBe(false);
|
||||
|
||||
runtime.observeStop([{ id: "99eb0b69", schedule: "0 */6 * * *", recurring: true, prompt: "hi" }]);
|
||||
callTool("CronDelete");
|
||||
runtime.observeStop([]);
|
||||
expect(runtime.canStopRuntime()).toBe(true);
|
||||
|
||||
callTool("ScheduleWakeup");
|
||||
expect(runtime.canStopRuntime()).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SDKMessage, SessionCronSummary } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AgentBackgroundWorkInput } from "../../background-work/index.js";
|
||||
import { parseClaudeCodeVersion } from "./model-manifest.js";
|
||||
import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background-work.js";
|
||||
|
||||
const SESSION_CRON_TOOLS = new Set(["CronCreate", "CronDelete", "ScheduleWakeup"]);
|
||||
|
||||
/** Process-local background ownership. Completion delivery can follow an empty roster. */
|
||||
export class ClaudeRuntimeSafety {
|
||||
private inventoryState: "unknown" | "supported" | "retired" = "unknown";
|
||||
@@ -16,8 +18,15 @@ export class ClaudeRuntimeSafety {
|
||||
private readonly monitorTools = new Set<string>();
|
||||
private readonly toolInputs = new Map<string, { description?: string; command?: string }>();
|
||||
private readonly taskTools = new Map<string, string>();
|
||||
// Session-only crons and wakeups live in the process and die with it. Only the Stop hook
|
||||
// lists them, and Claude runs it only for turns that complete. A cron tool call makes the
|
||||
// count unknown until the next Stop, so an interrupted or failed turn keeps the runtime.
|
||||
private sessionCrons = 0;
|
||||
private sessionCronsUnknown = false;
|
||||
|
||||
reset(): void {
|
||||
this.sessionCrons = 0;
|
||||
this.sessionCronsUnknown = false;
|
||||
this.inventoryState = "unknown";
|
||||
this.turnSeen = false;
|
||||
this.liveTasks = [];
|
||||
@@ -38,6 +47,13 @@ export class ClaudeRuntimeSafety {
|
||||
this.turnSeen = true;
|
||||
}
|
||||
|
||||
/** Called with the session crons of each main-thread Stop hook. Older CLIs omit them. */
|
||||
observeStop(sessionCrons: readonly SessionCronSummary[] | undefined): void {
|
||||
if (this.inventoryState === "retired") return;
|
||||
this.sessionCronsUnknown = false;
|
||||
if (sessionCrons) this.sessionCrons = sessionCrons.length;
|
||||
}
|
||||
|
||||
/** Returns a full replacement only when the visible inventory changes. */
|
||||
observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined {
|
||||
if (this.inventoryState === "retired") return undefined;
|
||||
@@ -54,6 +70,8 @@ export class ClaudeRuntimeSafety {
|
||||
if (message.type === "assistant") {
|
||||
let relevant = false;
|
||||
for (const block of message.message.content) {
|
||||
if (block.type === "tool_use" && SESSION_CRON_TOOLS.has(block.name))
|
||||
this.sessionCronsUnknown = true;
|
||||
if (block.type === "tool_use" && (block.name === "Monitor" || block.name === "Bash")) {
|
||||
if (block.name === "Monitor") relevant = this.observeMonitor(block.id) || relevant;
|
||||
relevant = this.observeInput(block.id, block.input) || relevant;
|
||||
@@ -129,6 +147,8 @@ export class ClaudeRuntimeSafety {
|
||||
if (this.inventoryState === "unknown" && !this.turnSeen) return true;
|
||||
return (
|
||||
this.inventoryState === "supported" &&
|
||||
this.sessionCrons === 0 &&
|
||||
!this.sessionCronsUnknown &&
|
||||
this.liveTasks.length === 0 &&
|
||||
this.awaitingNotifications.size === 0
|
||||
);
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "../agent/agent-sdk-types.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
createRealProviderClient,
|
||||
getRealProviderConfig,
|
||||
} from "./real-provider-test-config.js";
|
||||
|
||||
describe("real claude: idle runtime cleanup and session crons", () => {
|
||||
let canRun = false;
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("claude");
|
||||
});
|
||||
beforeEach((context) => {
|
||||
if (!canRun) context.skip();
|
||||
});
|
||||
|
||||
test("a runtime holding a session-only cron is kept until the cron is deleted", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = createRealProviderClient("claude", logger);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-session-cron-"));
|
||||
const session = await client.createSession({
|
||||
cwd,
|
||||
...getRealProviderConfig("claude"),
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await session.run(
|
||||
"Call the CronCreate tool exactly once with cron '0 */6 * * *', recurring true, durable false, prompt 'say hi'. Then reply with exactly: DONE",
|
||||
);
|
||||
const cronResult = events.find(
|
||||
(event) =>
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "CronCreate" &&
|
||||
event.item.status === "completed",
|
||||
);
|
||||
expect(cronResult, JSON.stringify(events.slice(-5))).toBeDefined();
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
|
||||
await session.run(
|
||||
"Delete every scheduled cron job with the CronDelete tool. Then reply with exactly: DONE",
|
||||
);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}, 240_000);
|
||||
|
||||
test("a turn interrupted after it creates a session-only cron keeps the runtime", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const client = createRealProviderClient("claude", logger);
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-session-cron-"));
|
||||
const session = await client.createSession({
|
||||
cwd,
|
||||
...getRealProviderConfig("claude"),
|
||||
});
|
||||
const cronCreated = new Promise<void>((resolve) => {
|
||||
session.subscribe((event) => {
|
||||
if (
|
||||
event.type === "timeline" &&
|
||||
event.item.type === "tool_call" &&
|
||||
event.item.name === "CronCreate" &&
|
||||
event.item.status === "completed"
|
||||
)
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
try {
|
||||
await session.startTurn(
|
||||
"Call the CronCreate tool exactly once with cron '0 */6 * * *', recurring true, durable false, prompt 'say hi'. Then run the Bash command 'sleep 120' in the foreground. Then reply with exactly: DONE",
|
||||
);
|
||||
await cronCreated;
|
||||
await session.interrupt();
|
||||
// The cancelled turn settles about a second after interrupt() resolves.
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000));
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
@@ -1660,6 +1660,37 @@ describe("plugin timeline append RPC", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["repro.card", "Review", "review_card", "1review", ""])(
|
||||
"rejects the kind %j that no client renderer can register",
|
||||
async (kind) => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const appendTimelineItem = vi.fn();
|
||||
const session = createSessionForTest({
|
||||
clientId: "plugin:review",
|
||||
messages,
|
||||
agentManager: { appendTimelineItem },
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "agent.timeline.append.request",
|
||||
requestId: "append-kind",
|
||||
agentId: "agent-1",
|
||||
item: { type: "plugin", id: "review-1", kind, version: 1, data: {} },
|
||||
});
|
||||
|
||||
expect(appendTimelineItem).not.toHaveBeenCalled();
|
||||
expect(messages).toContainEqual({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: "append-kind",
|
||||
requestType: "agent.timeline.append.request",
|
||||
error: `Request failed: Plugin timeline item kind must start with a lowercase letter and contain only lowercase letters, digits, and dashes: ${JSON.stringify(kind)}`,
|
||||
code: "handler_error",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("agent detach RPC", () => {
|
||||
|
||||
@@ -144,7 +144,10 @@ import {
|
||||
updateAgentCommand,
|
||||
} from "./agent/lifecycle-command.js";
|
||||
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
|
||||
import { assertPluginTimelineDataSize } from "./agent/agent-timeline-content.js";
|
||||
import {
|
||||
assertPluginTimelineDataSize,
|
||||
assertPluginTimelineKind,
|
||||
} from "./agent/agent-timeline-content.js";
|
||||
import { parsePluginClientId } from "./plugins/plugin-session-identity.js";
|
||||
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
|
||||
import { buildAgentPrompt } from "./agent/prompt-attachments.js";
|
||||
@@ -8504,6 +8507,7 @@ export class Session {
|
||||
): Promise<void> {
|
||||
const pluginId = parsePluginClientId(this.clientId);
|
||||
if (!pluginId) throw new Error("Only plugin sessions can append plugin timeline items");
|
||||
assertPluginTimelineKind(msg.item.kind);
|
||||
assertPluginTimelineDataSize(msg.item.data);
|
||||
const { seq, epoch } = await this.agentManager.appendTimelineItem(msg.agentId, {
|
||||
...msg.item,
|
||||
|
||||
@@ -641,7 +641,83 @@ describe("WorkspaceReconciliationService", () => {
|
||||
|
||||
expect(workspaces.get("w1")).toMatchObject({
|
||||
archivedAt,
|
||||
branch: "new-branch",
|
||||
branch: "old-branch",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps a worktree restorable when it is archived while its checkout read is in flight", async () => {
|
||||
const projectRoot = "/reconcile/archive-worktree/root";
|
||||
const worktreeCwd = "/reconcile/archive-worktree/worktrees/feature";
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
projects.set(
|
||||
"p1",
|
||||
createPersistedProjectRecord({
|
||||
projectId: "p1",
|
||||
rootPath: projectRoot,
|
||||
projectKey: deriveProjectKey({
|
||||
rootPath: projectRoot,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: projectRoot,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
kind: "git",
|
||||
displayName: "archive-worktree",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
"w1",
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "w1",
|
||||
projectId: "p1",
|
||||
cwd: worktreeCwd,
|
||||
kind: "worktree",
|
||||
displayName: "feature",
|
||||
branch: "feature",
|
||||
worktreeRoot: worktreeCwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: projectRoot,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
const worktreeReadStarted = deferred();
|
||||
const allowWorktreeRead = deferred();
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
inspectDirectory: async () => "directory",
|
||||
workspaceGitService: {
|
||||
getCheckout: async (cwd) => {
|
||||
if (cwd === projectRoot) {
|
||||
return createCheckout(cwd, { isGit: true, currentBranch: "main", worktreeRoot: cwd });
|
||||
}
|
||||
worktreeReadStarted.resolve();
|
||||
await allowWorktreeRead.promise;
|
||||
// Archive deletes the worktree after archiving its record, so a read that
|
||||
// lands mid-deletion no longer sees a Git checkout there.
|
||||
return createCheckout(cwd);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const reconciliation = service.runOnce();
|
||||
await worktreeReadStarted.promise;
|
||||
const archivedAt = "2025-01-02T00:00:00.000Z";
|
||||
await workspaceRegistry.archive("w1", archivedAt);
|
||||
allowWorktreeRead.resolve();
|
||||
const result = await reconciliation;
|
||||
|
||||
expect(result.changesApplied).toEqual([]);
|
||||
expect(workspaces.get("w1")).toMatchObject({
|
||||
archivedAt,
|
||||
kind: "worktree",
|
||||
branch: "feature",
|
||||
worktreeRoot: worktreeCwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: projectRoot,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -396,8 +396,7 @@ export class WorkspaceReconciliationService {
|
||||
const workspaceCheckouts = await Promise.all(
|
||||
siblings.map(async (workspace) => {
|
||||
const current = await this.workspaceRegistry.get(workspace.workspaceId);
|
||||
if (!current || current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
|
||||
return null;
|
||||
if (!current || !isStillReconcilable(current, workspace)) return null;
|
||||
return { workspace: current, checkout: await readCheckout(current.cwd) };
|
||||
}),
|
||||
);
|
||||
@@ -446,8 +445,9 @@ export class WorkspaceReconciliationService {
|
||||
return;
|
||||
let fields: Partial<MutableWorkspacePlacement> | null = null;
|
||||
await this.workspaceRegistry.update(workspace.workspaceId, (current) => {
|
||||
if (current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
|
||||
return current;
|
||||
// Archive removes a worktree only after archiving its record, so a read
|
||||
// that raced the archive may have seen the directory mid-deletion.
|
||||
if (!isStillReconcilable(current, workspace)) return current;
|
||||
const update = reconcileWorkspacePlacement({
|
||||
workspace: current,
|
||||
checkout: wsGit,
|
||||
@@ -685,3 +685,13 @@ async function mapBounded<T, R>(items: T[], visit: (item: T) => Promise<R>): Pro
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
/** An archived record keeps the placement its restore depends on. */
|
||||
function isStillReconcilable(
|
||||
current: PersistedWorkspaceRecord,
|
||||
scanned: PersistedWorkspaceRecord,
|
||||
): boolean {
|
||||
return (
|
||||
!current.archivedAt && current.cwd === scanned.cwd && current.projectId === scanned.projectId
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1690,8 +1690,10 @@ it("leaves a worker from a newer Paseo running, reports it incompatible, and clo
|
||||
|
||||
await manager.closeTerminals();
|
||||
|
||||
expect(isProcessAlive(newer.workerPid)).toBe(false);
|
||||
expect(isProcessAlive(newer.programPid)).toBe(false);
|
||||
// Cleanup counts the worker gone once its socket refuses connections, which happens before
|
||||
// its process exits.
|
||||
await waitForCondition(() => !isProcessAlive(newer.workerPid), 5000);
|
||||
expect(await workerRecords(paseoHome)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Locator, type Page } from "./fixtures";
|
||||
import { expect, test, waitForHydration, type Locator, type Page } from "./fixtures";
|
||||
|
||||
// The page renders the repository's CHANGELOG.md, so releases are found by shape, never by version:
|
||||
// a release with highlights has the toggle, a release without them does not.
|
||||
@@ -6,12 +6,9 @@ function releases(page: Page): Locator {
|
||||
return page.getByRole("region");
|
||||
}
|
||||
|
||||
// The router resets the scroll position when the page hydrates. A click sent before that is aimed
|
||||
// at where the control was, so wait for the page to be interactive: the language button is enabled
|
||||
// once it has hydrated.
|
||||
async function openChangelog(page: Page): Promise<void> {
|
||||
await page.goto("/changelog");
|
||||
await expect(page.getByRole("button", { name: /^Language/ }).first()).toBeEnabled();
|
||||
await waitForHydration(page);
|
||||
}
|
||||
|
||||
function releaseWithHighlights(page: Page): Locator {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { test as base } from "playwright/test";
|
||||
import { expect, test as base, type Page } from "playwright/test";
|
||||
|
||||
export { expect } from "playwright/test";
|
||||
export type { Locator, Page } from "playwright/test";
|
||||
|
||||
/**
|
||||
* Waits until the page has hydrated. Server-rendered content shows before that, but it has no
|
||||
* click handlers yet, and the router scrolls the window back to the top when it hydrates.
|
||||
* The language button stays disabled until the render after that reset.
|
||||
*/
|
||||
export async function waitForHydration(page: Page) {
|
||||
await expect(page.getByRole("button", { name: /^(Language|语言)/ }).first()).toBeEnabled();
|
||||
}
|
||||
|
||||
export const test = base.extend({
|
||||
page: async ({ page, baseURL }, run) => {
|
||||
page.on("response", async (response) => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { expect, test, type Locator, type Page } from "./fixtures";
|
||||
import { expect, test, waitForHydration, type Locator, type Page } from "./fixtures";
|
||||
|
||||
const desktopName =
|
||||
"Paseo desktop app with coding agents, a conversation, and a code diff open side by side";
|
||||
|
||||
async function openHomepage(page: Page) {
|
||||
await page.setViewportSize({ width: 1512, height: 930 });
|
||||
/** Opens the homepage once it has hydrated, so the router's scroll reset cannot undo a later scroll. */
|
||||
async function openHomepage(page: Page, window = { width: 1512, height: 930 }) {
|
||||
await page.setViewportSize(window);
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible();
|
||||
await waitForHydration(page);
|
||||
}
|
||||
|
||||
async function viewPhones(page: Page) {
|
||||
@@ -60,6 +61,225 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
|
||||
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
|
||||
});
|
||||
|
||||
/** Each hero pill and one thing only its finished scene draws. */
|
||||
const HERO_SCENES = [
|
||||
{ pill: "Orchestration", shows: "Pi · GLM-5.2" },
|
||||
{ pill: "Remote", shows: "Tailscale" },
|
||||
{ pill: "Code review", shows: "Feedback sent to Codex" },
|
||||
{ pill: "Pull requests", shows: "https://github.com/getpaseo/paseo/pull/3981" },
|
||||
{ pill: "Plugins", shows: "app-tests (macos)" },
|
||||
];
|
||||
|
||||
/** Clicks a hero pill until its scene starts. A click before hydration selects nothing. */
|
||||
async function playScene(page: Page, pill: string, opensWith: Locator) {
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: pill, exact: true }).click();
|
||||
await expect(opensWith).toBeVisible({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test("each hero pill shows its finished scene when motion is reduced", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await mockup.scrollIntoViewIfNeeded();
|
||||
// Last to first, so the opening scene is also reached by a click.
|
||||
for (const scene of HERO_SCENES.toReversed()) {
|
||||
await playScene(page, scene.pill, mockup.getByText(scene.shows, { exact: true }).first());
|
||||
}
|
||||
});
|
||||
|
||||
test("the hero opens on orchestration across providers and machines", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(page.getByRole("button", { name: "Orchestration", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
for (const agent of ["Codex · GPT-5.6-Sol", "OpenCode · Kimi K3", "Pi · GLM-5.2"]) {
|
||||
await expect(mockup.getByText(agent, { exact: true })).toBeVisible();
|
||||
}
|
||||
// The task on another machine carries that host's badge in its tool call and its subagent row.
|
||||
for (const remoteTask of ["OpenCode · fix/menu-bar-icon", "OpenCode · Kimi K3"]) {
|
||||
const row = mockup.getByText(remoteTask, { exact: true }).locator("xpath=..");
|
||||
await expect(row.getByText("mac-mini", { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("the orchestration scene plays from an empty chat to the open roster of subagents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const composer = mockup.getByText("Message the agent, tag @files, or use /commands and /skills");
|
||||
await playScene(page, "Orchestration", composer);
|
||||
await expect(mockup.getByText("Create agent", { exact: true })).toHaveCount(0);
|
||||
await expect(mockup.getByText("3 subagents", { exact: true })).toHaveCount(0);
|
||||
|
||||
// The orchestrator lists the issues and the agent profiles, launches three agents and says so,
|
||||
// then the pointer clicks the pill and the roster opens.
|
||||
await expect(mockup.getByText("3 subagents", { exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await expect(mockup.getByText("gh issue list --label ready", { exact: true })).toBeVisible();
|
||||
await expect(mockup.getByText("List agent profiles", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
mockup.getByText(
|
||||
"I will launch the UI agent on your mac-mini, because that issue is specific to macOS.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(mockup.getByText("Create agent", { exact: true })).toHaveCount(3);
|
||||
await expect(
|
||||
mockup.getByText("All three are launched. Paseo will notify me when each one finishes."),
|
||||
).toBeVisible();
|
||||
await expect(mockup.getByText("Pi · GLM-5.2", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the opening scene waits until most of the mockup is on screen", async ({ page }) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const request = mockup.getByText("Fix the issues labeled ready, one worktree each.");
|
||||
await expect(mockup).not.toBeInViewport({ ratio: 0.6 });
|
||||
|
||||
// A scene that started at load sends the request within three seconds.
|
||||
await page.waitForTimeout(4500);
|
||||
await expect(request).toHaveCount(0);
|
||||
|
||||
await mockup.scrollIntoViewIfNeeded();
|
||||
await expect(request).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the opening scene starts in a window shorter than most of the mockup", async ({ page }) => {
|
||||
await openHomepage(page, { width: 1400, height: 400 });
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const request = mockup.getByText("Fix the issues labeled ready, one worktree each.");
|
||||
await mockup.scrollIntoViewIfNeeded();
|
||||
await expect(mockup).not.toBeInViewport({ ratio: 0.6 });
|
||||
await expect(request).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the plugins scene installs the panel, then changes it on request", async ({ page }) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const changesPanel = mockup.getByText("Uncommitted", { exact: true });
|
||||
const pluginPanel = mockup.getByText("Recently merged", { exact: true });
|
||||
const failingCheck = mockup.getByText("app-tests (macos)", { exact: true });
|
||||
await playScene(page, "Plugins", page.getByRole("button", { name: "Plugins", pressed: true }));
|
||||
await expect(changesPanel).toBeVisible();
|
||||
await expect(pluginPanel).toHaveCount(0);
|
||||
|
||||
// Installing the plugin swaps the docked panel for the one the agent built.
|
||||
await expect(pluginPanel).toBeVisible({ timeout: 20_000 });
|
||||
await expect(changesPanel).toHaveCount(0);
|
||||
await expect(failingCheck).toHaveCount(0);
|
||||
|
||||
// The second request reloads the plugin and the open panel lists the failing check.
|
||||
await expect(failingCheck).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
mockup.getByText("paseo plugin reload release-radar", { exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("the code review scene comments on a line and sends the feedback to an agent", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
const comment = mockup.getByText("Add “write”. The agent edits files too.", { exact: true });
|
||||
const sendFeedback = mockup.getByText("Send feedback (1)", { exact: true });
|
||||
await playScene(
|
||||
page,
|
||||
"Code review",
|
||||
page.getByRole("button", { name: "Code review", pressed: true }),
|
||||
);
|
||||
await expect(comment).toHaveCount(0);
|
||||
|
||||
// The pointer opens the editor on a line, and the typed comment is saved under that line.
|
||||
await expect(sendFeedback).toBeVisible({ timeout: 10_000 });
|
||||
await expect(comment).toBeVisible();
|
||||
|
||||
// Send feedback asks which agent, and the sent comment leaves the diff.
|
||||
await expect(mockup.getByText("Claude Code", { exact: true })).toHaveCount(2, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(mockup.getByText("Feedback sent to Codex", { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(sendFeedback).toHaveCount(0);
|
||||
await expect(comment).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("picking a scene brings the pills and the whole mockup on screen", async ({ page }) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(mockup).not.toBeInViewport({ ratio: 0.9 });
|
||||
|
||||
await playScene(page, "Remote", page.getByRole("button", { name: "Remote", pressed: true }));
|
||||
await expect(mockup).toBeInViewport({ ratio: 0.99 });
|
||||
await expect(page.getByRole("button", { name: "Remote", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("picking a scene in a window too short for the mockup keeps the pills on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomepage(page);
|
||||
await page.setViewportSize({ width: 1280, height: 600 });
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(mockup).not.toBeInViewport();
|
||||
|
||||
await playScene(page, "Remote", page.getByRole("button", { name: "Remote", pressed: true }));
|
||||
await expect(mockup).toBeInViewport({ ratio: 0.7 });
|
||||
await expect(page.getByRole("button", { name: "Remote", exact: true })).toBeInViewport({
|
||||
ratio: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("picking a scene while the hero is still sliding in keeps the pills on screen", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Reduced motion makes the scroll instant, so the test does not wait for one to settle.
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await openHomepage(page, { width: 1280, height: 700 });
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await playScene(page, "Remote", page.getByRole("button", { name: "Remote", pressed: true }));
|
||||
|
||||
// The hero slides up 40px as it enters. The test waits for that to end, puts the same 40px back
|
||||
// on the container, picks a scene, and lets the slide end again. A click during a real slide
|
||||
// does the same, but only inside the first second after load.
|
||||
const sliding = page
|
||||
.getByRole("button", { name: "Remote", exact: true })
|
||||
.locator("xpath=../../../..");
|
||||
await expect(sliding).toHaveCSS("transform", "none");
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await expect(mockup).not.toBeInViewport({ ratio: 0.99 });
|
||||
const slideBy = (pixels: number) =>
|
||||
sliding.evaluate((container, y) => {
|
||||
container.style.transform = y === 0 ? "none" : `translateY(${y}px)`;
|
||||
}, pixels);
|
||||
|
||||
const codeReview = page.getByRole("button", { name: "Code review", exact: true });
|
||||
await slideBy(40);
|
||||
await codeReview.click();
|
||||
await slideBy(0);
|
||||
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(0);
|
||||
await expect(codeReview).toBeInViewport({ ratio: 1 });
|
||||
});
|
||||
|
||||
test("the remote scene lists hosts with how each connects and its round trip time", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openHomepage(page);
|
||||
const mockup = page.getByRole("img", { name: desktopName, exact: true });
|
||||
await expect(async () => {
|
||||
await page.getByRole("button", { name: "Remote", exact: true }).click();
|
||||
await expect(mockup.getByText("Hosts", { exact: true })).toBeVisible({ timeout: 500 });
|
||||
}).toPass({ timeout: 10_000 });
|
||||
for (const fact of ["Local", "Relay", "Tailscale", "SSH", "38ms", "mo@workstation"]) {
|
||||
await expect(mockup.getByText(fact, { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
/** Opacity as rendered: the element's own times every ancestor's. */
|
||||
async function renderedOpacity(locator: Locator) {
|
||||
return locator.evaluate((element) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import securityPlugin from "./overview.fixture.json" with { type: "json" };
|
||||
import { expect, test, type Page, type Locator } from "./fixtures";
|
||||
import { expect, test, waitForHydration, type Page, type Locator } from "./fixtures";
|
||||
import registry from "./registry.fixture.json" with { type: "json" };
|
||||
import { CATEGORIES } from "../src/plugins/categories";
|
||||
import copy from "../src/i18n/resources/en.json" with { type: "json" };
|
||||
@@ -9,12 +9,6 @@ async function openPlugins(page: Page) {
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
|
||||
}
|
||||
|
||||
async function waitForInteractivePage(page: Page) {
|
||||
// Server-rendered content is visible before hydration attaches its click handlers.
|
||||
// The language selector stays disabled until that page has hydrated.
|
||||
await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled();
|
||||
}
|
||||
|
||||
function pluginCards(page: Page, section: string): Locator {
|
||||
return page
|
||||
.getByRole("region", { name: section })
|
||||
@@ -84,18 +78,18 @@ test("browses from the directory into a category, a plugin, and its author", asy
|
||||
});
|
||||
});
|
||||
await openPlugins(page);
|
||||
await waitForInteractivePage(page);
|
||||
await waitForHydration(page);
|
||||
|
||||
await page.getByRole("region", { name: "Categories" }).getByRole("link", { name: /Git/ }).click();
|
||||
await expect(page).toHaveURL(/\/plugins\/category\/git$/);
|
||||
await expect(page.getByRole("heading", { level: 1, name: /^Git/ })).toBeVisible();
|
||||
await waitForInteractivePage(page);
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByRole("link", { name: /Dracula/ })).toHaveCount(0);
|
||||
|
||||
await page.getByRole("link", { name: /Fresh Worktrees/ }).click();
|
||||
await expect(page).toHaveURL(/\/plugins\/omercnet\/fresh-worktrees$/);
|
||||
await expect(page.getByRole("heading", { name: "Fresh Worktrees" })).toHaveCount(1);
|
||||
await waitForInteractivePage(page);
|
||||
await waitForHydration(page);
|
||||
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1);
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
|
||||
@@ -192,7 +186,7 @@ test("keeps the directory's ranking window when searching", async ({ page }) =>
|
||||
|
||||
test("submits the visible search term when its input event was missed", async ({ page }) => {
|
||||
await page.goto("/plugins?window=month");
|
||||
await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled();
|
||||
await waitForHydration(page);
|
||||
await fillSearchWithMissedInputEvent(page, "graphite");
|
||||
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
@@ -1,24 +1,57 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useState } from "react";
|
||||
import { motion, useInView, useReducedMotion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DEFAULT_MOCKUP_STATE,
|
||||
createMockupPlayer,
|
||||
DESIGN_HEIGHT,
|
||||
DESIGN_WIDTH,
|
||||
MOCKUP_STATES,
|
||||
type MockupStateId,
|
||||
MockupWindow,
|
||||
ScaledMockup,
|
||||
useMockupScene,
|
||||
} from "~/components/mockup";
|
||||
|
||||
const IN_VIEW = { once: true };
|
||||
/** How far down the mockup its start line sits. */
|
||||
const START_LINE = "60%";
|
||||
/** The space left between the top or bottom of the window and the pills or the mockup. */
|
||||
const PAGE_MARGIN = 16;
|
||||
const START_LINE_STYLE = { top: START_LINE };
|
||||
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
|
||||
|
||||
export function HeroMockup() {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
|
||||
const [player] = useState(createMockupPlayer);
|
||||
const state = useMockupScene(player);
|
||||
|
||||
// The opening scene plays once most of the mockup is on screen, so a visitor who scrolls to it
|
||||
// sees it from the start. "Most" is a line 60% of the way down the mockup. A window shorter than
|
||||
// 60% of the mockup still reaches the line, which a share of the mockup on screen would not.
|
||||
// With reduced motion there is nothing to miss, so the finished scene shows at once.
|
||||
const frame = useRef<HTMLDivElement>(null);
|
||||
const startLine = useRef<HTMLDivElement>(null);
|
||||
const reduceMotion = useReducedMotion();
|
||||
const onScreen = useInView(startLine, IN_VIEW);
|
||||
const ready = onScreen || reduceMotion === true;
|
||||
useEffect(() => {
|
||||
if (ready) player.start();
|
||||
}, [ready, player]);
|
||||
useEffect(() => player.stop, [player]);
|
||||
|
||||
// The pills can sit above a mockup that is partly below the fold, most of all on a phone.
|
||||
// Picking a scene brings the pills and the mockup on screen together. A window too short for
|
||||
// both keeps the pills at its top edge, so the next pill is still in reach.
|
||||
const select = useCallback(
|
||||
(scene: MockupStateId) => {
|
||||
player.select(scene);
|
||||
if (frame.current) scrollOnScreen(frame.current, reduceMotion ? "auto" : "smooth");
|
||||
},
|
||||
[player, reduceMotion],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={frame}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
|
||||
{MOCKUP_STATES.map((option) => (
|
||||
<StatePill
|
||||
@@ -26,24 +59,54 @@ export function HeroMockup() {
|
||||
id={option}
|
||||
label={t(`landing.mockup.${option}`)}
|
||||
selected={option === state}
|
||||
onSelect={setState}
|
||||
onSelect={select}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ScaledMockup
|
||||
width={DESIGN_WIDTH}
|
||||
height={DESIGN_HEIGHT}
|
||||
borderRadius={18}
|
||||
label={t("landing.mockup.alt")}
|
||||
className="ring-1 ring-white/10"
|
||||
>
|
||||
<MockupWindow state={state} />
|
||||
</ScaledMockup>
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={startLine}
|
||||
aria-hidden="true"
|
||||
style={START_LINE_STYLE}
|
||||
className="pointer-events-none absolute inset-x-0 h-px"
|
||||
/>
|
||||
<ScaledMockup
|
||||
width={DESIGN_WIDTH}
|
||||
height={DESIGN_HEIGHT}
|
||||
borderRadius={18}
|
||||
label={t("landing.mockup.alt")}
|
||||
className="ring-1 ring-white/10"
|
||||
>
|
||||
<MockupWindow player={player} />
|
||||
</ScaledMockup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Where an element sits in the window, from layout alone. A slide-in that is still running does not count. */
|
||||
function layoutRect(element: HTMLElement) {
|
||||
let top = -window.scrollY;
|
||||
for (let node: Element | null = element; node instanceof HTMLElement; node = node.offsetParent) {
|
||||
top += node.offsetTop;
|
||||
}
|
||||
return { top, bottom: top + element.offsetHeight };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls the least that shows all of an element, and never past the point where its top edge is
|
||||
* at the top of the window. The element is measured where it settles, because the hero slides up
|
||||
* as it enters and a click can come before it stops.
|
||||
*/
|
||||
function scrollOnScreen(element: HTMLElement, behavior: ScrollBehavior) {
|
||||
const { top, bottom } = layoutRect(element);
|
||||
const aboveWindow = top - PAGE_MARGIN;
|
||||
const belowWindow = bottom + PAGE_MARGIN - window.innerHeight;
|
||||
const distance = aboveWindow < 0 ? aboveWindow : Math.min(Math.max(belowWindow, 0), aboveWindow);
|
||||
if (distance !== 0) window.scrollBy({ top: distance, behavior });
|
||||
}
|
||||
|
||||
function StatePill({
|
||||
id,
|
||||
label,
|
||||
|
||||
@@ -714,8 +714,8 @@ function AutomationDetail({ kind }: { kind: AutomationKind }) {
|
||||
}
|
||||
|
||||
const MCP_AGENT_CALLS: ToolCall[] = [
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #412: retry flaky relay handshake" },
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #417: group schedules by project" },
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #412: relay handshake race" },
|
||||
{ kind: "paseo", label: "Create agent", detail: "Fix #417: menu bar icon on macOS" },
|
||||
];
|
||||
|
||||
function McpAutomationTranscript() {
|
||||
|
||||
@@ -18,6 +18,13 @@ export function DiffStat({ add, remove }: { add: string; remove: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The text caret of an input that has focus. */
|
||||
export function Caret({ className }: { className: string }) {
|
||||
return (
|
||||
<span className={`inline-block h-[14px] w-px translate-y-[2px] bg-mock-fg ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
export type DotTone = "success" | "danger" | "warning" | "running" | "idle";
|
||||
|
||||
const DOT_TONE: Record<DotTone, string> = {
|
||||
|
||||
@@ -15,15 +15,19 @@ import {
|
||||
SquareTerminal,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiffStat } from "./atoms";
|
||||
import { CodexIcon, PaseoMark } from "./icons";
|
||||
|
||||
// Icons passed as props are hoisted so they are not rebuilt on every render.
|
||||
const MODEL_ICON = <CodexIcon size={13} />;
|
||||
const EFFORT_ICON = <Brain size={13} />;
|
||||
const ACCESS_ICON = <ShieldOff size={13} />;
|
||||
import { Caret, DiffStat } from "./atoms";
|
||||
import { HostBadge } from "./hosts";
|
||||
import { PaseoMark } from "./icons";
|
||||
import { FADE_IN, FADE_OUT } from "./motion";
|
||||
import { DELEGATED_TASKS, SUBAGENTS_CLICK, tasksDelegated } from "./orchestration";
|
||||
import { useSceneClock } from "./player";
|
||||
import { Pointer } from "./pointer";
|
||||
import { PROVIDERS, type ProviderId } from "./providers";
|
||||
import { COMPOSER, type SceneScript, type ToolCall, type TranscriptEntry } from "./script";
|
||||
import type { MockupStateId } from "./states";
|
||||
|
||||
// --- transcript -------------------------------------------------------------
|
||||
|
||||
@@ -62,6 +66,38 @@ function TurnFooter({ duration }: { duration: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const;
|
||||
|
||||
function toolCallLabel(call: ToolCall): string {
|
||||
return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind];
|
||||
}
|
||||
|
||||
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
|
||||
if (kind === "read") return <Eye size={14} />;
|
||||
if (kind === "write") return <Pencil size={14} />;
|
||||
if (kind === "paseo") return <PaseoMark size={14} />;
|
||||
return <SquareTerminal size={14} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool call row, drawn like the app's collapsed timeline item
|
||||
* (`packages/app/src/components/message.tsx`): icon, label, then the summary,
|
||||
* all muted and in the UI font. A Paseo call that targets another machine ends
|
||||
* with that host's badge, which the app's row does not draw.
|
||||
*/
|
||||
export function ToolCallRow({ call }: { call: ToolCall }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
|
||||
<span className="shrink-0">
|
||||
<ToolCallIcon kind={call.kind} />
|
||||
</span>
|
||||
<span className="shrink-0">{toolCallLabel(call)}</span>
|
||||
{call.detail ? <span className="min-w-0 truncate">{call.detail}</span> : null}
|
||||
{call.kind === "paseo" && call.host ? <HostBadge host={call.host} glyph={13} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentTranscript() {
|
||||
return (
|
||||
<div className="space-y-[13px] pt-[2px]">
|
||||
@@ -88,161 +124,273 @@ export function AgentTranscript() {
|
||||
<Bullet>Includes responsive mobile proof and the supporting homepage story.</Bullet>
|
||||
<Bullet>Formatting, typecheck, and lint are clean.</Bullet>
|
||||
</div>
|
||||
<Para>Ready for review.</Para>
|
||||
<TurnFooter duration="1m 18s" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
|
||||
* fixed label; Paseo MCP tools name their action, such as "Create agent".
|
||||
*/
|
||||
export type ToolCall =
|
||||
| { kind: "read" | "write" | "shell"; detail: string }
|
||||
| { kind: "paseo"; label: string; detail: string };
|
||||
|
||||
const BUILT_IN_LABELS = { read: "Read", write: "Write", shell: "Shell" } as const;
|
||||
|
||||
function toolCallLabel(call: ToolCall): string {
|
||||
return call.kind === "paseo" ? call.label : BUILT_IN_LABELS[call.kind];
|
||||
}
|
||||
|
||||
const PLUGIN_TOOL_CALLS: ToolCall[] = [
|
||||
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
|
||||
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
|
||||
{ kind: "write", detail: "~/dev/release-radar/index.ts" },
|
||||
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
|
||||
{ kind: "shell", detail: "npm install && npm run typecheck" },
|
||||
{ kind: "shell", detail: "paseo plugin install ~/dev/release-radar" },
|
||||
];
|
||||
|
||||
const PLUGIN_DISCOVERY_CALLS = PLUGIN_TOOL_CALLS.slice(0, 1);
|
||||
const PLUGIN_BUILD_CALLS = PLUGIN_TOOL_CALLS.slice(1, 4);
|
||||
const PLUGIN_INSTALL_CALLS = PLUGIN_TOOL_CALLS.slice(4);
|
||||
|
||||
function ToolCallIcon({ kind }: { kind: ToolCall["kind"] }) {
|
||||
if (kind === "read") return <Eye size={14} />;
|
||||
if (kind === "write") return <Pencil size={14} />;
|
||||
if (kind === "paseo") return <PaseoMark size={14} />;
|
||||
return <SquareTerminal size={14} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tool call row, drawn like the app's collapsed timeline item
|
||||
* (`packages/app/src/components/message.tsx`): icon, label, then the summary,
|
||||
* all muted and in the UI font.
|
||||
*/
|
||||
export function ToolCallRow({ call }: { call: ToolCall }) {
|
||||
/** Something that fades in when the script reaches it. */
|
||||
function Entering({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[8px] py-[5px] text-[12.5px] leading-[18px] text-mock-fg-muted">
|
||||
<span className="shrink-0">
|
||||
<ToolCallIcon kind={call.kind} />
|
||||
</span>
|
||||
<span className="shrink-0">{toolCallLabel(call)}</span>
|
||||
<span className="min-w-0 truncate">{call.detail}</span>
|
||||
</div>
|
||||
<motion.div initial={FADE_OUT} animate={FADE_IN}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
|
||||
interface ScriptedProps {
|
||||
scene: MockupStateId;
|
||||
script: SceneScript;
|
||||
}
|
||||
|
||||
function StreamedText({ scene, script, entry }: ScriptedProps & { entry: TranscriptEntry }) {
|
||||
const text = useSceneClock(scene, (ms) => script.streamed(entry, ms));
|
||||
return <Para>{text}</Para>;
|
||||
}
|
||||
|
||||
function LandedTools({
|
||||
scene,
|
||||
script,
|
||||
entry,
|
||||
calls,
|
||||
}: ScriptedProps & { entry: TranscriptEntry; calls: ToolCall[] }) {
|
||||
const landed = useSceneClock(scene, (ms) => script.landed(entry, ms));
|
||||
return (
|
||||
<div>
|
||||
{calls.map((call) => (
|
||||
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
|
||||
{calls.slice(0, landed).map((call) => (
|
||||
<Entering key={`${call.kind}:${call.detail}`}>
|
||||
<ToolCallRow call={call} />
|
||||
</Entering>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PluginBuildTranscript() {
|
||||
function TranscriptEntryView({ scene, script, entry }: ScriptedProps & { entry: TranscriptEntry }) {
|
||||
const { beat } = entry;
|
||||
if (beat.kind === "agent") return <StreamedText scene={scene} script={script} entry={entry} />;
|
||||
if (beat.kind === "tools") {
|
||||
return <LandedTools scene={scene} script={script} entry={entry} calls={beat.calls} />;
|
||||
}
|
||||
if (beat.kind === "user") {
|
||||
return (
|
||||
<Entering>
|
||||
<UserBubble>{beat.text}</UserBubble>
|
||||
</Entering>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Entering>
|
||||
<TurnFooter duration={beat.worked} />
|
||||
</Entering>
|
||||
);
|
||||
}
|
||||
|
||||
/** The transcript of a scripted scene, as far as its script has played. */
|
||||
export function ScriptedTranscript({ scene, script }: ScriptedProps) {
|
||||
const entered = useSceneClock(scene, script.entered);
|
||||
return (
|
||||
<div className="space-y-[13px] pt-[2px]">
|
||||
<UserBubble>
|
||||
Build me a release radar for Paseo. I want an explorer panel showing merged PRs, changes in
|
||||
review, failing checks, and seven-day activity, accessible from the sidebar.
|
||||
</UserBubble>
|
||||
<Para>
|
||||
I’ll scaffold a local plugin, add the explorer surface, and wire it into the sidebar.
|
||||
</Para>
|
||||
<PluginToolGroup calls={PLUGIN_DISCOVERY_CALLS} />
|
||||
<Para>
|
||||
The plugin API exposes Paseo’s live project and workspace state, so the radar
|
||||
doesn’t need a separate service.
|
||||
</Para>
|
||||
<PluginToolGroup calls={PLUGIN_BUILD_CALLS} />
|
||||
<Para>The surface is wired. I’m checking the project and installing it now.</Para>
|
||||
<PluginToolGroup calls={PLUGIN_INSTALL_CALLS} />
|
||||
<Para>Release Radar is live. It should now be visible in the right sidebar.</Para>
|
||||
<TurnFooter duration="2m 14s" />
|
||||
{script.entries.slice(0, entered).map((entry) => (
|
||||
<TranscriptEntryView key={entry.id} scene={scene} script={script} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- composer ---------------------------------------------------------------
|
||||
|
||||
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
/** One composer control: its icon as the child, then the current value. */
|
||||
function ControlPill({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
|
||||
{icon}
|
||||
<span className="flex items-center gap-[6px] whitespace-nowrap text-[12px] text-mock-fg-muted">
|
||||
{children}
|
||||
{label}
|
||||
<ChevronDown size={11} className="text-mock-fg-xmuted" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A rounded pill that floats above the composer — task tracker, subagents, diff. */
|
||||
function ComposerPill({ children }: { children: React.ReactNode }) {
|
||||
/**
|
||||
* A rounded pill that floats above the composer — task tracker, subagents, diff. An open pill is
|
||||
* the one whose panel is showing.
|
||||
*/
|
||||
function ComposerPill({ children, open = false }: { children: React.ReactNode; open?: boolean }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[6px] rounded-full border border-mock-border-accent px-[11px] py-[4px] text-[11.5px] text-mock-fg-muted">
|
||||
<span
|
||||
className={`flex items-center gap-[6px] rounded-full border border-mock-border-accent px-[11px] py-[4px] text-[11.5px] ${
|
||||
open ? "bg-mock-surface2 text-mock-fg" : "text-mock-fg-muted"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentsPill({ count, open = false }: { count: number; open?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ComposerPill open={open}>
|
||||
<CircleDot size={12} className="text-mock-dot-running" />
|
||||
{t("landing.mockup.app.subagents", { count })}
|
||||
</ComposerPill>
|
||||
);
|
||||
}
|
||||
|
||||
/** The row of pills above the composer input. */
|
||||
function ComposerTrack({ children }: { children: React.ReactNode }) {
|
||||
return <div className="pb-[13px]">{children}</div>;
|
||||
}
|
||||
|
||||
/** The trackers of a turn in progress: tasks, subagents, and the diff so far. */
|
||||
export function ProgressTrack() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ComposerTrack>
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<ComposerPill>
|
||||
{t("landing.mockup.app.tasksProgress", { completed: 6, total: 6 })}
|
||||
</ComposerPill>
|
||||
<SubagentsPill count={DELEGATED_TASKS.length} />
|
||||
<ComposerPill>
|
||||
<DiffStat add="1.9k" remove="684" />
|
||||
</ComposerPill>
|
||||
</div>
|
||||
</ComposerTrack>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentRow({ task }: { task: (typeof DELEGATED_TASKS)[number] }) {
|
||||
const provider = PROVIDERS[task.provider];
|
||||
return (
|
||||
<div className="flex items-start gap-[9px] px-[12px] py-[6px]">
|
||||
<span className="relative mt-[2px] block size-[14px] shrink-0 text-mock-fg-muted">
|
||||
<provider.Icon size={14} />
|
||||
<span className="absolute -right-[2px] -bottom-[2px] size-[6px] rounded-full bg-mock-dot-running ring-[1.5px] ring-mock-surface1" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[13px] leading-[18px] text-mock-fg">{task.title}</span>
|
||||
<span className="flex items-center gap-[6px] text-[11.5px] leading-[16px] text-mock-fg-muted">
|
||||
<span>
|
||||
{provider.name} · {provider.model}
|
||||
</span>
|
||||
{task.host ? (
|
||||
<>
|
||||
<span className="text-mock-fg-xmuted">·</span>
|
||||
<HostBadge host={task.host} />
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Where the pointer sets off from, in design pixels from the spot it clicks.
|
||||
const POINTER_TO_PILL = { x: 236, y: -128 };
|
||||
const POINTER_TO_COMPOSER = { x: 230, y: -140 };
|
||||
|
||||
const ROSTER_CLOSED = { opacity: 0, scale: 0.97, y: 6 };
|
||||
const ROSTER_OPEN = { opacity: 1, scale: 1, y: 0 };
|
||||
|
||||
/**
|
||||
* The Subagents track of the orchestration scene. Its pill appears with the first subagent and
|
||||
* counts each one the orchestrator launches. When the pointer clicks the pill, the roster opens:
|
||||
* one row per agent, drawn like the app's track rows (`packages/app/src/subagents/track.tsx`).
|
||||
* The title is the task and the line under it names the provider and model. An agent on another
|
||||
* machine ends that line with the host's badge, where the app writes the host's name in plain text.
|
||||
*/
|
||||
export function SubagentsTrack() {
|
||||
const launched = useSceneClock("orchestrate", tasksDelegated);
|
||||
const pressed = useSceneClock("orchestrate", SUBAGENTS_CLICK.pressed);
|
||||
const open = useSceneClock("orchestrate", SUBAGENTS_CLICK.done);
|
||||
if (launched === 0) return null;
|
||||
return (
|
||||
<ComposerTrack>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={ROSTER_CLOSED}
|
||||
animate={ROSTER_OPEN}
|
||||
className="mb-[6px] w-fit min-w-[280px] origin-bottom-left rounded-[11px] border border-mock-border-accent bg-mock-surface1 py-[4px] shadow-[0_8px_24px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
{DELEGATED_TASKS.map((task) => (
|
||||
<SubagentRow key={task.id} task={task} />
|
||||
))}
|
||||
</motion.div>
|
||||
) : null}
|
||||
<Entering>
|
||||
<span className="relative flex w-fit">
|
||||
<SubagentsPill count={launched} open={pressed || open} />
|
||||
<Pointer
|
||||
scene="orchestrate"
|
||||
click={SUBAGENTS_CLICK}
|
||||
from={POINTER_TO_PILL}
|
||||
className="top-[58%] left-[91%]"
|
||||
/>
|
||||
</span>
|
||||
</Entering>
|
||||
</ComposerTrack>
|
||||
);
|
||||
}
|
||||
|
||||
/** The amber ring the app shows while a turn is streaming. */
|
||||
function BusyRing() {
|
||||
return <LoaderCircle size={14} className="text-mock-warning" strokeWidth={2.4} />;
|
||||
}
|
||||
|
||||
export function Composer({
|
||||
showProgress = true,
|
||||
busy = true,
|
||||
track,
|
||||
agent = "codex",
|
||||
busy = false,
|
||||
draft = "",
|
||||
focused = false,
|
||||
children,
|
||||
}: {
|
||||
showProgress?: boolean;
|
||||
/** The composer track shown above the input, such as `ProgressTrack`. It brings its own spacing. */
|
||||
track?: React.ReactNode;
|
||||
/** Which provider's agent this composer talks to. */
|
||||
agent?: ProviderId;
|
||||
busy?: boolean;
|
||||
/** What the user has typed and not yet sent. Empty shows the placeholder. */
|
||||
draft?: string;
|
||||
/** Whether the caret is in the input. */
|
||||
focused?: boolean;
|
||||
/** Something drawn over the input box, such as the pointer that clicks it. */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const provider = PROVIDERS[agent];
|
||||
return (
|
||||
<div className="shrink-0 px-[27px] pt-[16px] pb-[17px]">
|
||||
<div className={showProgress ? "pb-[13px]" : ""}>
|
||||
{showProgress ? (
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<ComposerPill>
|
||||
{t("landing.mockup.app.tasksProgress", { completed: 6, total: 6 })}
|
||||
</ComposerPill>
|
||||
<ComposerPill>
|
||||
<CircleDot size={12} className="text-mock-dot-running" />
|
||||
{t("landing.mockup.app.subagents", { count: 3 })}
|
||||
</ComposerPill>
|
||||
<ComposerPill>
|
||||
<DiffStat add="1.9k" remove="684" />
|
||||
</ComposerPill>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-[11px] border border-mock-border-accent bg-mock-surface1">
|
||||
{track}
|
||||
<div className="relative rounded-[11px] border border-mock-border-accent bg-mock-surface1">
|
||||
{children}
|
||||
<div className="px-[14px] pt-[13px] pb-[12px]">
|
||||
<div className="flex items-start gap-[10px]">
|
||||
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
|
||||
{t("landing.mockup.app.composerPlaceholder")}
|
||||
</span>
|
||||
{draft ? (
|
||||
<span className="min-w-0 flex-1 text-[13px] text-mock-fg">
|
||||
{draft}
|
||||
<Caret className="ml-px" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
|
||||
{/* The caret takes no room, so the placeholder does not move when it appears. */}
|
||||
{focused ? <Caret className="-mr-px" /> : null}
|
||||
{t("landing.mockup.app.composerPlaceholder")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-[17px] pt-[26px]">
|
||||
<Plus size={15} className="text-mock-fg-muted" />
|
||||
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
|
||||
<ControlPill icon={EFFORT_ICON} label="Medium" />
|
||||
<ControlPill icon={ACCESS_ICON} label="Full access" />
|
||||
<ControlPill label={provider.model}>
|
||||
<provider.Icon size={13} />
|
||||
</ControlPill>
|
||||
<ControlPill label="Medium">
|
||||
<Brain size={13} />
|
||||
</ControlPill>
|
||||
<ControlPill label="Full access">
|
||||
<ShieldOff size={13} />
|
||||
</ControlPill>
|
||||
<Zap size={14} className="text-mock-fg-muted" />
|
||||
<ListTodo size={14} className="text-mock-fg-muted" />
|
||||
<span className="flex-1" />
|
||||
@@ -255,3 +403,29 @@ export function Composer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer of a scripted scene. The pointer clicks it, which puts the caret in it. Then the
|
||||
* script types into it and says when a turn is running.
|
||||
*/
|
||||
export function ScriptedComposer({
|
||||
scene,
|
||||
script,
|
||||
agent,
|
||||
track,
|
||||
}: ScriptedProps & { agent: ProviderId; track?: React.ReactNode }) {
|
||||
const click = script.click(COMPOSER);
|
||||
const draft = useSceneClock(scene, script.draft);
|
||||
const busy = useSceneClock(scene, script.busy);
|
||||
const focused = useSceneClock(scene, (ms) => click.pressed(ms) || click.done(ms));
|
||||
return (
|
||||
<Composer agent={agent} track={track} busy={busy} draft={draft} focused={focused}>
|
||||
<Pointer
|
||||
scene={scene}
|
||||
click={click}
|
||||
from={POINTER_TO_COMPOSER}
|
||||
className="top-[22px] left-[168px]"
|
||||
/>
|
||||
</Composer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RefreshCw,
|
||||
Rows3,
|
||||
} from "lucide-react";
|
||||
import { Fragment } from "react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Code, DiffStat } from "./atoms";
|
||||
import { TABBAR_H } from "./geometry";
|
||||
@@ -29,7 +31,7 @@ const SUBHEADER_STYLE = { height: TABBAR_H };
|
||||
export type DiffRow =
|
||||
| { kind: "file"; name: string; path?: string; add: string; remove: string }
|
||||
| { kind: "hunk"; label: string }
|
||||
| { kind: "line"; n: string; text: string; mark?: DiffMark }
|
||||
| { kind: "line"; n: string; text: string; mark?: DiffMark; reviewed?: boolean }
|
||||
| { kind: "scrollbar" }
|
||||
| { kind: "gap" };
|
||||
|
||||
@@ -47,16 +49,35 @@ const NUMBER_COLOR: Record<DiffMark, string> = {
|
||||
remove: "text-mock-danger",
|
||||
};
|
||||
|
||||
function DiffLine({ n, text, mark }: { n: string; text: string; mark?: DiffMark }) {
|
||||
/** What a review adds to the line it comments on. Rows mark that line with `reviewed`. */
|
||||
export interface LineReview {
|
||||
/** Drawn on the line's gutter, such as the button that adds a comment. */
|
||||
action: React.ReactNode;
|
||||
/** Drawn under the line: the thread of comments. */
|
||||
thread: React.ReactNode;
|
||||
}
|
||||
|
||||
function DiffLine({
|
||||
n,
|
||||
text,
|
||||
mark,
|
||||
action,
|
||||
}: {
|
||||
n: string;
|
||||
text: string;
|
||||
mark?: DiffMark;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
const fill = mark ? ROW_FILL[mark] : "";
|
||||
const number = mark ? NUMBER_COLOR[mark] : "text-mock-fg-muted";
|
||||
return (
|
||||
<div className={`flex items-center ${fill}`} style={ROW_STYLE}>
|
||||
<span
|
||||
className={`flex h-full shrink-0 items-center justify-end border-r border-mock-border pr-[10px] font-mono text-[11px] tabular-nums ${number}`}
|
||||
className={`relative flex h-full shrink-0 items-center justify-end border-r border-mock-border pr-[10px] font-mono text-[11px] tabular-nums ${number}`}
|
||||
style={GUTTER_STYLE}
|
||||
>
|
||||
{n}
|
||||
{action}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden whitespace-pre pl-[10px] font-mono text-[12px] leading-[18.5px]">
|
||||
<Code line={text} />
|
||||
@@ -88,7 +109,15 @@ function FileRow({
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffRows({ rows, flush }: { rows: DiffRow[]; flush?: boolean }) {
|
||||
export function DiffRows({
|
||||
rows,
|
||||
flush,
|
||||
review,
|
||||
}: {
|
||||
rows: DiffRow[];
|
||||
flush?: boolean;
|
||||
review?: LineReview;
|
||||
}) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-mock-surface1 ${flush ? "" : "min-h-0 flex-1"}`}>
|
||||
{rows.map((row, index) => {
|
||||
@@ -110,6 +139,14 @@ export function DiffRows({ rows, flush }: { rows: DiffRow[]; flush?: boolean })
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (row.reviewed && review) {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
<DiffLine {...row} action={review.action} />
|
||||
{review.thread}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return <DiffLine key={key} {...row} />;
|
||||
})}
|
||||
{!flush ? (
|
||||
@@ -333,6 +370,7 @@ export const REVIEW_DIFF_ROWS: DiffRow[] = [
|
||||
n: "23",
|
||||
text: 'type ToolKind = "read" | "shell";',
|
||||
mark: "add",
|
||||
reviewed: true,
|
||||
},
|
||||
{ kind: "line", n: "24", text: "" },
|
||||
{
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
* plain px number you can reason about directly, and nothing has to change when
|
||||
* the hero column gets wider or narrower.
|
||||
*
|
||||
* The numbers come from measuring public/homepage-hero.png (a 2x capture of a
|
||||
* 2249x1289 window region) and dividing by 1.5617 so the window lands at 1440
|
||||
* wide — the widest the hero column ever renders it.
|
||||
* The window is 1280 wide, the widest the hero column ever renders it, so on a
|
||||
* wide screen one design pixel is one CSS pixel and the text is as large as the
|
||||
* app's. The type and control sizes come from measuring public/homepage-hero.png
|
||||
* (a 2x capture of a 2249x1289 window region) and dividing by 1.5617.
|
||||
*/
|
||||
export const DESIGN_WIDTH = 1440;
|
||||
export const DESIGN_HEIGHT = 826;
|
||||
export const DESIGN_WIDTH = 1280;
|
||||
export const DESIGN_HEIGHT = 734;
|
||||
|
||||
export const TITLEBAR_H = 39;
|
||||
export const TABBAR_H = 31;
|
||||
export const SIDEBAR_W = 262;
|
||||
export const RIGHT_PANEL_W = 424;
|
||||
// The docked panel is as wide as the file tree needs. A pull request or a plugin panel gets more room.
|
||||
export const RIGHT_PANEL_W = 300;
|
||||
export const RIGHT_PANEL_WIDE_W = 424;
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Computer, Globe, Laptop, Monitor, PcCase, Plus, Server, Terminal, X } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TailscaleMark } from "./icons";
|
||||
|
||||
/**
|
||||
* The machines the mockup's workspaces live on. A host's name is the label its owner gave it, and
|
||||
* its color is the identity color they picked for its badge. The local machine draws no badge, as
|
||||
* a host set to hide its badge does in the app.
|
||||
*/
|
||||
export type RemoteHostId = "devbox" | "mac-mini";
|
||||
|
||||
/** Badge ink per host, as the app's `HostBadge` paints glyph and label in one identity color. */
|
||||
const HOST_TONE: Record<RemoteHostId, string> = {
|
||||
devbox: "text-mock-host-teal",
|
||||
"mac-mini": "text-mock-host-violet",
|
||||
};
|
||||
|
||||
/**
|
||||
* The mark that says which machine something runs on: the server glyph and the host's name in its
|
||||
* identity color. It inherits its text size, so it sits in a sidebar row, a tool call, or a
|
||||
* subagent row alike.
|
||||
*/
|
||||
export function HostBadge({ host, glyph = 12 }: { host: RemoteHostId; glyph?: number }) {
|
||||
return (
|
||||
<span className={`flex shrink-0 items-center gap-[4px] ${HOST_TONE[host]}`}>
|
||||
<Server size={glyph} className="shrink-0" />
|
||||
{host}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- hosts modal --------------------------------------------------------------
|
||||
|
||||
type IconComponent = React.ComponentType<{ size?: number; className?: string }>;
|
||||
|
||||
/** How the app reaches a host. Tailscale is a direct TCP connection to a tailnet name. */
|
||||
type HostLink = "local" | "relay" | "tailscale" | "ssh";
|
||||
|
||||
const LINK_ICON: Record<HostLink, IconComponent> = {
|
||||
local: Monitor,
|
||||
relay: Globe,
|
||||
tailscale: TailscaleMark,
|
||||
ssh: Terminal,
|
||||
};
|
||||
|
||||
interface ConnectedHost {
|
||||
name: string;
|
||||
device: IconComponent;
|
||||
tone: string;
|
||||
link: HostLink;
|
||||
/** The address in use. The local daemon has none worth showing. */
|
||||
endpoint?: string;
|
||||
/** Round trip time, formatted as the app's `formatLatency` prints it. */
|
||||
latency: string;
|
||||
agents: number;
|
||||
}
|
||||
|
||||
const CONNECTED_HOSTS: ConnectedHost[] = [
|
||||
{
|
||||
name: "MacBook Pro",
|
||||
device: Laptop,
|
||||
tone: "text-mock-fg-muted",
|
||||
link: "local",
|
||||
latency: "1ms",
|
||||
agents: 4,
|
||||
},
|
||||
{
|
||||
name: "devbox",
|
||||
device: Server,
|
||||
tone: HOST_TONE.devbox,
|
||||
link: "relay",
|
||||
endpoint: "relay.paseo.sh",
|
||||
latency: "38ms",
|
||||
agents: 6,
|
||||
},
|
||||
{
|
||||
name: "mac-mini",
|
||||
device: Computer,
|
||||
tone: HOST_TONE["mac-mini"],
|
||||
link: "tailscale",
|
||||
endpoint: "mac-mini:6767",
|
||||
latency: "12ms",
|
||||
agents: 3,
|
||||
},
|
||||
{
|
||||
name: "workstation",
|
||||
device: PcCase,
|
||||
tone: "text-mock-host-orange",
|
||||
link: "ssh",
|
||||
endpoint: "mo@workstation",
|
||||
latency: "24ms",
|
||||
agents: 2,
|
||||
},
|
||||
];
|
||||
|
||||
function HostRow({ host }: { host: ConnectedHost }) {
|
||||
const { t } = useTranslation();
|
||||
const Device = host.device;
|
||||
const LinkIcon = LINK_ICON[host.link];
|
||||
return (
|
||||
<div className="flex items-center gap-[13px] border-t border-mock-border px-[18px] py-[13px]">
|
||||
<span
|
||||
className={`relative flex size-[36px] shrink-0 items-center justify-center rounded-[9px] bg-mock-surface2 ${host.tone}`}
|
||||
>
|
||||
<Device size={18} />
|
||||
<span className="absolute -right-[2px] -bottom-[2px] size-[9px] rounded-full bg-mock-dot-success ring-2 ring-mock-surface1" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13.5px] leading-[18px] text-mock-fg">
|
||||
{host.name}
|
||||
</span>
|
||||
<span className="mt-[3px] flex items-center gap-[6px] text-[12px] leading-[16px] text-mock-fg-muted">
|
||||
<LinkIcon size={12} className="shrink-0" />
|
||||
<span className="text-mock-fg">{t(`landing.mockup.app.hostLinks.${host.link}`)}</span>
|
||||
{host.endpoint ? <span className="min-w-0 truncate">{host.endpoint}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-right">
|
||||
<span className="block text-[13px] leading-[18px] text-mock-fg tabular-nums">
|
||||
{host.latency}
|
||||
</span>
|
||||
<span className="mt-[3px] block text-[12px] leading-[16px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.agentCount", { count: host.agents })}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every machine this window is connected to: what kind of machine it is, how the app reaches it,
|
||||
* the round trip time, and how many agents it runs.
|
||||
*/
|
||||
export function HostsModal() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="w-[468px] overflow-hidden rounded-[14px] border border-mock-border-accent bg-mock-surface1 shadow-[0_24px_64px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex h-[46px] items-center justify-between px-[18px]">
|
||||
<span className="text-[14px] font-medium text-mock-fg">
|
||||
{t("landing.mockup.app.hosts")}
|
||||
</span>
|
||||
<X size={15} className="text-mock-fg-muted" />
|
||||
</div>
|
||||
{CONNECTED_HOSTS.map((host) => (
|
||||
<HostRow key={host.name} host={host} />
|
||||
))}
|
||||
<div className="flex h-[46px] items-center gap-[13px] border-t border-mock-border px-[18px] text-[13px] text-mock-fg">
|
||||
{/* The plus sits on the center line of the device tiles above it. */}
|
||||
<span className="flex w-[36px] shrink-0 justify-center">
|
||||
<Plus size={15} className="text-mock-fg-muted" />
|
||||
</span>
|
||||
{t("landing.mockup.app.addHost")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// Exact SVG paths from packages/app/src/components/icons/
|
||||
|
||||
import { OpenCodeIcon as OpenCodeBrandIcon, PiIcon as PiBrandIcon } from "../agent-icons";
|
||||
|
||||
/** The Paseo brand swirl — public/logo.svg, drawn as a small sidebar avatar mark. */
|
||||
export function PaseoMark({ size = 13, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
@@ -112,3 +114,65 @@ export function SourceControlIcon({ size = 14, className }: { size?: number; cla
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenCodeIcon({ size = 13, className }: { size?: number; className?: string }) {
|
||||
return <OpenCodeBrandIcon width={size} height={size} className={className} />;
|
||||
}
|
||||
|
||||
export function PiIcon({ size = 13, className }: { size?: number; className?: string }) {
|
||||
return <PiBrandIcon width={size} height={size} className={className} />;
|
||||
}
|
||||
|
||||
const TAILSCALE_DOTS = [3, 12, 21].flatMap((cy, row) =>
|
||||
[3, 12, 21].map((cx, column) => ({
|
||||
cx,
|
||||
cy,
|
||||
// The mark is a T: the middle row and the dot under its center are solid.
|
||||
solid: row === 1 || (row === 2 && column === 1),
|
||||
})),
|
||||
);
|
||||
|
||||
/** The Tailscale mark: a three by three dot grid with a solid T. */
|
||||
export function TailscaleMark({ size = 12, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
{TAILSCALE_DOTS.map((dot) => (
|
||||
<circle
|
||||
key={`${dot.cx}:${dot.cy}`}
|
||||
cx={dot.cx}
|
||||
cy={dot.cy}
|
||||
r={3}
|
||||
opacity={dot.solid ? 1 : 0.3}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** A mouse pointer. Its tip is the top left corner of the glyph. */
|
||||
export function PointerArrow({ size = 24, className }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
className={`drop-shadow-[0_1px_2px_rgba(0,0,0,0.5)] ${className ?? ""}`}
|
||||
>
|
||||
<path
|
||||
d="M1 1v14.6l3.9-3.7 2.7 6.1 2.7-1.2-2.7-6h5.5z"
|
||||
fill="#0b0b0b"
|
||||
stroke="#fff"
|
||||
strokeWidth={1.2}
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
|
||||
export { type ToolCall, ToolCallRow } from "./chat";
|
||||
export { ToolCallRow } from "./chat";
|
||||
export type { ToolCall } from "./script";
|
||||
export { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
|
||||
export { PhoneFrame } from "./mobile/frame";
|
||||
export { MobileChat } from "./mobile/chat";
|
||||
export { MobileDiff } from "./mobile/diff";
|
||||
export { MobileSidebar } from "./mobile/sidebar";
|
||||
export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states";
|
||||
export { MockupWindow } from "./window";
|
||||
export { MOCKUP_STATES, type MockupStateId } from "./states";
|
||||
export { createMockupPlayer, MockupWindow, useMockupScene } from "./window";
|
||||
export { ScaledMockup } from "./scaled-mockup";
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RemoteHostId } from "./hosts";
|
||||
import { PROVIDERS, type ProviderId } from "./providers";
|
||||
import { COMPOSER, script, type ToolCall } from "./script";
|
||||
|
||||
/**
|
||||
* The work the orchestrating agent hands out in the orchestration scene. The sidebar draws one
|
||||
* workspace per task and the chat draws the tool calls and the subagent rows from the same list.
|
||||
*/
|
||||
export const DELEGATED_TASKS: {
|
||||
id: string;
|
||||
title: string;
|
||||
branch: string;
|
||||
provider: Exclude<ProviderId, "claude">;
|
||||
host?: RemoteHostId;
|
||||
}[] = [
|
||||
{
|
||||
id: "issue-412",
|
||||
title: "Fix #412: relay handshake race",
|
||||
branch: "fix/relay-handshake-race",
|
||||
provider: "codex",
|
||||
},
|
||||
{
|
||||
id: "issue-417",
|
||||
title: "Fix #417: menu bar icon on macOS",
|
||||
branch: "fix/menu-bar-icon",
|
||||
provider: "opencode",
|
||||
host: "mac-mini",
|
||||
},
|
||||
{
|
||||
id: "issue-421",
|
||||
title: "Fix #421: stale Docker setup docs",
|
||||
branch: "docs/docker-setup",
|
||||
provider: "pi",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The orchestrator's Paseo tool calls, one per delegated task. One call creates the worktree
|
||||
* workspace and starts the agent in it.
|
||||
*/
|
||||
const DELEGATION_CALLS: ToolCall[] = DELEGATED_TASKS.map((task) => ({
|
||||
kind: "paseo",
|
||||
label: "Create agent",
|
||||
detail: `${PROVIDERS[task.provider].name} · ${task.branch}`,
|
||||
host: task.host,
|
||||
}));
|
||||
|
||||
/**
|
||||
* What the orchestrator looks up before it delegates: the issues to fix, and the agent profiles
|
||||
* the user set up, which say which model takes which kind of work.
|
||||
*/
|
||||
const LOOKUP_CALLS: ToolCall[] = [
|
||||
{ kind: "shell", detail: "gh issue list --label ready" },
|
||||
{ kind: "paseo", label: "List agent profiles" },
|
||||
];
|
||||
|
||||
const REQUEST = "Fix the issues labeled ready, one worktree each.";
|
||||
|
||||
/**
|
||||
* The orchestration scene: the user asks Claude to fix the ready issues, each in a worktree. Claude
|
||||
* lists the issues and the user's agent profiles, picks a model for each issue, and puts the one
|
||||
* that needs macOS on another machine. It says that Paseo will notify it when the agents finish.
|
||||
* Then the pointer opens the subagents roster.
|
||||
*/
|
||||
export const ORCHESTRATION = script([
|
||||
{ kind: "wait", ms: 200 },
|
||||
{ kind: "click", target: COMPOSER },
|
||||
{ kind: "wait", ms: 250 },
|
||||
{ kind: "type", text: REQUEST },
|
||||
{ kind: "user", text: REQUEST },
|
||||
{ kind: "tools", calls: LOOKUP_CALLS },
|
||||
{
|
||||
kind: "agent",
|
||||
text: `I loaded your orchestration preferences: ${PROVIDERS.codex.model} for the hard bug, ${PROVIDERS.opencode.model} for UI, ${PROVIDERS.pi.model} for docs.`,
|
||||
},
|
||||
{
|
||||
kind: "agent",
|
||||
text: "I will launch the UI agent on your mac-mini, because that issue is specific to macOS.",
|
||||
},
|
||||
{ kind: "tools", calls: DELEGATION_CALLS },
|
||||
{
|
||||
kind: "agent",
|
||||
text: "All three are launched. Paseo will notify me when each one finishes.",
|
||||
},
|
||||
// No footer: with it, the transcript and the open roster do not both fit in the pane.
|
||||
{ kind: "turnEnd" },
|
||||
{ kind: "click", target: "subagents" },
|
||||
]);
|
||||
|
||||
/** The pointer's click on the subagents pill. It opens the roster of subagents. */
|
||||
export const SUBAGENTS_CLICK = ORCHESTRATION.click("subagents");
|
||||
|
||||
/**
|
||||
* How many tasks the orchestrator delegated so far. Each one adds a workspace to the sidebar and a
|
||||
* subagent to the composer's pill.
|
||||
*/
|
||||
export function tasksDelegated(ms: number): number {
|
||||
const delegation = ORCHESTRATION.entries.find(
|
||||
(entry) => entry.beat.kind === "tools" && entry.beat.calls === DELEGATION_CALLS,
|
||||
);
|
||||
return delegation ? ORCHESTRATION.landed(delegation, ms) : 0;
|
||||
}
|
||||
@@ -1,15 +1,86 @@
|
||||
import { DiffRows, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
|
||||
import { DiffRows, type LineReview, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
|
||||
import { AddCommentAction, ReviewFeedback, ReviewThread } from "./review-thread";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// --- review -----------------------------------------------------------------
|
||||
|
||||
/** The selected Changes-tree file, open in its own workspace tab. */
|
||||
const LINE_REVIEW: LineReview = { action: <AddCommentAction />, thread: <ReviewThread /> };
|
||||
|
||||
/**
|
||||
* The selected Changes-tree file, open in its own workspace tab. The review scene comments on one
|
||||
* of its lines and sends that feedback to an agent.
|
||||
*/
|
||||
export function DiffReviewPane() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
<ScopeRow label={t("landing.mockup.app.uncommitted")} />
|
||||
<DiffRows rows={REVIEW_DIFF_ROWS} />
|
||||
<DiffRows rows={REVIEW_DIFF_ROWS} review={LINE_REVIEW} />
|
||||
<ReviewFeedback />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- remote -----------------------------------------------------------------
|
||||
|
||||
function ShellPrompt() {
|
||||
return (
|
||||
<>
|
||||
<span className="text-mock-term-green">mo@devbox</span>:
|
||||
<span className="text-mock-term-blue">~/dev/paseo</span>${" "}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ViteArrow() {
|
||||
return <span className="text-mock-term-green">{" ➜ "}</span>;
|
||||
}
|
||||
|
||||
function HmrLine({ time, file }: { time: string; file: string }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-mock-fg-xmuted">{time}</span>{" "}
|
||||
<span className="text-mock-term-cyan">[vite]</span>{" "}
|
||||
<span className="text-mock-term-green">hmr update</span>{" "}
|
||||
<span className="text-mock-fg-muted">{file}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace's terminal tab. The workspace lives on devbox, so the shell does too: the prompt
|
||||
* names the remote machine while the pane sits in the same window as every local workspace.
|
||||
*/
|
||||
export function RemoteTerminalPane() {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-hidden whitespace-pre bg-mock-surface0 px-[16px] py-[12px] font-mono text-[12px] leading-[18.5px] text-mock-fg">
|
||||
<div>
|
||||
<ShellPrompt />
|
||||
npm run dev
|
||||
</div>
|
||||
<div> </div>
|
||||
<div className="text-mock-fg-muted">> @getpaseo/website dev</div>
|
||||
<div className="text-mock-fg-muted">> vite</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
{" "}
|
||||
<span className="text-mock-term-green">VITE v7.3.3</span>
|
||||
<span className="text-mock-fg-muted">{" ready in "}</span>412 ms
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
<ViteArrow />
|
||||
{"Local: "}
|
||||
<span className="text-mock-term-cyan">http://localhost:3000/</span>
|
||||
</div>
|
||||
<div className="text-mock-fg-muted">
|
||||
<ViteArrow />
|
||||
press h + enter to show help
|
||||
</div>
|
||||
<div> </div>
|
||||
<HmrLine time="6:53:12 PM" file="/src/components/hero-mockup.tsx" />
|
||||
<HmrLine time="6:53:40 PM" file="/src/components/mockup/window.tsx" />
|
||||
<HmrLine time="6:54:07 PM" file="/src/styles.css" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createScenePlayer, type Frames } from "./player";
|
||||
|
||||
/** A page clock that moves only when the test turns it. */
|
||||
function handClock() {
|
||||
let time = 0;
|
||||
let nextId = 0;
|
||||
const waiting = new Map<number, (now: number) => void>();
|
||||
const frames: Frames = {
|
||||
now: () => time,
|
||||
request(callback) {
|
||||
nextId += 1;
|
||||
waiting.set(nextId, callback);
|
||||
return nextId;
|
||||
},
|
||||
cancel: (id) => waiting.delete(id),
|
||||
reducedMotion: () => false,
|
||||
};
|
||||
return {
|
||||
frames,
|
||||
/** Moves the clock on and runs the frames that were waiting for it. */
|
||||
advance(ms: number) {
|
||||
time += ms;
|
||||
const due = [...waiting.values()];
|
||||
waiting.clear();
|
||||
for (const callback of due) callback(time);
|
||||
},
|
||||
framesWaiting: () => waiting.size,
|
||||
};
|
||||
}
|
||||
|
||||
function setUp() {
|
||||
const clock = handClock();
|
||||
const player = createScenePlayer("orchestrate", { orchestrate: 1000, review: 500 }, clock.frames);
|
||||
return { clock, player };
|
||||
}
|
||||
|
||||
describe("scene player", () => {
|
||||
it("plays the opening scene from the start when the page starts it", () => {
|
||||
const { clock, player } = setUp();
|
||||
player.start();
|
||||
clock.advance(16);
|
||||
clock.advance(16);
|
||||
expect(player.elapsed("orchestrate")).toBe(32);
|
||||
});
|
||||
|
||||
it("keeps the place of a scene that a pill click started before the page did", () => {
|
||||
const { clock, player } = setUp();
|
||||
player.select("review");
|
||||
clock.advance(16);
|
||||
clock.advance(16);
|
||||
expect(player.elapsed("review")).toBe(32);
|
||||
|
||||
player.start();
|
||||
expect(player.scene()).toBe("review");
|
||||
expect(player.elapsed("review")).toBe(32);
|
||||
expect(player.elapsed("orchestrate")).toBe(0);
|
||||
});
|
||||
|
||||
it("stops the clock", () => {
|
||||
const { clock, player } = setUp();
|
||||
player.start();
|
||||
clock.advance(16);
|
||||
expect(clock.framesWaiting()).toBe(1);
|
||||
|
||||
player.stop();
|
||||
expect(clock.framesWaiting()).toBe(0);
|
||||
clock.advance(16);
|
||||
expect(player.elapsed("orchestrate")).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { createContext, useContext, useSyncExternalStore } from "react";
|
||||
import type { MockupStateId } from "./states";
|
||||
|
||||
/**
|
||||
* Which scene the mockup shows and how far into its script each scene is. The player owns the
|
||||
* clock. Components read the time through `useSceneClock` and never keep time themselves.
|
||||
*/
|
||||
export interface ScenePlayer {
|
||||
scene(): MockupStateId;
|
||||
/** Milliseconds into a scene's script. Each scene keeps its own, so a scene that is fading out holds its last frame. */
|
||||
elapsed(scene: MockupStateId): number;
|
||||
/** Shows a scene and plays its script from the start. */
|
||||
select(scene: MockupStateId): void;
|
||||
/**
|
||||
* Plays the opening scene from the start, unless a scene has played already. A pill click can
|
||||
* come before the page decides to start, and that scene keeps its place.
|
||||
*/
|
||||
start(): void;
|
||||
/** Stops the clock. A scene that is playing holds its last frame. */
|
||||
stop(): void;
|
||||
subscribe(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
// A background tab stops animation frames. On return the script resumes where it paused.
|
||||
const LONGEST_FRAME_MS = 64;
|
||||
|
||||
/** What the player needs from the page to keep time. Tests pass a clock they turn by hand. */
|
||||
export interface Frames {
|
||||
now(): number;
|
||||
request(callback: (now: number) => void): number;
|
||||
cancel(id: number): void;
|
||||
reducedMotion(): boolean;
|
||||
}
|
||||
|
||||
const browserFrames: Frames = {
|
||||
now: () => performance.now(),
|
||||
request: (callback) => requestAnimationFrame(callback),
|
||||
cancel: (id) => cancelAnimationFrame(id),
|
||||
// Visitors who ask for reduced motion get the finished scene.
|
||||
reducedMotion: () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param durations How long each scene's script runs. A scene without a script has no entry and is
|
||||
* always at its finished state.
|
||||
*/
|
||||
export function createScenePlayer(
|
||||
initial: MockupStateId,
|
||||
durations: Partial<Record<MockupStateId, number>>,
|
||||
frames: Frames = browserFrames,
|
||||
): ScenePlayer {
|
||||
let scene = initial;
|
||||
const times = new Map<MockupStateId, number>();
|
||||
const listeners = new Set<() => void>();
|
||||
let frame = 0;
|
||||
let lastFrameAt = 0;
|
||||
let played = false;
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (frame) frames.cancel(frame);
|
||||
frame = 0;
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
const end = durations[scene] ?? 0;
|
||||
const next = Math.min(
|
||||
end,
|
||||
(times.get(scene) ?? 0) + Math.min(now - lastFrameAt, LONGEST_FRAME_MS),
|
||||
);
|
||||
lastFrameAt = now;
|
||||
times.set(scene, next);
|
||||
frame = next < end ? frames.request(tick) : 0;
|
||||
emit();
|
||||
}
|
||||
|
||||
function play() {
|
||||
played = true;
|
||||
stop();
|
||||
const end = durations[scene] ?? 0;
|
||||
const animated = end > 0 && !frames.reducedMotion();
|
||||
times.set(scene, animated ? 0 : end);
|
||||
if (animated) {
|
||||
lastFrameAt = frames.now();
|
||||
frame = frames.request(tick);
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
return {
|
||||
scene: () => scene,
|
||||
elapsed: (of) => times.get(of) ?? 0,
|
||||
select(next) {
|
||||
scene = next;
|
||||
play();
|
||||
},
|
||||
start() {
|
||||
if (!played) play();
|
||||
},
|
||||
stop,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ScenePlayerContext = createContext<ScenePlayer | null>(null);
|
||||
|
||||
/** The scene a player shows. */
|
||||
export function usePlayerScene(player: ScenePlayer): MockupStateId {
|
||||
return useSyncExternalStore(player.subscribe, player.scene, player.scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one fact about a scene at its current time, such as how many tool calls have landed.
|
||||
* The component renders again only when that fact changes, so `select` must return a primitive.
|
||||
*/
|
||||
export function useSceneClock<Fact extends string | number | boolean>(
|
||||
scene: MockupStateId,
|
||||
select: (ms: number) => Fact,
|
||||
): Fact {
|
||||
const player = useContext(ScenePlayerContext);
|
||||
if (!player) throw new Error("useSceneClock needs a ScenePlayerContext");
|
||||
const read = () => select(player.elapsed(scene));
|
||||
return useSyncExternalStore(player.subscribe, read, read);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { COMPOSER, script } from "./script";
|
||||
|
||||
const BUILD_REQUEST = "Build a release radar panel for merged PRs and failing checks.";
|
||||
const CHANGE_REQUEST = "Show which check is failing.";
|
||||
|
||||
/**
|
||||
* The plugins scene: the user asks for a panel, the agent builds and installs a plugin, and the
|
||||
* panel opens. Then the user asks for a change and the open panel shows it.
|
||||
*/
|
||||
export const PLUGIN_BUILD = script([
|
||||
{ kind: "wait", ms: 200 },
|
||||
{ kind: "click", target: COMPOSER },
|
||||
{ kind: "wait", ms: 250 },
|
||||
{ kind: "type", text: BUILD_REQUEST },
|
||||
{ kind: "user", text: BUILD_REQUEST },
|
||||
{
|
||||
kind: "agent",
|
||||
text: "I’ll scaffold a local plugin, add the explorer surface, and wire it into the sidebar.",
|
||||
},
|
||||
{
|
||||
kind: "tools",
|
||||
calls: [
|
||||
{ kind: "read", detail: "paseo-plugin/SKILL.md" },
|
||||
{ kind: "shell", detail: "paseo plugin init ~/dev/release-radar" },
|
||||
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
|
||||
{ kind: "shell", detail: "paseo plugin install ~/dev/release-radar" },
|
||||
],
|
||||
},
|
||||
{ kind: "mark", name: "installed", ms: 500 },
|
||||
{ kind: "agent", text: "Release Radar is live. It should now be visible in the right sidebar." },
|
||||
{ kind: "turnEnd", worked: "2m 14s" },
|
||||
{ kind: "wait", ms: 900 },
|
||||
{ kind: "type", text: CHANGE_REQUEST },
|
||||
{ kind: "user", text: CHANGE_REQUEST },
|
||||
{
|
||||
kind: "tools",
|
||||
calls: [
|
||||
{ kind: "write", detail: "~/dev/release-radar/release-radar.client.tsx" },
|
||||
{ kind: "shell", detail: "paseo plugin reload release-radar" },
|
||||
],
|
||||
},
|
||||
{ kind: "mark", name: "revised", ms: 500 },
|
||||
{ kind: "agent", text: "Reloaded. The radar now lists the failing check under the tiles." },
|
||||
{ kind: "turnEnd", worked: "19s" },
|
||||
]);
|
||||
|
||||
/** Whether the plugin is installed: its panel is open and its item is in the sidebar. */
|
||||
export function pluginInstalled(ms: number): boolean {
|
||||
return PLUGIN_BUILD.reached("installed", ms);
|
||||
}
|
||||
|
||||
/** Whether the reloaded plugin lists the failing check. */
|
||||
export function failingCheckListed(ms: number): boolean {
|
||||
return PLUGIN_BUILD.reached("revised", ms);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import { PointerArrow } from "./icons";
|
||||
import { useSceneClock } from "./player";
|
||||
import type { PointerClick } from "./script";
|
||||
import type { MockupStateId } from "./states";
|
||||
|
||||
/**
|
||||
* The visitor's stand-in: a mouse pointer that glides to a spot and clicks it. The spot is where
|
||||
* `className` places the pointer in its nearest positioned ancestor.
|
||||
*/
|
||||
export function Pointer({
|
||||
scene,
|
||||
click,
|
||||
from,
|
||||
className,
|
||||
}: {
|
||||
scene: MockupStateId;
|
||||
click: PointerClick;
|
||||
/** Where the pointer sets off from, in design pixels from the spot it clicks. */
|
||||
from: { x: number; y: number };
|
||||
className: string;
|
||||
}) {
|
||||
const opacity = useSceneClock(scene, click.opacity);
|
||||
const approach = useSceneClock(scene, click.approach);
|
||||
const pressed = useSceneClock(scene, click.pressed);
|
||||
const style = useMemo(
|
||||
() => ({
|
||||
opacity,
|
||||
transform: `translate(${(1 - approach) * from.x}px, ${(1 - approach) * from.y}px)`,
|
||||
}),
|
||||
[opacity, approach, from],
|
||||
);
|
||||
if (opacity === 0) return null;
|
||||
return (
|
||||
<span
|
||||
// The arrow's tip is the glyph's top left corner, so that corner is what lands on the spot.
|
||||
className={`pointer-events-none absolute z-10 ${className}`}
|
||||
style={style}
|
||||
>
|
||||
<PointerArrow
|
||||
className={`origin-top-left transition-[scale] duration-100 ${pressed ? "scale-[0.86]" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ClaudeIcon, CodexIcon, OpenCodeIcon, PiIcon } from "./icons";
|
||||
|
||||
/** Each agent provider the mockup shows: its name, its mark, and the model it runs. */
|
||||
export const PROVIDERS = {
|
||||
claude: { name: "Claude Code", Icon: ClaudeIcon, model: "Opus 5.5" },
|
||||
codex: { name: "Codex", Icon: CodexIcon, model: "GPT-5.6-Sol" },
|
||||
opencode: { name: "OpenCode", Icon: OpenCodeIcon, model: "Kimi K3" },
|
||||
pi: { name: "Pi", Icon: PiIcon, model: "GLM-5.2" },
|
||||
};
|
||||
|
||||
export type ProviderId = keyof typeof PROVIDERS;
|
||||
@@ -0,0 +1,231 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CircleCheck, Pencil, Plus, Send, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Caret } from "./atoms";
|
||||
import { FADE_IN, FADE_OUT, MOCKUP_TRANSITION } from "./motion";
|
||||
import { useSceneClock } from "./player";
|
||||
import { Pointer } from "./pointer";
|
||||
import { PROVIDERS, type ProviderId } from "./providers";
|
||||
import {
|
||||
ADD_COMMENT_CLICK,
|
||||
lineHovered,
|
||||
RECIPIENT_CLICK,
|
||||
recipientHovered,
|
||||
REVIEW,
|
||||
REVIEW_COMMENT,
|
||||
reviewPhase,
|
||||
SEND_FEEDBACK_CLICK,
|
||||
} from "./review";
|
||||
|
||||
// The review scene, drawn like the app's review module (`packages/app/src/review`): the add
|
||||
// button of a hovered line, the inline editor, the saved comment, and Send feedback.
|
||||
|
||||
// --- on the line ------------------------------------------------------------
|
||||
|
||||
const POINTER_TO_LINE = { x: 240, y: 150 };
|
||||
|
||||
/**
|
||||
* The add button a hovered line shows across its gutter's edge, and the pointer that clicks it.
|
||||
* Goes in the line's gutter.
|
||||
*/
|
||||
export function AddCommentAction() {
|
||||
const hovered = useSceneClock("review", lineHovered);
|
||||
const pressed = useSceneClock("review", ADD_COMMENT_CLICK.pressed);
|
||||
return (
|
||||
<span className="absolute top-1/2 -right-[10px] z-10 size-[20px] -translate-y-1/2">
|
||||
{hovered ? (
|
||||
<span
|
||||
className={`flex size-full items-center justify-center rounded-[5px] bg-mock-accent text-white ${
|
||||
pressed ? "brightness-90" : ""
|
||||
}`}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.4} />
|
||||
</span>
|
||||
) : null}
|
||||
<Pointer
|
||||
scene="review"
|
||||
click={ADD_COMMENT_CLICK}
|
||||
from={POINTER_TO_LINE}
|
||||
className="top-1/2 left-1/2"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentEditor() {
|
||||
const { t } = useTranslation();
|
||||
const body = useSceneClock("review", REVIEW.draft);
|
||||
return (
|
||||
<div className="flex h-[94px] flex-col gap-[10px] rounded-[8px] border border-mock-border-accent bg-mock-surface2 p-[10px]">
|
||||
<div className="flex h-[34px] shrink-0 items-center rounded-[6px] border border-mock-accent bg-mock-surface1 px-[11px] text-[13px]">
|
||||
{body ? (
|
||||
<span className="text-mock-fg">
|
||||
{body}
|
||||
<Caret className="ml-px" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-mock-fg-muted">
|
||||
<Caret className="-mr-px" />
|
||||
{t("landing.mockup.app.reviewCommentPlaceholder")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-[8px] text-[12px] leading-[18px]">
|
||||
<span className="px-[10px] py-[5px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.reviewCancel")}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-[6px] bg-mock-accent px-[11px] py-[5px] text-white ${
|
||||
body ? "" : "opacity-50"
|
||||
}`}
|
||||
>
|
||||
{t("landing.mockup.app.reviewSave")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SavedComment() {
|
||||
return (
|
||||
<div className="flex h-[42px] items-center gap-[12px] rounded-[8px] border border-mock-border-accent bg-mock-surface2 px-[12px]">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-mock-fg">{REVIEW_COMMENT}</span>
|
||||
<Pencil size={13} className="shrink-0 text-mock-fg-muted" />
|
||||
<Trash2 size={13} className="shrink-0 text-mock-destructive" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The thread is as tall as what it holds plus 8 of padding above and below.
|
||||
const THREAD_CLOSED = { height: 0, opacity: 0 };
|
||||
const THREAD_WRITING = { height: 110, opacity: 1 };
|
||||
const THREAD_COMMENTED = { height: 58, opacity: 1 };
|
||||
|
||||
/** The comment thread under the line: the editor first, then the saved comment until it is sent. */
|
||||
export function ReviewThread() {
|
||||
const phase = useSceneClock("review", reviewPhase);
|
||||
const writing = phase === "writing";
|
||||
const open = writing || phase === "commented" || phase === "choosing";
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={THREAD_CLOSED}
|
||||
animate={writing ? THREAD_WRITING : THREAD_COMMENTED}
|
||||
exit={THREAD_CLOSED}
|
||||
transition={MOCKUP_TRANSITION}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-[12px] py-[8px]">{writing ? <CommentEditor /> : <SavedComment />}</div>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// --- over the diff ----------------------------------------------------------
|
||||
|
||||
function RecipientRow({ agent, lit = false }: { agent: ProviderId; lit?: boolean }) {
|
||||
const provider = PROVIDERS[agent];
|
||||
return (
|
||||
<span
|
||||
className={`flex h-[30px] items-center gap-[8px] rounded-[6px] px-[9px] text-[13px] whitespace-nowrap text-mock-fg ${
|
||||
lit ? "bg-mock-surface2" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-mock-fg-muted">
|
||||
<provider.Icon size={13} />
|
||||
</span>
|
||||
{provider.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Who gets the feedback. The workspace has two agent tabs, so Send feedback asks which one. */
|
||||
const RECIPIENT: ProviderId = "codex";
|
||||
|
||||
// Where the pointer sets off for Send feedback, and how far the picked agent's row is from there.
|
||||
// The button is 32 high. The list floats 6 above it, with 4 of padding around two rows of 30.
|
||||
const POINTER_TO_SEND = { x: -190, y: -170 };
|
||||
const POINTER_TO_RECIPIENT = { x: -6, y: 73 };
|
||||
|
||||
const FLOATING = "pointer-events-none absolute inset-x-0 bottom-[16px] flex justify-center";
|
||||
const FLOATING_SHADOW = "shadow-[0_8px_24px_rgba(0,0,0,0.4)]";
|
||||
|
||||
/**
|
||||
* What floats over the bottom of the diff: Send feedback while a comment waits, the list of
|
||||
* agents it opens, and the toast that says who got the feedback.
|
||||
*/
|
||||
export function ReviewFeedback() {
|
||||
const { t } = useTranslation();
|
||||
const phase = useSceneClock("review", reviewPhase);
|
||||
const pressed = useSceneClock("review", SEND_FEEDBACK_CLICK.pressed);
|
||||
const recipientLit = useSceneClock("review", recipientHovered);
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence initial={false}>
|
||||
{phase === "commented" || phase === "choosing" ? (
|
||||
<motion.div
|
||||
key="send"
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
className={FLOATING}
|
||||
>
|
||||
<span className="relative">
|
||||
{phase === "choosing" ? (
|
||||
<motion.span
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
className={`absolute bottom-full left-1/2 mb-[6px] block -translate-x-1/2 rounded-[9px] border border-mock-border-accent bg-mock-surface1 p-[4px] ${FLOATING_SHADOW}`}
|
||||
>
|
||||
<RecipientRow agent={RECIPIENT} lit={recipientLit} />
|
||||
<RecipientRow agent="claude" />
|
||||
</motion.span>
|
||||
) : null}
|
||||
<span
|
||||
className={`flex h-[32px] items-center gap-[7px] rounded-[8px] bg-mock-accent px-[13px] text-[13px] whitespace-nowrap text-white ${FLOATING_SHADOW} ${
|
||||
pressed ? "brightness-90" : ""
|
||||
}`}
|
||||
>
|
||||
<Send size={13} />
|
||||
{t("landing.mockup.app.sendFeedback", { count: 1 })}
|
||||
</span>
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
{phase === "sent" ? (
|
||||
<motion.div
|
||||
key="sent"
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
className={FLOATING}
|
||||
>
|
||||
<span
|
||||
className={`flex items-center gap-[8px] rounded-[14px] border border-mock-border-accent bg-mock-surface0 px-[12px] py-[8px] text-[13px] whitespace-nowrap text-mock-fg ${FLOATING_SHADOW}`}
|
||||
>
|
||||
<CircleCheck size={16} className="text-mock-accent-bright" />
|
||||
{t("landing.mockup.app.feedbackSent", { recipient: PROVIDERS[RECIPIENT].name })}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{/* Both spots are measured from the middle of the button's bottom edge. */}
|
||||
<span className="absolute bottom-[16px] left-1/2">
|
||||
<Pointer
|
||||
scene="review"
|
||||
click={SEND_FEEDBACK_CLICK}
|
||||
from={POINTER_TO_SEND}
|
||||
className="-top-[14px] left-[18px]"
|
||||
/>
|
||||
<Pointer
|
||||
scene="review"
|
||||
click={RECIPIENT_CLICK}
|
||||
from={POINTER_TO_RECIPIENT}
|
||||
className="-top-[87px] left-[24px]"
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { script } from "./script";
|
||||
|
||||
/** What the reviewer writes on the line. */
|
||||
export const REVIEW_COMMENT = "Add “write”. The agent edits files too.";
|
||||
|
||||
/**
|
||||
* The code review scene: the pointer adds a comment on a line of the diff and the user types it.
|
||||
* Then the pointer sends the feedback and picks the agent that gets it.
|
||||
*/
|
||||
export const REVIEW = script([
|
||||
{ kind: "wait", ms: 300 },
|
||||
{ kind: "click", target: "addComment" },
|
||||
{ kind: "wait", ms: 250 },
|
||||
{ kind: "type", text: REVIEW_COMMENT },
|
||||
{ kind: "mark", name: "saved", ms: 450 },
|
||||
{ kind: "click", target: "sendFeedback" },
|
||||
{ kind: "click", target: "recipient" },
|
||||
]);
|
||||
|
||||
/** The click on the add button of the line. It opens the comment editor. */
|
||||
export const ADD_COMMENT_CLICK = REVIEW.click("addComment");
|
||||
/** The click on Send feedback. It opens the list of agents. */
|
||||
export const SEND_FEEDBACK_CLICK = REVIEW.click("sendFeedback");
|
||||
/** The click on the agent that gets the feedback. */
|
||||
export const RECIPIENT_CLICK = REVIEW.click("recipient");
|
||||
|
||||
/**
|
||||
* Where the review stands.
|
||||
*
|
||||
* - `reading`: no comment yet.
|
||||
* - `writing`: the comment editor is open.
|
||||
* - `commented`: the comment is saved and waits to be sent.
|
||||
* - `choosing`: the list of agents is open.
|
||||
* - `sent`: an agent has the feedback, and the saved comment is gone.
|
||||
*/
|
||||
export type ReviewPhase = "reading" | "writing" | "commented" | "choosing" | "sent";
|
||||
|
||||
export function reviewPhase(ms: number): ReviewPhase {
|
||||
if (RECIPIENT_CLICK.done(ms)) return "sent";
|
||||
if (SEND_FEEDBACK_CLICK.done(ms)) return "choosing";
|
||||
if (REVIEW.reached("saved", ms)) return "commented";
|
||||
if (ADD_COMMENT_CLICK.done(ms)) return "writing";
|
||||
return "reading";
|
||||
}
|
||||
|
||||
/** Whether the pointer is over the line. A hovered line shows its add button. */
|
||||
export function lineHovered(ms: number): boolean {
|
||||
return ADD_COMMENT_CLICK.approach(ms) > 0.93 && !ADD_COMMENT_CLICK.done(ms);
|
||||
}
|
||||
|
||||
/** Whether the pointer is over the agent it is about to pick. */
|
||||
export function recipientHovered(ms: number): boolean {
|
||||
return RECIPIENT_CLICK.approach(ms) > 0.8;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
FileDiff,
|
||||
@@ -14,12 +15,26 @@ import type { TFunction } from "i18next";
|
||||
import type * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangesBranchRow, ChangesCommitsRow, ChangesScopeRow, ExplorerChangesTree } from "./diff";
|
||||
import { RIGHT_PANEL_W, TABBAR_H, TITLEBAR_H } from "./geometry";
|
||||
import type { MockupStateId } from "./states";
|
||||
import { RIGHT_PANEL_W, RIGHT_PANEL_WIDE_W, TABBAR_H, TITLEBAR_H } from "./geometry";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useSceneClock } from "./player";
|
||||
import { failingCheckListed } from "./plugin-build";
|
||||
|
||||
const TABBAR_STYLE = { height: TITLEBAR_H };
|
||||
const SUBHEADER_STYLE = { height: TABBAR_H };
|
||||
const PANEL_STYLE = { width: RIGHT_PANEL_W };
|
||||
|
||||
export type PanelId = "changes" | "ship" | "plugin";
|
||||
|
||||
/** How wide the docked panel is for what it shows. Only the file tree fits the narrow width. */
|
||||
export function panelWidth(panel: PanelId): number {
|
||||
return panel === "changes" ? RIGHT_PANEL_W : RIGHT_PANEL_WIDE_W;
|
||||
}
|
||||
|
||||
const PANEL_STYLES: Record<PanelId, { width: number }> = {
|
||||
changes: { width: panelWidth("changes") },
|
||||
ship: { width: panelWidth("ship") },
|
||||
plugin: { width: panelWidth("plugin") },
|
||||
};
|
||||
|
||||
interface PanelTab {
|
||||
label: string;
|
||||
@@ -52,10 +67,12 @@ function PanelTabs({ tabs, active }: { tabs: PanelTab[]; active: number }) {
|
||||
}
|
||||
|
||||
function PanelShell({
|
||||
panel,
|
||||
tabs,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
panel: PanelId;
|
||||
tabs: PanelTab[];
|
||||
active: number;
|
||||
children: React.ReactNode;
|
||||
@@ -63,7 +80,7 @@ function PanelShell({
|
||||
return (
|
||||
<div
|
||||
className="flex h-full shrink-0 flex-col overflow-hidden bg-mock-sidebar"
|
||||
style={PANEL_STYLE}
|
||||
style={PANEL_STYLES[panel]}
|
||||
>
|
||||
<PanelTabs tabs={tabs} active={active} />
|
||||
{children}
|
||||
@@ -88,7 +105,7 @@ function changesTabs(t: TFunction): PanelTab[] {
|
||||
function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={changesTabs(t)} active={1}>
|
||||
<PanelShell panel="changes" tabs={changesTabs(t)} active={1}>
|
||||
<ChangesBranchRow />
|
||||
<ChangesScopeRow />
|
||||
<ExplorerChangesTree selectedFile={selectedFile} />
|
||||
@@ -118,7 +135,7 @@ function CheckGlyph() {
|
||||
function ShipPanel() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={changesTabs(t)} active={2}>
|
||||
<PanelShell panel="ship" tabs={changesTabs(t)} active={2}>
|
||||
<div
|
||||
className="flex shrink-0 items-center border-b border-mock-border px-[16px]"
|
||||
style={SUBHEADER_STYLE}
|
||||
@@ -235,10 +252,38 @@ function recentlyMerged(t: TFunction): { title: string; meta: string; tone: stri
|
||||
];
|
||||
}
|
||||
|
||||
function PluginDashboard() {
|
||||
const SECTION_CLOSED = { height: 0, opacity: 0 };
|
||||
const SECTION_OPEN = { height: "auto", opacity: 1 };
|
||||
|
||||
/** The section the user asks for after the panel is live: which check is failing. */
|
||||
function FailingCheck() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PanelShell tabs={pluginTabs(t)} active={3}>
|
||||
<motion.div initial={SECTION_CLOSED} animate={SECTION_OPEN} className="overflow-hidden">
|
||||
<div className="pt-[16px] text-[11px] text-mock-fg-muted">
|
||||
{t("landing.mockup.app.failing")}
|
||||
</div>
|
||||
<div className="mt-[7px] flex items-center gap-[10px] rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[12px] py-[9px]">
|
||||
<CircleX size={15} className="shrink-0 text-mock-tile-red" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[12px] leading-[16px] text-mock-fg">
|
||||
app-tests (macos)
|
||||
</span>
|
||||
<span className="block truncate text-[10.5px] leading-[13px] text-mock-fg-muted">
|
||||
#3976 · Honor configured commit signing
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-mock-fg-muted">5m 02s</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginDashboard() {
|
||||
const { t } = useTranslation();
|
||||
const listsFailingCheck = useSceneClock("extend", failingCheckListed);
|
||||
return (
|
||||
<PanelShell panel="plugin" tabs={pluginTabs(t)} active={3}>
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-[8px] border-b border-mock-border px-[16px]"
|
||||
style={SUBHEADER_STYLE}
|
||||
@@ -265,6 +310,10 @@ function PluginDashboard() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{listsFailingCheck ? <FailingCheck key="failing-check" /> : null}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="mt-[16px] rounded-[10px] border border-mock-border-accent bg-mock-surface1 px-[14px] pt-[12px] pb-[13px]">
|
||||
<div className="flex items-center justify-between text-[11px] text-mock-fg-muted">
|
||||
<span>{t("landing.mockup.app.mergesWeek")}</span>
|
||||
@@ -307,8 +356,8 @@ function PluginDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
export function RightPanel({ state }: { state: MockupStateId }) {
|
||||
if (state === "ship") return <ShipPanel />;
|
||||
if (state === "extend") return <PluginDashboard />;
|
||||
export function RightPanel({ panel }: { panel: PanelId }) {
|
||||
if (panel === "ship") return <ShipPanel />;
|
||||
if (panel === "plugin") return <PluginDashboard />;
|
||||
return <ChangesPanel />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { script } from "./script";
|
||||
|
||||
const REQUEST = "Rename the panel.";
|
||||
const REPLY = "Renamed. The panel is now called Radar.";
|
||||
|
||||
const scene = script([
|
||||
{ kind: "wait", ms: 500 },
|
||||
{ kind: "type", text: REQUEST },
|
||||
{ kind: "user", text: REQUEST },
|
||||
{
|
||||
kind: "tools",
|
||||
calls: [
|
||||
{ kind: "read", detail: "panel.tsx" },
|
||||
{ kind: "write", detail: "panel.tsx" },
|
||||
],
|
||||
},
|
||||
{ kind: "mark", name: "renamed", ms: 400 },
|
||||
{ kind: "agent", text: REPLY },
|
||||
{ kind: "turnEnd", worked: "8s" },
|
||||
]);
|
||||
|
||||
const [sent, tools, reply, turnEnd] = scene.entries;
|
||||
|
||||
function midway(entry: { start: number; end: number }): number {
|
||||
return (entry.start + entry.end) / 2;
|
||||
}
|
||||
|
||||
const POINTER_ENTERS = 200;
|
||||
const PAUSE_BEFORE_TYPING = 100;
|
||||
|
||||
const pointed = script([
|
||||
{ kind: "wait", ms: POINTER_ENTERS },
|
||||
{ kind: "click", target: "composer" },
|
||||
{ kind: "wait", ms: PAUSE_BEFORE_TYPING },
|
||||
{ kind: "type", text: REQUEST },
|
||||
{ kind: "user", text: REQUEST },
|
||||
{ kind: "click", target: "pill" },
|
||||
{ kind: "click", target: "menu" },
|
||||
]);
|
||||
const composerClick = pointed.click("composer");
|
||||
const pillClick = pointed.click("pill");
|
||||
const menuClick = pointed.click("menu");
|
||||
|
||||
/** The first millisecond of the pointed scene at which something is true. */
|
||||
function firstMs(holds: (ms: number) => boolean): number {
|
||||
for (let ms = 0; ms <= pointed.duration; ms += 1) {
|
||||
if (holds(ms)) return ms;
|
||||
}
|
||||
throw new Error("It never holds");
|
||||
}
|
||||
|
||||
describe("script", () => {
|
||||
it("starts with an empty composer, an empty transcript, and no turn running", () => {
|
||||
expect(scene.draft(0)).toBe("");
|
||||
expect(scene.entered(0)).toBe(0);
|
||||
expect(scene.busy(0)).toBe(false);
|
||||
});
|
||||
|
||||
it("types the request into the composer, then clears it when the message is sent", () => {
|
||||
const typing = { start: 500, end: sent.start };
|
||||
const partial = scene.draft(typing.start + 200);
|
||||
expect(partial.length).toBeGreaterThan(0);
|
||||
expect(partial.length).toBeLessThan(REQUEST.length);
|
||||
expect(REQUEST.startsWith(partial)).toBe(true);
|
||||
|
||||
expect(scene.draft(typing.end - 1)).toBe(REQUEST);
|
||||
expect(scene.draft(sent.start)).toBe("");
|
||||
expect(scene.entered(midway(sent))).toBe(1);
|
||||
});
|
||||
|
||||
it("runs the turn from the sent message until the turn ends", () => {
|
||||
expect(scene.busy(sent.start - 1)).toBe(false);
|
||||
expect(scene.busy(sent.start)).toBe(true);
|
||||
expect(scene.busy(midway(reply))).toBe(true);
|
||||
expect(scene.busy(turnEnd.start)).toBe(false);
|
||||
});
|
||||
|
||||
it("lands tool calls one at a time", () => {
|
||||
expect(scene.landed(tools, tools.start)).toBe(0);
|
||||
expect(scene.landed(tools, tools.start + 1)).toBe(1);
|
||||
expect(scene.landed(tools, tools.end)).toBe(2);
|
||||
});
|
||||
|
||||
it("streams the agent's text", () => {
|
||||
expect(scene.streamed(reply, reply.start)).toBe("");
|
||||
const partial = scene.streamed(reply, midway(reply));
|
||||
expect(partial.length).toBeGreaterThan(0);
|
||||
expect(partial.length).toBeLessThan(REPLY.length);
|
||||
expect(REPLY.startsWith(partial)).toBe(true);
|
||||
expect(scene.streamed(reply, reply.end)).toBe(REPLY);
|
||||
});
|
||||
|
||||
it("reaches a mark after the beats before it and reports progress through it", () => {
|
||||
expect(scene.reached("renamed", tools.end)).toBe(false);
|
||||
expect(scene.reached("renamed", tools.end + 1)).toBe(true);
|
||||
expect(scene.progress("renamed", tools.end + 100)).toBeCloseTo(0.25);
|
||||
expect(scene.progress("renamed", reply.start)).toBe(1);
|
||||
});
|
||||
|
||||
it("glides the pointer to its target, presses, and lets go", () => {
|
||||
const pressedAt = firstMs(composerClick.pressed);
|
||||
const doneAt = firstMs(composerClick.done);
|
||||
|
||||
expect(composerClick.opacity(POINTER_ENTERS)).toBe(0);
|
||||
expect(composerClick.approach(POINTER_ENTERS)).toBe(0);
|
||||
|
||||
const gliding = (POINTER_ENTERS + pressedAt) / 2;
|
||||
expect(composerClick.opacity(gliding)).toBe(1);
|
||||
expect(composerClick.approach(gliding)).toBeGreaterThan(0);
|
||||
expect(composerClick.approach(gliding)).toBeLessThan(1);
|
||||
expect(composerClick.pressed(gliding)).toBe(false);
|
||||
|
||||
expect(composerClick.approach(pressedAt)).toBe(1);
|
||||
expect(composerClick.done(pressedAt)).toBe(false);
|
||||
|
||||
expect(doneAt).toBeGreaterThan(pressedAt);
|
||||
expect(composerClick.pressed(doneAt)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the pointer where it clicked until the user types", () => {
|
||||
const typingAt = firstMs(composerClick.done) + PAUSE_BEFORE_TYPING;
|
||||
expect(composerClick.opacity(typingAt - 1)).toBe(1);
|
||||
expect(pointed.draft(typingAt - 1)).toBe("");
|
||||
|
||||
// The pointer fades away while the first characters land.
|
||||
expect(pointed.draft(typingAt + 1)).not.toBe("");
|
||||
expect(composerClick.opacity(typingAt + 1)).toBeLessThan(1);
|
||||
expect(composerClick.opacity(typingAt + 500)).toBe(0);
|
||||
expect(composerClick.done(typingAt + 500)).toBe(true);
|
||||
});
|
||||
|
||||
it("fades the pointer in when it comes back after typing", () => {
|
||||
const setsOffAt = firstMs((ms) => pillClick.approach(ms) > 0);
|
||||
expect(pillClick.opacity(setsOffAt)).toBeGreaterThan(0);
|
||||
expect(pillClick.opacity(setsOffAt)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("moves one pointer from a click to the click after it", () => {
|
||||
const handoffAt = firstMs(pillClick.done);
|
||||
expect(pillClick.opacity(handoffAt - 1)).toBe(1);
|
||||
expect(menuClick.opacity(handoffAt - 1)).toBe(0);
|
||||
|
||||
expect(pillClick.opacity(handoffAt)).toBe(0);
|
||||
expect(menuClick.opacity(handoffAt)).toBe(1);
|
||||
expect(menuClick.approach(handoffAt)).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves the pointer on the last thing it clicked", () => {
|
||||
expect(menuClick.opacity(pointed.duration)).toBe(1);
|
||||
expect(menuClick.approach(pointed.duration)).toBe(1);
|
||||
expect(menuClick.done(pointed.duration)).toBe(true);
|
||||
expect(composerClick.opacity(pointed.duration)).toBe(0);
|
||||
expect(composerClick.done(pointed.duration)).toBe(true);
|
||||
});
|
||||
|
||||
it("ends a turn without a footer when the beat gives no time worked", () => {
|
||||
const quiet = script([
|
||||
{ kind: "user", text: REQUEST },
|
||||
{ kind: "agent", text: REPLY },
|
||||
{ kind: "turnEnd" },
|
||||
{ kind: "wait", ms: 300 },
|
||||
]);
|
||||
const [, quietReply] = quiet.entries;
|
||||
expect(quiet.entries.map((entry) => entry.beat.kind)).toEqual(["user", "agent"]);
|
||||
expect(quiet.busy(quietReply.end - 1)).toBe(true);
|
||||
expect(quiet.busy(quietReply.end)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the finished scene at its duration", () => {
|
||||
expect(scene.duration).toBe(turnEnd.end);
|
||||
expect(scene.entered(scene.duration)).toBe(scene.entries.length);
|
||||
expect(scene.draft(scene.duration)).toBe("");
|
||||
expect(scene.busy(scene.duration)).toBe(false);
|
||||
expect(scene.reached("renamed", scene.duration)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import type { RemoteHostId } from "./hosts";
|
||||
|
||||
/**
|
||||
* A collapsed tool call as the app's timeline shows it. Built-in tools carry a
|
||||
* fixed label; Paseo MCP tools name their action, such as "Create agent".
|
||||
*/
|
||||
export type ToolCall =
|
||||
| { kind: "read" | "write" | "shell"; detail: string }
|
||||
| { kind: "paseo"; label: string; detail?: string; host?: RemoteHostId };
|
||||
|
||||
/**
|
||||
* One step of a scripted scene. Beats play in order, each after the one before it ends.
|
||||
*
|
||||
* - `wait`: nothing happens.
|
||||
* - `click`: the pointer glides to a named target and clicks it.
|
||||
* - `type`: the user types into the composer.
|
||||
* - `user`: the typed message is sent and joins the transcript.
|
||||
* - `agent`: the agent's text streams into the transcript.
|
||||
* - `tools`: the agent's tool calls land one at a time.
|
||||
* - `turnEnd`: the turn finishes. With `worked`, its footer joins the transcript.
|
||||
* - `mark`: a named moment the rest of the window reacts to, such as a panel opening.
|
||||
*/
|
||||
export type Beat<Mark extends string = string, Target extends string = string> =
|
||||
| { kind: "wait"; ms: number }
|
||||
| { kind: "click"; target: Target }
|
||||
| { kind: "type"; text: string }
|
||||
| { kind: "user"; text: string }
|
||||
| { kind: "agent"; text: string }
|
||||
| { kind: "tools"; calls: ToolCall[] }
|
||||
| { kind: "turnEnd"; worked?: string }
|
||||
| { kind: "mark"; name: Mark; ms: number };
|
||||
|
||||
type TranscriptBeat =
|
||||
| Extract<Beat, { kind: "user" | "agent" | "tools" }>
|
||||
| { kind: "turnEnd"; worked: string };
|
||||
|
||||
/** A beat that leaves something in the transcript, with the span of time it plays over. */
|
||||
export interface TranscriptEntry {
|
||||
id: string;
|
||||
start: number;
|
||||
end: number;
|
||||
beat: TranscriptBeat;
|
||||
}
|
||||
|
||||
/** One click of the pointer: it glides to its target, presses the button, and lets go. */
|
||||
export interface PointerClick {
|
||||
/**
|
||||
* How visible the pointer is for this click, from 0 to 1. It fades in on its way, and fades out
|
||||
* when the user types. A click right after another one takes the same pointer over, with no fade.
|
||||
*/
|
||||
opacity(ms: number): number;
|
||||
/** How far the pointer has come on its way to the target, from 0 to 1. */
|
||||
approach(ms: number): number;
|
||||
/** Whether the pointer's button is down on the target. */
|
||||
pressed(ms: number): boolean;
|
||||
/** Whether the click has happened: the button went down and came back up. */
|
||||
done(ms: number): boolean;
|
||||
}
|
||||
|
||||
/** The click target every scripted chat has. Clicking it puts the caret in the composer. */
|
||||
export const COMPOSER = "composer";
|
||||
|
||||
/** What a chat pane reads from a script: the composer and the transcript at a point in time. */
|
||||
export interface SceneScript {
|
||||
/** The length of the whole script. At this time the scene shows its finished state. */
|
||||
duration: number;
|
||||
entries: TranscriptEntry[];
|
||||
/** What the user is typing: the text so far, until it is sent. */
|
||||
draft(ms: number): string;
|
||||
/** Whether a turn is running: from the user's message until the turn ends. */
|
||||
busy(ms: number): boolean;
|
||||
/** How many transcript entries have started. */
|
||||
entered(ms: number): number;
|
||||
/** How much of an agent entry's text has streamed in. */
|
||||
streamed(entry: TranscriptEntry, ms: number): string;
|
||||
/** How many of a tools entry's calls have landed. */
|
||||
landed(entry: TranscriptEntry, ms: number): number;
|
||||
/** The script's click on a target. */
|
||||
click(target: string): PointerClick;
|
||||
}
|
||||
|
||||
export interface Script<Mark extends string, Target extends string> extends SceneScript {
|
||||
click(target: Target): PointerClick;
|
||||
/** Whether a mark has started. */
|
||||
reached(mark: Mark, ms: number): boolean;
|
||||
/** How far through a mark the scene is, from 0 to 1. */
|
||||
progress(mark: Mark, ms: number): number;
|
||||
}
|
||||
|
||||
// A demo types and streams faster than a person or a model does, and still reads as both.
|
||||
const TYPING_MS_PER_CHARACTER = 14;
|
||||
const SHORTEST_TYPING_MS = 700;
|
||||
const PAUSE_BEFORE_SEND_MS = 320;
|
||||
const SEND_MS = 420;
|
||||
const STREAM_MS_PER_CHARACTER = 8;
|
||||
const TOOL_CALL_MS = 320;
|
||||
const TURN_END_MS = 420;
|
||||
const POINTER_APPROACH_MS = 900;
|
||||
const POINTER_PRESS_MS = 240;
|
||||
const POINTER_FADE_MS = 150;
|
||||
|
||||
function typingMs(text: string): number {
|
||||
return Math.max(SHORTEST_TYPING_MS, text.length * TYPING_MS_PER_CHARACTER);
|
||||
}
|
||||
|
||||
function beatMs(beat: Beat): number {
|
||||
switch (beat.kind) {
|
||||
case "wait":
|
||||
case "mark":
|
||||
return beat.ms;
|
||||
case "click":
|
||||
return POINTER_APPROACH_MS + POINTER_PRESS_MS;
|
||||
case "type":
|
||||
return typingMs(beat.text) + PAUSE_BEFORE_SEND_MS;
|
||||
case "user":
|
||||
return SEND_MS;
|
||||
case "agent":
|
||||
return beat.text.length * STREAM_MS_PER_CHARACTER;
|
||||
case "tools":
|
||||
return beat.calls.length * TOOL_CALL_MS;
|
||||
case "turnEnd":
|
||||
return TURN_END_MS;
|
||||
}
|
||||
}
|
||||
|
||||
function isTranscriptBeat(beat: Beat): beat is TranscriptBeat {
|
||||
if (beat.kind === "turnEnd") return beat.worked !== undefined;
|
||||
return beat.kind === "user" || beat.kind === "agent" || beat.kind === "tools";
|
||||
}
|
||||
|
||||
function fraction(start: number, end: number, ms: number): number {
|
||||
if (ms <= start) return 0;
|
||||
if (ms >= end) return 1;
|
||||
return (ms - start) / (end - start);
|
||||
}
|
||||
|
||||
// A hand speeds a mouse up and slows it down again.
|
||||
function easeInOut(linear: number): number {
|
||||
return linear < 0.5 ? 2 * linear * linear : 1 - (-2 * linear + 2) ** 2 / 2;
|
||||
}
|
||||
|
||||
function hundredths(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** How a click's pointer comes and goes. */
|
||||
interface PointerPresence {
|
||||
/** When the pointer sets off. */
|
||||
start: number;
|
||||
/** Whether the pointer is already on screen, at the click before this one. */
|
||||
continues: boolean;
|
||||
/** When the pointer goes away, or `Infinity` when it stays to the end of the scene. */
|
||||
leaves: number;
|
||||
/** Whether the next click takes the pointer over. Otherwise typing fades it out. */
|
||||
handsOver: boolean;
|
||||
}
|
||||
|
||||
function pointerClick({ start, continues, leaves, handsOver }: PointerPresence): PointerClick {
|
||||
const pressStart = start + POINTER_APPROACH_MS;
|
||||
const pressEnd = pressStart + POINTER_PRESS_MS;
|
||||
return {
|
||||
opacity(ms) {
|
||||
if (ms >= leaves) {
|
||||
return handsOver ? 0 : hundredths(1 - fraction(leaves, leaves + POINTER_FADE_MS, ms));
|
||||
}
|
||||
if (continues) return ms >= start ? 1 : 0;
|
||||
return hundredths(fraction(start, start + POINTER_FADE_MS, ms));
|
||||
},
|
||||
approach: (ms) => Math.round(easeInOut(fraction(start, pressStart, ms)) * 1000) / 1000,
|
||||
pressed: (ms) => ms >= pressStart && ms < pressEnd,
|
||||
done: (ms) => ms >= pressEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lays beats end to end and answers what the scene shows at any time. */
|
||||
export function script<Mark extends string, Target extends string>(
|
||||
beats: Beat<Mark, Target>[],
|
||||
): Script<Mark, Target> {
|
||||
let clock = 0;
|
||||
const timed = beats.map((beat, index) => {
|
||||
const start = clock;
|
||||
clock += beatMs(beat);
|
||||
return { id: `${index}:${beat.kind}`, start, end: clock, beat };
|
||||
});
|
||||
const duration = clock;
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const typing: { start: number; end: number; text: string }[] = [];
|
||||
const turns: { start: number; end: number }[] = [];
|
||||
const marks = new Map<Mark, { start: number; end: number }>();
|
||||
const clicks = new Map<string, PointerClick>();
|
||||
// A hand that types is off the mouse, and the pointer has one place at a time. So typing takes
|
||||
// the pointer off screen, and a click that follows a click takes the pointer over from it.
|
||||
let pointerOnScreen = false;
|
||||
timed.forEach(({ start, beat }, index) => {
|
||||
if (beat.kind === "type") pointerOnScreen = false;
|
||||
if (beat.kind !== "click") return;
|
||||
const next = timed
|
||||
.slice(index + 1)
|
||||
.find((later) => later.beat.kind === "type" || later.beat.kind === "click");
|
||||
clicks.set(
|
||||
beat.target,
|
||||
pointerClick({
|
||||
start,
|
||||
continues: pointerOnScreen,
|
||||
leaves: next ? next.start : Infinity,
|
||||
handsOver: next?.beat.kind === "click",
|
||||
}),
|
||||
);
|
||||
pointerOnScreen = true;
|
||||
});
|
||||
for (const { id, start, end, beat } of timed) {
|
||||
if (isTranscriptBeat(beat)) entries.push({ id, start, end, beat });
|
||||
if (beat.kind === "type") typing.push({ start, end, text: beat.text });
|
||||
if (beat.kind === "user") turns.push({ start, end: duration });
|
||||
if (beat.kind === "turnEnd") {
|
||||
const turn = turns.at(-1);
|
||||
if (turn) turn.end = start;
|
||||
}
|
||||
if (beat.kind === "mark") marks.set(beat.name, { start, end });
|
||||
}
|
||||
|
||||
function span(mark: Mark) {
|
||||
const found = marks.get(mark);
|
||||
if (!found) throw new Error(`The script has no mark named ${mark}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
return {
|
||||
duration,
|
||||
entries,
|
||||
draft(ms) {
|
||||
const beat = typing.find(({ start, end }) => ms >= start && ms < end);
|
||||
if (!beat) return "";
|
||||
const typed = fraction(beat.start, beat.start + typingMs(beat.text), ms);
|
||||
return beat.text.slice(0, Math.ceil(typed * beat.text.length));
|
||||
},
|
||||
busy(ms) {
|
||||
return turns.some(({ start, end }) => ms >= start && ms < end);
|
||||
},
|
||||
entered(ms) {
|
||||
// The last entry enters at the end of a script that closes on it, so the end counts.
|
||||
return entries.filter(({ start }) => ms > start || ms >= duration).length;
|
||||
},
|
||||
streamed(entry, ms) {
|
||||
if (entry.beat.kind !== "agent") return "";
|
||||
const { text } = entry.beat;
|
||||
return text.slice(0, Math.ceil(fraction(entry.start, entry.end, ms) * text.length));
|
||||
},
|
||||
landed(entry, ms) {
|
||||
if (entry.beat.kind !== "tools") return 0;
|
||||
return Math.ceil(fraction(entry.start, entry.end, ms) * entry.beat.calls.length);
|
||||
},
|
||||
reached(mark, ms) {
|
||||
return ms > span(mark).start || ms >= duration;
|
||||
},
|
||||
progress(mark, ms) {
|
||||
const { start, end } = span(mark);
|
||||
return fraction(start, end, ms);
|
||||
},
|
||||
click(target) {
|
||||
const found = clicks.get(target);
|
||||
if (!found) throw new Error(`The script has no click on ${target}`);
|
||||
return found;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
CircleHelp,
|
||||
GitPullRequest,
|
||||
History,
|
||||
House,
|
||||
PanelBottom,
|
||||
PanelLeft,
|
||||
Plus,
|
||||
@@ -18,10 +17,15 @@ import {
|
||||
SquarePlus,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SIDEBAR_W, TITLEBAR_H } from "./geometry";
|
||||
import { HostBadge, type RemoteHostId } from "./hosts";
|
||||
import { PaseoMark } from "./icons";
|
||||
import { FADE_IN, FADE_OUT } from "./motion";
|
||||
import { DELEGATED_TASKS, tasksDelegated } from "./orchestration";
|
||||
import { useSceneClock } from "./player";
|
||||
import { pluginInstalled } from "./plugin-build";
|
||||
import type { MockupStateId } from "./states";
|
||||
|
||||
const SIDEBAR_STYLE = { width: SIDEBAR_W };
|
||||
@@ -41,7 +45,7 @@ interface WorkspaceRow {
|
||||
title: string;
|
||||
avatar: Avatar;
|
||||
time: string;
|
||||
host?: string;
|
||||
host?: RemoteHostId;
|
||||
pr?: string;
|
||||
passed?: boolean;
|
||||
running?: boolean;
|
||||
@@ -65,6 +69,7 @@ const WORKING_ROWS: WorkspaceRow[] = [
|
||||
id: "safe-area",
|
||||
title: "Fix commit safe area in emulators",
|
||||
avatar: PASEO,
|
||||
host: "mac-mini",
|
||||
time: "4m",
|
||||
running: true,
|
||||
},
|
||||
@@ -82,6 +87,7 @@ const READY_TO_REVIEW_ROWS: WorkspaceRow[] = [
|
||||
id: "lineage",
|
||||
title: "Paseo homepage",
|
||||
avatar: PASEO,
|
||||
host: "devbox",
|
||||
pr: "3981",
|
||||
passed: true,
|
||||
time: "now",
|
||||
@@ -97,6 +103,16 @@ const READY_TO_REVIEW_ROWS: WorkspaceRow[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** The workspaces the orchestrating agent created, one per delegated task. */
|
||||
const DELEGATED_ROWS: WorkspaceRow[] = DELEGATED_TASKS.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
avatar: PASEO,
|
||||
host: task.host,
|
||||
time: "now",
|
||||
running: true,
|
||||
}));
|
||||
|
||||
const DONE_ROWS: WorkspaceRow[] = [
|
||||
{ id: "beta", title: "Release v0.7.0-beta.2", avatar: PASEO, time: "2h" },
|
||||
{
|
||||
@@ -111,7 +127,7 @@ const DONE_ROWS: WorkspaceRow[] = [
|
||||
id: "perms",
|
||||
title: "Design semantic permission sy…",
|
||||
avatar: PASEO,
|
||||
host: "my-server",
|
||||
host: "devbox",
|
||||
pr: "3981",
|
||||
passed: true,
|
||||
time: "7h",
|
||||
@@ -120,6 +136,7 @@ const DONE_ROWS: WorkspaceRow[] = [
|
||||
id: "modules",
|
||||
title: "Plan modules and abstractions",
|
||||
avatar: PASEO,
|
||||
host: "mac-mini",
|
||||
pr: "3884",
|
||||
passed: true,
|
||||
time: "1d",
|
||||
@@ -202,12 +219,7 @@ function RowMeta({ row }: { row: WorkspaceRow }) {
|
||||
if (row.host || row.pr) {
|
||||
return (
|
||||
<span className="mt-[3px] flex items-center gap-[6px] text-[10.5px] leading-[13px] text-mock-fg-muted">
|
||||
{row.host ? (
|
||||
<span className="flex items-center gap-[4px] text-mock-host-teal">
|
||||
<Server size={12} className="shrink-0" />
|
||||
{row.host}
|
||||
</span>
|
||||
) : null}
|
||||
{row.host ? <HostBadge host={row.host} /> : null}
|
||||
{row.host && row.pr ? <span className="text-mock-fg-xmuted">·</span> : null}
|
||||
{row.pr ? (
|
||||
<span className="flex items-center gap-[4px]">
|
||||
@@ -267,7 +279,18 @@ function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string })
|
||||
|
||||
export function Sidebar({ state }: { state: MockupStateId }) {
|
||||
const { t } = useTranslation();
|
||||
const extending = state === "extend";
|
||||
// The plugin's item joins the nav when the plugin is installed, and each delegated workspace
|
||||
// joins Working when the orchestrator creates it.
|
||||
const pluginIsInstalled = useSceneClock("extend", pluginInstalled);
|
||||
const delegated = useSceneClock("orchestrate", tasksDelegated);
|
||||
const extending = state === "extend" && pluginIsInstalled;
|
||||
const workingRows = useMemo(
|
||||
() =>
|
||||
state === "orchestrate"
|
||||
? [...DELEGATED_ROWS.slice(0, delegated), ...WORKING_ROWS]
|
||||
: WORKING_ROWS,
|
||||
[state, delegated],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col overflow-hidden bg-mock-sidebar" style={SIDEBAR_STYLE}>
|
||||
@@ -321,7 +344,7 @@ export function Sidebar({ state }: { state: MockupStateId }) {
|
||||
|
||||
<GroupHeader icon={WORKING_ICON} label={t("landing.mockup.app.working")} />
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{WORKING_ROWS.map((row) => (
|
||||
{workingRows.map((row) => (
|
||||
<motion.div key={row.id} layout initial={FADE_OUT} animate={FADE_IN} exit={FADE_OUT}>
|
||||
<Row row={row} indented />
|
||||
</motion.div>
|
||||
@@ -340,7 +363,7 @@ export function Sidebar({ state }: { state: MockupStateId }) {
|
||||
<span className="text-mock-fg">{t("landing.mockup.app.addProject")}</span>
|
||||
<span className="flex-1" />
|
||||
<PanelBottom size={14} />
|
||||
<House size={14} />
|
||||
<Server size={14} className={state === "remote" ? "text-mock-fg" : ""} />
|
||||
<CircleHelp size={14} />
|
||||
<Settings size={14} />
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
/**
|
||||
* The states the hero mockup can be in, in pill order. Labels are copy — edit
|
||||
* them here and nothing else has to change.
|
||||
*/
|
||||
/** The scenes the hero mockup can show; each pill's label is `landing.mockup.<id>`. */
|
||||
export const MOCKUP_STATES = ["build", "review", "ship", "extend"] as const;
|
||||
/** The scenes the hero mockup can show, in pill order. Each pill's label is `landing.mockup.<id>`. */
|
||||
export const MOCKUP_STATES = ["orchestrate", "remote", "review", "ship", "extend"] as const;
|
||||
|
||||
export type MockupStateId = (typeof MOCKUP_STATES)[number];
|
||||
|
||||
export const DEFAULT_MOCKUP_STATE: MockupStateId = "build";
|
||||
export const DEFAULT_MOCKUP_STATE: MockupStateId = "orchestrate";
|
||||
|
||||
/** Which states keep a panel docked to the right of the workspace. */
|
||||
export function hasRightPanel(_state: MockupStateId): boolean {
|
||||
|
||||
@@ -106,9 +106,17 @@ const WORKSPACE_TABS: Tab[] = [
|
||||
|
||||
const DIFF_TAB: Tab = { id: "diff", label: "Diff", icon: <FileDiff size={11} /> };
|
||||
|
||||
/** The tab a scene has in front. The orchestrating agent is Claude; the remote scene is a terminal. */
|
||||
function activeTabId(state: MockupStateId): string {
|
||||
if (state === "review") return DIFF_TAB.id;
|
||||
if (state === "orchestrate") return "claude";
|
||||
if (state === "remote") return "terminal";
|
||||
return "codex";
|
||||
}
|
||||
|
||||
export function TabStrip({ state }: { state: MockupStateId }) {
|
||||
const review = state === "review";
|
||||
const tabs = review ? [...WORKSPACE_TABS, DIFF_TAB] : WORKSPACE_TABS;
|
||||
const tabs = state === "review" ? [...WORKSPACE_TABS, DIFF_TAB] : WORKSPACE_TABS;
|
||||
const activeId = activeTabId(state);
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-[5px] border-b border-mock-border pr-[16px] pl-[7px]"
|
||||
@@ -118,9 +126,7 @@ export function TabStrip({ state }: { state: MockupStateId }) {
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`flex items-center gap-[7px] rounded-[7px] px-[10px] py-[5px] ${
|
||||
(review ? tab.id === "diff" : tab.id === "codex")
|
||||
? "bg-mock-surface2 text-mock-fg"
|
||||
: "text-mock-fg-muted"
|
||||
tab.id === activeId ? "bg-mock-surface2 text-mock-fg" : "text-mock-fg-muted"
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
|
||||
@@ -1,57 +1,123 @@
|
||||
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
|
||||
import { AgentTranscript, Composer, PluginBuildTranscript } from "./chat";
|
||||
import { DESIGN_HEIGHT, DESIGN_WIDTH, RIGHT_PANEL_W } from "./geometry";
|
||||
import type * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
AgentTranscript,
|
||||
Composer,
|
||||
ProgressTrack,
|
||||
ScriptedComposer,
|
||||
ScriptedTranscript,
|
||||
SubagentsTrack,
|
||||
} from "./chat";
|
||||
import { DESIGN_HEIGHT, DESIGN_WIDTH } from "./geometry";
|
||||
import { HostsModal } from "./hosts";
|
||||
import { FADE_IN, FADE_OUT, INSTANT_TRANSITION, MOCKUP_TRANSITION } from "./motion";
|
||||
import { DiffReviewPane } from "./panes";
|
||||
import { RightPanel } from "./right-panel";
|
||||
import { ORCHESTRATION } from "./orchestration";
|
||||
import { DiffReviewPane, RemoteTerminalPane } from "./panes";
|
||||
import {
|
||||
createScenePlayer,
|
||||
type ScenePlayer,
|
||||
ScenePlayerContext,
|
||||
usePlayerScene,
|
||||
useSceneClock,
|
||||
} from "./player";
|
||||
import { PLUGIN_BUILD, pluginInstalled } from "./plugin-build";
|
||||
import { REVIEW } from "./review";
|
||||
import type { ProviderId } from "./providers";
|
||||
import { type PanelId, panelWidth, RightPanel } from "./right-panel";
|
||||
import type { SceneScript } from "./script";
|
||||
import { Sidebar } from "./sidebar";
|
||||
import { hasRightPanel, type MockupStateId } from "./states";
|
||||
import { DEFAULT_MOCKUP_STATE, hasRightPanel, type MockupStateId } from "./states";
|
||||
import { TabStrip, TitleBar } from "./titlebar";
|
||||
|
||||
const WINDOW_STYLE = { width: DESIGN_WIDTH, height: DESIGN_HEIGHT };
|
||||
const PANEL_STYLE = { width: RIGHT_PANEL_W };
|
||||
const PANEL_COLLAPSED = { width: 0, opacity: 0 };
|
||||
const PANEL_OPEN = { width: RIGHT_PANEL_W + 1, opacity: 1 };
|
||||
|
||||
/** Which center pane a state shows. States sharing a key keep it mounted. */
|
||||
function centerKey(state: MockupStateId): string {
|
||||
if (state === "review") return "review";
|
||||
if (state === "extend") return "extend";
|
||||
return "chat";
|
||||
/** Which panel is docked on the right. The plugin's panel exists once the plugin is installed. */
|
||||
function dockedPanel(state: MockupStateId, pluginIsInstalled: boolean): PanelId {
|
||||
if (state === "ship") return "ship";
|
||||
if (state === "extend" && pluginIsInstalled) return "plugin";
|
||||
return "changes";
|
||||
}
|
||||
|
||||
function panelKey(state: MockupStateId): string {
|
||||
if (state === "ship") return "ship";
|
||||
if (state === "extend") return "extend";
|
||||
return "changes";
|
||||
// Elements passed as props are hoisted so they are not rebuilt on every render.
|
||||
const PROGRESS_TRACK = <ProgressTrack />;
|
||||
|
||||
/** The scenes that play a script, with the agent the user talks to and that agent's composer track. */
|
||||
const SCRIPTED_SCENES: Partial<
|
||||
Record<MockupStateId, { script: SceneScript; agent: ProviderId; track?: React.ReactNode }>
|
||||
> = {
|
||||
orchestrate: { script: ORCHESTRATION, agent: "claude", track: <SubagentsTrack /> },
|
||||
extend: { script: PLUGIN_BUILD, agent: "codex" },
|
||||
};
|
||||
|
||||
/** A player for the mockup's scenes. It opens on the default scene. */
|
||||
export function createMockupPlayer(): ScenePlayer {
|
||||
return createScenePlayer(DEFAULT_MOCKUP_STATE, {
|
||||
orchestrate: ORCHESTRATION.duration,
|
||||
extend: PLUGIN_BUILD.duration,
|
||||
review: REVIEW.duration,
|
||||
});
|
||||
}
|
||||
|
||||
export { usePlayerScene as useMockupScene };
|
||||
|
||||
/** What the agent tab in front says in each scene. */
|
||||
function Transcript({ state }: { state: MockupStateId }) {
|
||||
const scripted = SCRIPTED_SCENES[state];
|
||||
if (scripted) return <ScriptedTranscript scene={state} script={scripted.script} />;
|
||||
return <AgentTranscript />;
|
||||
}
|
||||
|
||||
/** The composer under that transcript: whose agent it is, its track, and whether a turn runs. */
|
||||
function ChatComposer({ state }: { state: MockupStateId }) {
|
||||
const scripted = SCRIPTED_SCENES[state];
|
||||
if (scripted) {
|
||||
return (
|
||||
<ScriptedComposer
|
||||
scene={state}
|
||||
script={scripted.script}
|
||||
agent={scripted.agent}
|
||||
track={scripted.track}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Composer track={PROGRESS_TRACK} />;
|
||||
}
|
||||
|
||||
function CenterPane({ state }: { state: MockupStateId }) {
|
||||
if (state === "review") return <DiffReviewPane />;
|
||||
if (state === "remote") return <RemoteTerminalPane />;
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
|
||||
{/* Bottom-anchored: the newest turn sits on the composer, older text clips. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-[27px] pb-[6px]">
|
||||
{state === "extend" ? <PluginBuildTranscript /> : <AgentTranscript />}
|
||||
{/* A chat scrolls like this: a short transcript starts at the top, and one taller than the
|
||||
pane keeps its newest turn on the composer while older text clips. The reversed column
|
||||
pins the bottom, and the auto margin lifts a short transcript to the top. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-hidden px-[27px] pt-[12px] pb-[6px]">
|
||||
<div className="mb-auto">
|
||||
<Transcript state={state} />
|
||||
</div>
|
||||
</div>
|
||||
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
|
||||
<ChatComposer state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The docked panel keeps its width while its contents swap. */
|
||||
function PanelCrossfade({ state }: { state: MockupStateId }) {
|
||||
/** The docked panel's contents cross-fade when the panel it shows changes. */
|
||||
function PanelCrossfade({ panel }: { panel: PanelId }) {
|
||||
return (
|
||||
<div className="relative" style={PANEL_STYLE}>
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<AnimatePresence initial={false}>
|
||||
{/* Each panel keeps its own width and the dock clips it, so nothing inside a panel
|
||||
reflows while the dock resizes. */}
|
||||
<motion.div
|
||||
key={panelKey(state)}
|
||||
key={panel}
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
className="absolute inset-0"
|
||||
className="absolute inset-y-0 left-0"
|
||||
>
|
||||
<RightPanel state={state} />
|
||||
<RightPanel panel={panel} />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
@@ -67,7 +133,7 @@ function CenterColumn({ state }: { state: MockupStateId }) {
|
||||
<div className="relative min-h-0 flex-1">
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
key={centerKey(state)}
|
||||
key={state}
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
@@ -81,39 +147,59 @@ function CenterColumn({ state }: { state: MockupStateId }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The docked right panel, animating its width open and closed. */
|
||||
/** The docked right panel. Its width follows the panel it shows, plus one pixel for the divider. */
|
||||
function DockedPanel({ state }: { state: MockupStateId }) {
|
||||
const panel = dockedPanel(state, useSceneClock("extend", pluginInstalled));
|
||||
const open = useMemo(() => ({ width: panelWidth(panel) + 1, opacity: 1 }), [panel]);
|
||||
return (
|
||||
<motion.div
|
||||
initial={PANEL_COLLAPSED}
|
||||
animate={PANEL_OPEN}
|
||||
animate={open}
|
||||
exit={PANEL_COLLAPSED}
|
||||
className="relative flex shrink-0 overflow-hidden"
|
||||
>
|
||||
<div className="w-px shrink-0 bg-mock-border" />
|
||||
<PanelCrossfade state={state} />
|
||||
<PanelCrossfade panel={panel} />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MockupWindow({ state }: { state: MockupStateId }) {
|
||||
export function MockupWindow({ player }: { player: ScenePlayer }) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
const state = usePlayerScene(player);
|
||||
|
||||
return (
|
||||
<MotionConfig transition={reduceMotion ? INSTANT_TRANSITION : MOCKUP_TRANSITION}>
|
||||
<div
|
||||
className="relative flex select-none overflow-hidden bg-mock-surface0 text-mock-fg antialiased"
|
||||
style={WINDOW_STYLE}
|
||||
>
|
||||
{/* Three full-height columns, each owning its own header. The middle
|
||||
<ScenePlayerContext value={player}>
|
||||
<MotionConfig transition={reduceMotion ? INSTANT_TRANSITION : MOCKUP_TRANSITION}>
|
||||
<div
|
||||
className="relative flex select-none overflow-hidden bg-mock-surface0 text-mock-fg antialiased"
|
||||
style={WINDOW_STYLE}
|
||||
>
|
||||
{/* Three full-height columns, each owning its own header. The middle
|
||||
header stops at the divider — it never spans over the right panel. */}
|
||||
<Sidebar state={state} />
|
||||
<div className="w-px shrink-0 bg-mock-border" />
|
||||
<CenterColumn state={state} />
|
||||
<AnimatePresence initial={false}>
|
||||
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
<Sidebar state={state} />
|
||||
<div className="w-px shrink-0 bg-mock-border" />
|
||||
<CenterColumn state={state} />
|
||||
<AnimatePresence initial={false}>
|
||||
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
|
||||
</AnimatePresence>
|
||||
{/* The remote scene opens the hosts modal over the whole window, as the app dims
|
||||
everything behind a modal. */}
|
||||
<AnimatePresence initial={false}>
|
||||
{state === "remote" ? (
|
||||
<motion.div
|
||||
key="hosts-modal"
|
||||
initial={FADE_OUT}
|
||||
animate={FADE_IN}
|
||||
exit={FADE_OUT}
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/55"
|
||||
>
|
||||
<HostsModal />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
</ScenePlayerContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@
|
||||
},
|
||||
"mockup": {
|
||||
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
|
||||
"build": "Parallel agents",
|
||||
"orchestrate": "Orchestration",
|
||||
"remote": "Remote",
|
||||
"review": "Code review",
|
||||
"ship": "Pull requests",
|
||||
"extend": "Plugins",
|
||||
@@ -459,6 +460,15 @@
|
||||
"working": "Working",
|
||||
"done": "Done",
|
||||
"addProject": "Add project",
|
||||
"addHost": "Add host",
|
||||
"hosts": "Hosts",
|
||||
"agentCount": "{{count}} agents",
|
||||
"hostLinks": {
|
||||
"local": "Local",
|
||||
"relay": "Relay",
|
||||
"tailscale": "Tailscale",
|
||||
"ssh": "SSH"
|
||||
},
|
||||
"files": "Files",
|
||||
"changes": "Changes",
|
||||
"uncommitted": "Uncommitted",
|
||||
@@ -473,6 +483,11 @@
|
||||
"tasksProgress": "{{completed}}/{{total}} tasks",
|
||||
"composerPlaceholder": "Message the agent, tag @files, or use /commands and /skills",
|
||||
"mobileComposerPlaceholder": "Message, @files, /commands",
|
||||
"reviewCommentPlaceholder": "Leave a comment",
|
||||
"reviewCancel": "Cancel",
|
||||
"reviewSave": "Comment",
|
||||
"sendFeedback": "Send feedback ({{count}})",
|
||||
"feedbackSent": "Feedback sent to {{recipient}}",
|
||||
"plugin": "plugin",
|
||||
"mergedWeek": "Merged · 7d",
|
||||
"inReview": "In review",
|
||||
@@ -482,7 +497,8 @@
|
||||
"minutesAgo": "{{count}}m ago",
|
||||
"hoursAgo": "{{count}}h ago",
|
||||
"reviewing": "reviewing",
|
||||
"subagents": "{{count}} subagents",
|
||||
"subagents_one": "{{count}} subagent",
|
||||
"subagents_other": "{{count}} subagents",
|
||||
"checksPassedShort": "passed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,7 +446,8 @@
|
||||
},
|
||||
"mockup": {
|
||||
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
|
||||
"build": "并行 Agent",
|
||||
"orchestrate": "编排",
|
||||
"remote": "远程",
|
||||
"review": "代码审查",
|
||||
"ship": "Pull Request",
|
||||
"extend": "插件",
|
||||
@@ -459,6 +460,15 @@
|
||||
"working": "运行中",
|
||||
"done": "已完成",
|
||||
"addProject": "添加 project",
|
||||
"addHost": "添加主机",
|
||||
"hosts": "主机",
|
||||
"agentCount": "{{count}} 个 Agent",
|
||||
"hostLinks": {
|
||||
"local": "本地",
|
||||
"relay": "中继",
|
||||
"tailscale": "Tailscale",
|
||||
"ssh": "SSH"
|
||||
},
|
||||
"files": "文件",
|
||||
"changes": "变更",
|
||||
"uncommitted": "未 commit",
|
||||
@@ -473,6 +483,11 @@
|
||||
"tasksProgress": "{{completed}}/{{total}} 项任务",
|
||||
"composerPlaceholder": "给 Agent 发消息,标记 @files,或使用 /commands 和 /skills",
|
||||
"mobileComposerPlaceholder": "发消息,@files,/commands",
|
||||
"reviewCommentPlaceholder": "留下评论",
|
||||
"reviewCancel": "取消",
|
||||
"reviewSave": "评论",
|
||||
"sendFeedback": "发送反馈 ({{count}})",
|
||||
"feedbackSent": "已向 {{recipient}} 发送反馈",
|
||||
"plugin": "插件",
|
||||
"mergedWeek": "已合并 · 7 天",
|
||||
"inReview": "审查中",
|
||||
@@ -482,7 +497,8 @@
|
||||
"minutesAgo": "{{count}} 分钟前",
|
||||
"hoursAgo": "{{count}} 小时前",
|
||||
"reviewing": "审查中",
|
||||
"subagents": "{{count}} 个 subagent",
|
||||
"subagents_one": "{{count}} 个 subagent",
|
||||
"subagents_other": "{{count}} 个 subagent",
|
||||
"checksPassedShort": "通过"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -770,7 +770,11 @@
|
||||
--color-mock-tile-violet: #7a6aa8;
|
||||
--color-mock-tile-amber: #8f7838;
|
||||
--color-mock-tile-teal: #368080;
|
||||
/* Host badge ink — the identity colors as foreground, IDENTITY_FOREGROUND_DARK in the
|
||||
same file. */
|
||||
--color-mock-host-teal: #6cacab;
|
||||
--color-mock-host-violet: #a392d5;
|
||||
--color-mock-host-orange: #cc8f64;
|
||||
|
||||
/* Status *dot* band (dark: L=0.72, chroma 90%). Sidebar row state dots only.
|
||||
theme.ts:198-204. */
|
||||
|
||||
@@ -1279,9 +1279,12 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
|
||||
|
||||
The daemon stamps `pluginId` from the calling plugin session and rejects this RPC from non-plugin
|
||||
sessions. The row appears live, survives timeline refetches, and keeps only the latest value for the
|
||||
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row. Daemons
|
||||
reject `data` over the limit rather than truncating it. Daemons that support this operation
|
||||
advertise `server_info.features.pluginTimelineItems`.
|
||||
same plugin and `id`. If its renderer is missing, Paseo shows the existing unavailable row.
|
||||
|
||||
`kind` starts with a lowercase letter and contains lowercase letters, numbers, or hyphens, the same
|
||||
rule `addTimelineRenderer` applies. Daemons reject any other `kind`. Daemons reject `data` over the
|
||||
limit rather than truncating it. Daemons that support this operation advertise
|
||||
`server_info.features.pluginTimelineItems`.
|
||||
|
||||
## Theme and layout
|
||||
|
||||
|
||||
@@ -1060,7 +1060,9 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
|
||||
| `version` | 正整数 | 是 | 选择渲染器契约的版本。 |
|
||||
| `data` | 兼容 JSON | 是 | 渲染器负载,JSON 序列化后最多 64 KiB。 |
|
||||
|
||||
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
|
||||
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。
|
||||
|
||||
`kind` 以小写字母开头,只包含小写字母、数字或连字符,与 `addTimelineRenderer` 使用的规则相同。daemon 会拒绝其他任何 `kind`。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
|
||||
|
||||
## 主题与布局 {#theme-and-layout}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user