Compare commits

..
64 changed files with 301 additions and 2779 deletions
+3 -4
View File
@@ -873,10 +873,9 @@ 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. `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
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
`server_info.features.pluginTimelineItems`.
## Contribute client slash commands
@@ -20,21 +20,6 @@ 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],
) {
@@ -259,7 +244,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 resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await revealPhoneExplorer(page);
await expect(
explorerSidebar(page).getByRole("button", { name: "New tab", exact: true }),
@@ -304,10 +289,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 resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
await expect(explorer).toBeVisible();
expect(await host.evaluate((element) => element.isConnected)).toBe(true);
} finally {
@@ -342,7 +327,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 resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await openFilesPanel(page);
await reorderCompactExplorerTabs(page);
await expect(
@@ -409,9 +394,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 resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
expect(await explorerEditor!.evaluate((node) => node.isConnected)).toBe(true);
await expect(text).toContainText("retained Explorer edit");
} finally {
@@ -476,9 +461,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 resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
await ensureExplorerSidebar(page);
await resizeToLayout(page, "wide");
await page.setViewportSize({ width: 1280, height: 900 });
await cycleWorkspaceTheme(page);
expect(await terminalNode!.evaluate((node) => node.isConnected)).toBe(true);
expect(attaches).toHaveLength(1);
@@ -498,7 +483,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 resizeToLayout(page, "compact");
await page.setViewportSize({ width: 390, height: 844 });
const explorer = await ensureExplorerSidebar(page);
await openFilesPanel(page);
const host = await explorer.elementHandle();
@@ -91,13 +91,10 @@ 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 });
// 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") {
const explorer = explorerSidebar(page);
if ((await explorer.count()) === 0) {
await toggle.click();
}
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const explorer = explorerSidebar(page);
await expect(explorer).toBeVisible({ timeout: 30_000 });
return explorer;
}
@@ -1,6 +1,5 @@
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;
@@ -15,15 +14,6 @@ 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) {
@@ -1,12 +1,5 @@
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import type {
HookCallback,
Query,
SDKMessage,
SDKUserMessage,
SessionCronSummary,
StopHookInput,
} from "@anthropic-ai/claude-agent-sdk";
import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -891,7 +884,6 @@ 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;
@@ -916,10 +908,9 @@ 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, options }) => {
queryFactory: ({ prompt }) => {
if (typeof prompt === "string") throw new Error("Expected streaming input");
nativeInput = prompt[Symbol.asyncIterator]();
stopHook = options?.hooks?.Stop?.[0]?.hooks[0];
connected();
return query;
},
@@ -966,22 +957,6 @@ 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,7 +14,6 @@ import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.j
import {
type AgentDefinition,
type CanUseTool,
type HookCallback,
type McpServerConfig as ClaudeSdkMcpServerConfig,
type PermissionMode,
type PermissionResult,
@@ -3805,7 +3804,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(), Stop: [{ hooks: [this.observeStopHook] }] },
hooks: this.buildSubagentEffortHooks(),
...(this.persistSession === undefined ? {} : { persistSession: this.persistSession }),
env: sdkEnv,
};
@@ -5481,12 +5480,6 @@ 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,47 +46,3 @@ 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,10 +1,8 @@
import type { SDKMessage, SessionCronSummary } from "@anthropic-ai/claude-agent-sdk";
import type { SDKMessage } 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";
@@ -18,15 +16,8 @@ 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 = [];
@@ -47,13 +38,6 @@ 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;
@@ -70,8 +54,6 @@ 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;
@@ -147,8 +129,6 @@ 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
);
@@ -1,88 +0,0 @@
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,37 +1660,6 @@ 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", () => {
+1 -5
View File
@@ -144,10 +144,7 @@ import {
updateAgentCommand,
} from "./agent/lifecycle-command.js";
import { buildStoredAgentPayload, toAgentPayload } from "./agent/agent-projections.js";
import {
assertPluginTimelineDataSize,
assertPluginTimelineKind,
} from "./agent/agent-timeline-content.js";
import { assertPluginTimelineDataSize } 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";
@@ -8507,7 +8504,6 @@ 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,83 +641,7 @@ describe("WorkspaceReconciliationService", () => {
expect(workspaces.get("w1")).toMatchObject({
archivedAt,
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,
branch: "new-branch",
});
});
@@ -396,7 +396,8 @@ export class WorkspaceReconciliationService {
const workspaceCheckouts = await Promise.all(
siblings.map(async (workspace) => {
const current = await this.workspaceRegistry.get(workspace.workspaceId);
if (!current || !isStillReconcilable(current, workspace)) return null;
if (!current || current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
return null;
return { workspace: current, checkout: await readCheckout(current.cwd) };
}),
);
@@ -445,9 +446,8 @@ export class WorkspaceReconciliationService {
return;
let fields: Partial<MutableWorkspacePlacement> | null = null;
await this.workspaceRegistry.update(workspace.workspaceId, (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;
if (current.cwd !== workspace.cwd || current.projectId !== workspace.projectId)
return current;
const update = reconcileWorkspacePlacement({
workspace: current,
checkout: wsGit,
@@ -685,13 +685,3 @@ 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,10 +1690,8 @@ 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([]);
});
+5 -2
View File
@@ -1,4 +1,4 @@
import { expect, test, waitForHydration, type Locator, type Page } from "./fixtures";
import { expect, test, 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,9 +6,12 @@ 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 waitForHydration(page);
await expect(page.getByRole("button", { name: /^Language/ }).first()).toBeEnabled();
}
function releaseWithHighlights(page: Page): Locator {
+1 -10
View File
@@ -1,17 +1,8 @@
import { expect, test as base, type Page } from "playwright/test";
import { test as base } 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) => {
+4 -224
View File
@@ -1,13 +1,12 @@
import { expect, test, waitForHydration, type Locator, type Page } from "./fixtures";
import { expect, test, type Locator, type Page } from "./fixtures";
const desktopName =
"Paseo desktop app with coding agents, a conversation, and a code diff open side by side";
/** 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);
async function openHomepage(page: Page) {
await page.setViewportSize({ width: 1512, height: 930 });
await page.goto("/");
await waitForHydration(page);
await expect(page.getByRole("button", { name: "Parallel agents", exact: true })).toBeVisible();
}
async function viewPhones(page: Page) {
@@ -61,225 +60,6 @@ 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) => {
+11 -5
View File
@@ -1,5 +1,5 @@
import securityPlugin from "./overview.fixture.json" with { type: "json" };
import { expect, test, waitForHydration, type Page, type Locator } from "./fixtures";
import { expect, test, 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,6 +9,12 @@ 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 })
@@ -78,18 +84,18 @@ test("browses from the directory into a category, a plugin, and its author", asy
});
});
await openPlugins(page);
await waitForHydration(page);
await waitForInteractivePage(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 waitForHydration(page);
await waitForInteractivePage(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 waitForHydration(page);
await waitForInteractivePage(page);
await expect(page.getByText("paseo plugin add omercnet/fresh-worktrees")).toHaveCount(1);
await expect(
page.getByRole("heading", { level: 2, name: "Behavior", exact: true }),
@@ -186,7 +192,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 waitForHydration(page);
await expect(page.getByRole("button", { name: "Language: English" }).first()).toBeEnabled();
await fillSearchWithMissedInputEvent(page, "graphite");
await page.keyboard.press("Enter");
+15 -78
View File
@@ -1,57 +1,24 @@
import { motion, useInView, useReducedMotion } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
createMockupPlayer,
DEFAULT_MOCKUP_STATE,
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 [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],
);
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
return (
<div ref={frame}>
<div>
<div className="mb-4 flex flex-wrap items-center justify-center gap-1 sm:gap-2">
{MOCKUP_STATES.map((option) => (
<StatePill
@@ -59,54 +26,24 @@ export function HeroMockup() {
id={option}
label={t(`landing.mockup.${option}`)}
selected={option === state}
onSelect={select}
onSelect={setState}
/>
))}
</div>
<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>
<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>
);
}
/** 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: relay handshake race" },
{ kind: "paseo", label: "Create agent", detail: "Fix #417: menu bar icon on macOS" },
{ kind: "paseo", label: "Create agent", detail: "Fix #412: retry flaky relay handshake" },
{ kind: "paseo", label: "Create agent", detail: "Fix #417: group schedules by project" },
];
function McpAutomationTranscript() {
@@ -18,13 +18,6 @@ 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> = {
+108 -282
View File
@@ -15,19 +15,15 @@ import {
SquareTerminal,
Zap,
} from "lucide-react";
import { motion } from "framer-motion";
import type * as React from "react";
import { useTranslation } from "react-i18next";
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";
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} />;
// --- transcript -------------------------------------------------------------
@@ -66,38 +62,6 @@ 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]">
@@ -124,273 +88,161 @@ 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>
);
}
/** Something that fades in when the script reaches it. */
function Entering({ children }: { children: React.ReactNode }) {
/**
* 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 }) {
return (
<motion.div initial={FADE_OUT} animate={FADE_IN}>
{children}
</motion.div>
<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>
);
}
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));
function PluginToolGroup({ calls }: { calls: ToolCall[] }) {
return (
<div>
{calls.slice(0, landed).map((call) => (
<Entering key={`${call.kind}:${call.detail}`}>
<ToolCallRow call={call} />
</Entering>
{calls.map((call) => (
<ToolCallRow key={`${call.kind}:${call.detail}`} call={call} />
))}
</div>
);
}
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);
export function PluginBuildTranscript() {
return (
<div className="space-y-[13px] pt-[2px]">
{script.entries.slice(0, entered).map((entry) => (
<TranscriptEntryView key={entry.id} scene={scene} script={script} entry={entry} />
))}
<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&rsquo;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&rsquo;s live project and workspace state, so the radar
doesn&rsquo;t need a separate service.
</Para>
<PluginToolGroup calls={PLUGIN_BUILD_CALLS} />
<Para>The surface is wired. I&rsquo;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" />
</div>
);
}
// --- composer ---------------------------------------------------------------
/** One composer control: its icon as the child, then the current value. */
function ControlPill({ label, children }: { label: string; children: React.ReactNode }) {
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<span className="flex items-center gap-[6px] whitespace-nowrap text-[12px] text-mock-fg-muted">
{children}
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
{icon}
{label}
<ChevronDown size={11} className="text-mock-fg-xmuted" />
</span>
);
}
/**
* 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 }) {
/** A rounded pill that floats above the composer — task tracker, subagents, diff. */
function ComposerPill({ children }: { children: React.ReactNode }) {
return (
<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"
}`}
>
<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">
{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({
track,
agent = "codex",
busy = false,
draft = "",
focused = false,
children,
showProgress = true,
busy = true,
}: {
/** 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;
showProgress?: boolean;
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]">
{track}
<div className="relative rounded-[11px] border border-mock-border-accent bg-mock-surface1">
{children}
<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">
<div className="px-[14px] pt-[13px] pb-[12px]">
<div className="flex items-start gap-[10px]">
{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>
)}
<span className="min-w-0 flex-1 text-[13px] text-mock-surface4">
{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 label={provider.model}>
<provider.Icon size={13} />
</ControlPill>
<ControlPill label="Medium">
<Brain size={13} />
</ControlPill>
<ControlPill label="Full access">
<ShieldOff size={13} />
</ControlPill>
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
<ControlPill icon={EFFORT_ICON} label="Medium" />
<ControlPill icon={ACCESS_ICON} label="Full access" />
<Zap size={14} className="text-mock-fg-muted" />
<ListTodo size={14} className="text-mock-fg-muted" />
<span className="flex-1" />
@@ -403,29 +255,3 @@ 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,8 +9,6 @@ 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";
@@ -31,7 +29,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; reviewed?: boolean }
| { kind: "line"; n: string; text: string; mark?: DiffMark }
| { kind: "scrollbar" }
| { kind: "gap" };
@@ -49,35 +47,16 @@ const NUMBER_COLOR: Record<DiffMark, string> = {
remove: "text-mock-danger",
};
/** 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;
}) {
function DiffLine({ n, text, mark }: { n: string; text: string; mark?: DiffMark }) {
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={`relative flex h-full shrink-0 items-center justify-end border-r border-mock-border pr-[10px] font-mono text-[11px] tabular-nums ${number}`}
className={`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} />
@@ -109,15 +88,7 @@ function FileRow({
);
}
export function DiffRows({
rows,
flush,
review,
}: {
rows: DiffRow[];
flush?: boolean;
review?: LineReview;
}) {
export function DiffRows({ rows, flush }: { rows: DiffRow[]; flush?: boolean }) {
return (
<div className={`relative overflow-hidden bg-mock-surface1 ${flush ? "" : "min-h-0 flex-1"}`}>
{rows.map((row, index) => {
@@ -139,14 +110,6 @@ export function DiffRows({
</span>
</div>
);
if (row.reviewed && review) {
return (
<Fragment key={key}>
<DiffLine {...row} action={review.action} />
{review.thread}
</Fragment>
);
}
return <DiffLine key={key} {...row} />;
})}
{!flush ? (
@@ -370,7 +333,6 @@ export const REVIEW_DIFF_ROWS: DiffRow[] = [
n: "23",
text: 'type ToolKind = "read" | "shell";',
mark: "add",
reviewed: true,
},
{ kind: "line", n: "24", text: "" },
{
@@ -4,17 +4,14 @@
* plain px number you can reason about directly, and nothing has to change when
* the hero column gets wider or narrower.
*
* 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.
* 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.
*/
export const DESIGN_WIDTH = 1280;
export const DESIGN_HEIGHT = 734;
export const DESIGN_WIDTH = 1440;
export const DESIGN_HEIGHT = 826;
export const TITLEBAR_H = 39;
export const TABBAR_H = 31;
export const SIDEBAR_W = 262;
// 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;
export const RIGHT_PANEL_W = 424;
@@ -1,157 +0,0 @@
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,7 +1,5 @@
// 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 (
@@ -114,65 +112,3 @@ 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,11 +1,10 @@
export { ClaudeIcon, CodexIcon, SourceControlIcon } from "./icons";
export { ToolCallRow } from "./chat";
export type { ToolCall } from "./script";
export { type ToolCall, ToolCallRow } from "./chat";
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 { MOCKUP_STATES, type MockupStateId } from "./states";
export { createMockupPlayer, MockupWindow, useMockupScene } from "./window";
export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states";
export { MockupWindow } from "./window";
export { ScaledMockup } from "./scaled-mockup";
@@ -1,102 +0,0 @@
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,86 +1,15 @@
import { DiffRows, type LineReview, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
import { AddCommentAction, ReviewFeedback, ReviewThread } from "./review-thread";
import { DiffRows, REVIEW_DIFF_ROWS, ScopeRow } from "./diff";
import { useTranslation } from "react-i18next";
// --- review -----------------------------------------------------------------
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.
*/
/** The selected Changes-tree file, open in its own workspace tab. */
export function DiffReviewPane() {
const { t } = useTranslation();
return (
<div className="relative flex min-h-0 flex-1 flex-col bg-mock-surface0">
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
<ScopeRow label={t("landing.mockup.app.uncommitted")} />
<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">&gt; @getpaseo/website dev</div>
<div className="text-mock-fg-muted">&gt; 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" />
<DiffRows rows={REVIEW_DIFF_ROWS} />
</div>
);
}
@@ -1,71 +0,0 @@
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);
});
});
@@ -1,130 +0,0 @@
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);
}
@@ -1,55 +0,0 @@
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);
}
@@ -1,45 +0,0 @@
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>
);
}
@@ -1,11 +0,0 @@
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;
@@ -1,231 +0,0 @@
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>
</>
);
}
@@ -1,54 +0,0 @@
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,6 +1,5 @@
import {
CircleCheck,
CircleX,
ChevronDown,
ExternalLink,
FileDiff,
@@ -15,26 +14,12 @@ 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, 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";
import { RIGHT_PANEL_W, TABBAR_H, TITLEBAR_H } from "./geometry";
import type { MockupStateId } from "./states";
const TABBAR_STYLE = { height: TITLEBAR_H };
const SUBHEADER_STYLE = { height: TABBAR_H };
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") },
};
const PANEL_STYLE = { width: RIGHT_PANEL_W };
interface PanelTab {
label: string;
@@ -67,12 +52,10 @@ function PanelTabs({ tabs, active }: { tabs: PanelTab[]; active: number }) {
}
function PanelShell({
panel,
tabs,
active,
children,
}: {
panel: PanelId;
tabs: PanelTab[];
active: number;
children: React.ReactNode;
@@ -80,7 +63,7 @@ function PanelShell({
return (
<div
className="flex h-full shrink-0 flex-col overflow-hidden bg-mock-sidebar"
style={PANEL_STYLES[panel]}
style={PANEL_STYLE}
>
<PanelTabs tabs={tabs} active={active} />
{children}
@@ -105,7 +88,7 @@ function changesTabs(t: TFunction): PanelTab[] {
function ChangesPanel({ selectedFile }: { selectedFile?: string }) {
const { t } = useTranslation();
return (
<PanelShell panel="changes" tabs={changesTabs(t)} active={1}>
<PanelShell tabs={changesTabs(t)} active={1}>
<ChangesBranchRow />
<ChangesScopeRow />
<ExplorerChangesTree selectedFile={selectedFile} />
@@ -135,7 +118,7 @@ function CheckGlyph() {
function ShipPanel() {
const { t } = useTranslation();
return (
<PanelShell panel="ship" tabs={changesTabs(t)} active={2}>
<PanelShell tabs={changesTabs(t)} active={2}>
<div
className="flex shrink-0 items-center border-b border-mock-border px-[16px]"
style={SUBHEADER_STYLE}
@@ -252,38 +235,10 @@ function recentlyMerged(t: TFunction): { title: string; meta: string; tone: stri
];
}
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 (
<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}>
<PanelShell tabs={pluginTabs(t)} active={3}>
<div
className="flex shrink-0 items-center gap-[8px] border-b border-mock-border px-[16px]"
style={SUBHEADER_STYLE}
@@ -310,10 +265,6 @@ 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>
@@ -356,8 +307,8 @@ function PluginDashboard() {
);
}
export function RightPanel({ panel }: { panel: PanelId }) {
if (panel === "ship") return <ShipPanel />;
if (panel === "plugin") return <PluginDashboard />;
export function RightPanel({ state }: { state: MockupStateId }) {
if (state === "ship") return <ShipPanel />;
if (state === "extend") return <PluginDashboard />;
return <ChangesPanel />;
}
@@ -1,177 +0,0 @@
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);
});
});
@@ -1,267 +0,0 @@
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,6 +6,7 @@ import {
CircleHelp,
GitPullRequest,
History,
House,
PanelBottom,
PanelLeft,
Plus,
@@ -17,15 +18,10 @@ 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 };
@@ -45,7 +41,7 @@ interface WorkspaceRow {
title: string;
avatar: Avatar;
time: string;
host?: RemoteHostId;
host?: string;
pr?: string;
passed?: boolean;
running?: boolean;
@@ -69,7 +65,6 @@ const WORKING_ROWS: WorkspaceRow[] = [
id: "safe-area",
title: "Fix commit safe area in emulators",
avatar: PASEO,
host: "mac-mini",
time: "4m",
running: true,
},
@@ -87,7 +82,6 @@ const READY_TO_REVIEW_ROWS: WorkspaceRow[] = [
id: "lineage",
title: "Paseo homepage",
avatar: PASEO,
host: "devbox",
pr: "3981",
passed: true,
time: "now",
@@ -103,16 +97,6 @@ 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" },
{
@@ -127,7 +111,7 @@ const DONE_ROWS: WorkspaceRow[] = [
id: "perms",
title: "Design semantic permission sy…",
avatar: PASEO,
host: "devbox",
host: "my-server",
pr: "3981",
passed: true,
time: "7h",
@@ -136,7 +120,6 @@ const DONE_ROWS: WorkspaceRow[] = [
id: "modules",
title: "Plan modules and abstractions",
avatar: PASEO,
host: "mac-mini",
pr: "3884",
passed: true,
time: "1d",
@@ -219,7 +202,12 @@ 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 ? <HostBadge host={row.host} /> : null}
{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 && row.pr ? <span className="text-mock-fg-xmuted">·</span> : null}
{row.pr ? (
<span className="flex items-center gap-[4px]">
@@ -279,18 +267,7 @@ function GroupHeader({ icon, label }: { icon: React.ReactNode; label: string })
export function Sidebar({ state }: { state: MockupStateId }) {
const { t } = useTranslation();
// 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],
);
const extending = state === "extend";
return (
<div className="flex shrink-0 flex-col overflow-hidden bg-mock-sidebar" style={SIDEBAR_STYLE}>
@@ -344,7 +321,7 @@ export function Sidebar({ state }: { state: MockupStateId }) {
<GroupHeader icon={WORKING_ICON} label={t("landing.mockup.app.working")} />
<AnimatePresence initial={false} mode="popLayout">
{workingRows.map((row) => (
{WORKING_ROWS.map((row) => (
<motion.div key={row.id} layout initial={FADE_OUT} animate={FADE_IN} exit={FADE_OUT}>
<Row row={row} indented />
</motion.div>
@@ -363,7 +340,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} />
<Server size={14} className={state === "remote" ? "text-mock-fg" : ""} />
<House size={14} />
<CircleHelp size={14} />
<Settings size={14} />
</div>
@@ -1,9 +1,13 @@
/** 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;
/**
* 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;
export type MockupStateId = (typeof MOCKUP_STATES)[number];
export const DEFAULT_MOCKUP_STATE: MockupStateId = "orchestrate";
export const DEFAULT_MOCKUP_STATE: MockupStateId = "build";
/** Which states keep a panel docked to the right of the workspace. */
export function hasRightPanel(_state: MockupStateId): boolean {
@@ -106,17 +106,9 @@ 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 tabs = state === "review" ? [...WORKSPACE_TABS, DIFF_TAB] : WORKSPACE_TABS;
const activeId = activeTabId(state);
const review = state === "review";
const tabs = review ? [...WORKSPACE_TABS, DIFF_TAB] : WORKSPACE_TABS;
return (
<div
className="flex shrink-0 items-center gap-[5px] border-b border-mock-border pr-[16px] pl-[7px]"
@@ -126,7 +118,9 @@ export function TabStrip({ state }: { state: MockupStateId }) {
<div
key={tab.id}
className={`flex items-center gap-[7px] rounded-[7px] px-[10px] py-[5px] ${
tab.id === activeId ? "bg-mock-surface2 text-mock-fg" : "text-mock-fg-muted"
(review ? tab.id === "diff" : tab.id === "codex")
? "bg-mock-surface2 text-mock-fg"
: "text-mock-fg-muted"
}`}
>
<span>{tab.icon}</span>
+45 -131
View File
@@ -1,123 +1,57 @@
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
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 { AgentTranscript, Composer, PluginBuildTranscript } from "./chat";
import { DESIGN_HEIGHT, DESIGN_WIDTH, RIGHT_PANEL_W } from "./geometry";
import { FADE_IN, FADE_OUT, INSTANT_TRANSITION, MOCKUP_TRANSITION } from "./motion";
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 { DiffReviewPane } from "./panes";
import { RightPanel } from "./right-panel";
import { Sidebar } from "./sidebar";
import { DEFAULT_MOCKUP_STATE, hasRightPanel, type MockupStateId } from "./states";
import { 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 panel is docked on the right. The plugin's panel exists once the plugin is installed. */
function dockedPanel(state: MockupStateId, pluginIsInstalled: boolean): PanelId {
/** 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";
}
function panelKey(state: MockupStateId): string {
if (state === "ship") return "ship";
if (state === "extend" && pluginIsInstalled) return "plugin";
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">
{/* 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>
{/* 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 />}
</div>
<ChatComposer state={state} />
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
</div>
);
}
/** The docked panel's contents cross-fade when the panel it shows changes. */
function PanelCrossfade({ panel }: { panel: PanelId }) {
/** The docked panel keeps its width while its contents swap. */
function PanelCrossfade({ state }: { state: MockupStateId }) {
return (
<div className="relative min-w-0 flex-1">
<div className="relative" style={PANEL_STYLE}>
<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={panel}
key={panelKey(state)}
initial={FADE_OUT}
animate={FADE_IN}
exit={FADE_OUT}
className="absolute inset-y-0 left-0"
className="absolute inset-0"
>
<RightPanel panel={panel} />
<RightPanel state={state} />
</motion.div>
</AnimatePresence>
</div>
@@ -133,7 +67,7 @@ function CenterColumn({ state }: { state: MockupStateId }) {
<div className="relative min-h-0 flex-1">
<AnimatePresence initial={false}>
<motion.div
key={state}
key={centerKey(state)}
initial={FADE_OUT}
animate={FADE_IN}
exit={FADE_OUT}
@@ -147,59 +81,39 @@ function CenterColumn({ state }: { state: MockupStateId }) {
);
}
/** The docked right panel. Its width follows the panel it shows, plus one pixel for the divider. */
/** The docked right panel, animating its width open and closed. */
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={open}
animate={PANEL_OPEN}
exit={PANEL_COLLAPSED}
className="relative flex shrink-0 overflow-hidden"
>
<div className="w-px shrink-0 bg-mock-border" />
<PanelCrossfade panel={panel} />
<PanelCrossfade state={state} />
</motion.div>
);
}
export function MockupWindow({ player }: { player: ScenePlayer }) {
export function MockupWindow({ state }: { state: MockupStateId }) {
const reduceMotion = useReducedMotion();
const state = usePlayerScene(player);
return (
<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
<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>
{/* 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>
<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>
);
}
+2 -18
View File
@@ -446,8 +446,7 @@
},
"mockup": {
"alt": "Paseo desktop app with coding agents, a conversation, and a code diff open side by side",
"orchestrate": "Orchestration",
"remote": "Remote",
"build": "Parallel agents",
"review": "Code review",
"ship": "Pull requests",
"extend": "Plugins",
@@ -460,15 +459,6 @@
"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",
@@ -483,11 +473,6 @@
"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",
@@ -497,8 +482,7 @@
"minutesAgo": "{{count}}m ago",
"hoursAgo": "{{count}}h ago",
"reviewing": "reviewing",
"subagents_one": "{{count}} subagent",
"subagents_other": "{{count}} subagents",
"subagents": "{{count}} subagents",
"checksPassedShort": "passed"
}
}
+2 -18
View File
@@ -446,8 +446,7 @@
},
"mockup": {
"alt": "Paseo 桌面应用,并排打开了编程 Agent、一段对话和一份代码 diff",
"orchestrate": "编排",
"remote": "远程",
"build": "并行 Agent",
"review": "代码审查",
"ship": "Pull Request",
"extend": "插件",
@@ -460,15 +459,6 @@
"working": "运行中",
"done": "已完成",
"addProject": "添加 project",
"addHost": "添加主机",
"hosts": "主机",
"agentCount": "{{count}} 个 Agent",
"hostLinks": {
"local": "本地",
"relay": "中继",
"tailscale": "Tailscale",
"ssh": "SSH"
},
"files": "文件",
"changes": "变更",
"uncommitted": "未 commit",
@@ -483,11 +473,6 @@
"tasksProgress": "{{completed}}/{{total}} 项任务",
"composerPlaceholder": "给 Agent 发消息,标记 @files,或使用 /commands 和 /skills",
"mobileComposerPlaceholder": "发消息,@files,/commands",
"reviewCommentPlaceholder": "留下评论",
"reviewCancel": "取消",
"reviewSave": "评论",
"sendFeedback": "发送反馈 ({{count}})",
"feedbackSent": "已向 {{recipient}} 发送反馈",
"plugin": "插件",
"mergedWeek": "已合并 · 7 天",
"inReview": "审查中",
@@ -497,8 +482,7 @@
"minutesAgo": "{{count}} 分钟前",
"hoursAgo": "{{count}} 小时前",
"reviewing": "审查中",
"subagents_one": "{{count}} 个 subagent",
"subagents_other": "{{count}} 个 subagent",
"subagents": "{{count}} 个 subagent",
"checksPassedShort": "通过"
}
}
-4
View File
@@ -770,11 +770,7 @@
--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. */
+2 -2
View File
@@ -14,7 +14,7 @@ Every event Hub accepts is recorded, whether or not it ran anything. That record
**Project → Activity** lists routed trigger runs and their execution state.
**Connections → Known unrouted events** is the organization-level view of accepted provider events that did not start a configured trigger. Hub records one of four bounded reasons:
**Integrations → Known unrouted events** is the organization-level view of accepted provider events that did not start a configured trigger. Hub records one of four bounded reasons:
| Reason | Meaning |
| --------------------------- | ------------------------------------------------------------ |
@@ -27,7 +27,7 @@ Every event Hub accepts is recorded, whether or not it ran anything. That record
Work down this list.
1. **Is the event in the project's Activity?** If not, check **Connections → Known unrouted events** and use its reason to distinguish routing, source, filter, and configuration failures.
1. **Is the event in the project's Activity?** If not, check **Integrations → Known unrouted events** and use its reason to distinguish routing, source, filter, and configuration failures.
2. **Is the event anywhere at all?** If not, the event never reached Hub. Check the provider's own delivery log: GitHub's App → Advanced → Recent Deliveries, or Slack's Event Subscriptions page. Then check that the app is subscribed to that event type.
3. **Is your user in `from_users`?** This is the most common cause. GitHub uses your login; Slack and Discord use the user ID, not the display name. See [find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) and [find your Discord IDs](/docs/hub/triggers/discord#find-your-discord-ids).
4. **Did the invocation match?** On GitHub, `contains` must appear in the comment body. On Slack and Discord the bot must be mentioned, and `pattern` or its legacy `contains` alias must prefix the text after the mention.
+2 -2
View File
@@ -12,7 +12,7 @@ Hub 接受的每个事件都会被记录,无论它是否运行了任何内容
**Project → Activity** 列出已路由的触发器运行及其执行状态。
**Connections → Known unrouted events** 是组织级视图,列出已接受但没有启动任何已配置触发器的 Provider 事件。Hub 会记录以下四种原因之一:
**Integrations → Known unrouted events** 是组织级视图,列出已接受但没有启动任何已配置触发器的 Provider 事件。Hub 会记录以下四种原因之一:
| 原因 | 含义 |
| --------------------------- | -------------------------------------------- |
@@ -25,7 +25,7 @@ Hub 接受的每个事件都会被记录,无论它是否运行了任何内容
按这个列表逐项排查。
1. **事件是否出现在项目的 Activity 中?** 如果没有,请查看 **Connections → Known unrouted events**,根据其中的原因区分路由、来源、过滤器和配置方面的失败。
1. **事件是否出现在项目的 Activity 中?** 如果没有,请查看 **Integrations → Known unrouted events**,根据其中的原因区分路由、来源、过滤器和配置方面的失败。
2. **事件是否出现在任何地方?** 如果没有,说明事件从未到达 Hub。请查看 Provider 自己的投递日志:GitHub 的 App → Advanced → Recent Deliveries,或 Slack 的 Event Subscriptions 页面。然后确认应用订阅了该事件类型。
3. **你的用户是否在 `from_users` 中?** 这是最常见的原因。GitHub 使用你的登录名;Slack 和 Discord 使用用户 ID,而不是显示名称。参见[查找 Slack ID](/docs/hub/triggers/slack#find-your-slack-ids) 和[查找 Discord ID](/docs/hub/triggers/discord#find-your-discord-ids)。
4. **调用是否匹配?** 在 GitHub 上,评论正文中必须出现 `contains`。在 Slack 和 Discord 上,必须提及机器人,并且提及之后的文本必须以 `pattern`(或其旧别名 `contains`)开头。
+2 -2
View File
@@ -18,7 +18,7 @@ Each organization trigger is one self-contained YAML file. Keep triggers in your
## Generated starter trigger
Run `paseo hub init` from the repository the agent should work in. Setup selects an app connection and an available agent runtime, asks which user may trigger it, validates the result, and writes one file. It then asks whether to deploy. Interactive `paseo hub login` connects the daemon and points to this command; it does not write trigger files.
Run `paseo hub init` from the repository the agent should work in. Setup selects an integration connection and an available agent runtime, asks which user may trigger it, validates the result, and writes one file. It then asks whether to deploy. Interactive `paseo hub login` connects the daemon and points to this command; it does not write trigger files.
For a Slack connection named `my-team`, the generated document looks like this:
@@ -56,7 +56,7 @@ run:
required: true
```
`connection` is the app connection's slug in your organization. `target.daemon` is the connected daemon's slug and `target.cwd` is the absolute directory where you ran setup. `agent` contains the provider, model, and execution mode you selected from the daemon. The mode is required.
`connection` is the integration connection's slug in your organization. `target.daemon` is the connected daemon's slug and `target.cwd` is the absolute directory where you ran setup. `agent` contains the provider, model, and execution mode you selected from the daemon. The mode is required.
`continuation.mode: conversation` keeps follow-ups in the same provider conversation on the same agent. The prompt asks the agent to reply and then call `hub.finish_execution`; replying alone does not finish the execution.
+2 -2
View File
@@ -16,7 +16,7 @@ nav: 配置
## 生成的起步触发器 {#generated-starter-trigger}
在 Agent 要工作的仓库中运行 `paseo hub init`。设置流程会选择一个应用连接和一个可用的 Agent 运行时,询问哪个用户可以触发它,校验结果并写入一个文件,然后询问是否部署。交互式的 `paseo hub login` 会连接 daemon 并提示你运行这条命令;它不会写入触发器文件。
在 Agent 要工作的仓库中运行 `paseo hub init`。设置流程会选择一个集成连接和一个可用的 Agent 运行时,询问哪个用户可以触发它,校验结果并写入一个文件,然后询问是否部署。交互式的 `paseo hub login` 会连接 daemon 并提示你运行这条命令;它不会写入触发器文件。
对于名为 `my-team` 的 Slack 连接,生成的文档如下:
@@ -54,7 +54,7 @@ run:
required: true
```
`connection` 是应用连接在你组织中的 slug。`target.daemon` 是已连接 daemon 的 slug,`target.cwd` 是你运行设置流程时所在的绝对目录。`agent` 包含你从 daemon 中选择的 Provider、模型和执行模式。模式是必填的。
`connection` 是集成连接在你组织中的 slug。`target.daemon` 是已连接 daemon 的 slug,`target.cwd` 是你运行设置流程时所在的绝对目录。`agent` 包含你从 daemon 中选择的 Provider、模型和执行模式。模式是必填的。
`continuation.mode: conversation` 让后续请求在同一个 Agent 的同一段 Provider 对话中进行。提示词要求 Agent 先回复,再调用 `hub.finish_execution`;只回复并不会结束执行。
+1 -1
View File
@@ -75,7 +75,7 @@ paseo hub permissions grant hub.execute
paseo hub permissions revoke hub.execute
```
Until it is granted, Hub keeps the daemon for identity and presence only. The Daemons page shows it as **Connected only** with the grant command, and Home's checklist reads **Cannot run agents**. [Hub security](/docs/hub/security#choose-daemon-authority) covers what the permission allows once granted.
Until it is granted, Hub keeps the daemon for identity and presence only. The Daemons page shows it as **Connected only** with the grant command, and Home's checklist says the daemon cannot run agents yet. [Hub security](/docs/hub/security#choose-daemon-authority) covers what the permission allows once granted.
## Reference it from configuration
+1 -1
View File
@@ -73,7 +73,7 @@ paseo hub permissions grant hub.execute
paseo hub permissions revoke hub.execute
```
在授予之前,Hub 只把该 daemon 用于身份和在线状态。Daemons 页面会把它显示为 **Connected only** 并给出授权命令,Home 清单会显示 **Cannot run agents**。授予后该权限允许做什么,见 [Hub 安全](/docs/hub/security#choose-daemon-authority)。
在授予之前,Hub 只把该 daemon 用于身份和在线状态。Daemons 页面会把它显示为 **Connected only** 并给出授权命令,Home 清单会提示该 daemon 还无法运行 Agent。授予后该权限允许做什么,见 [Hub 安全](/docs/hub/security#choose-daemon-authority)。
## 在配置中引用它 {#reference-it-from-configuration}
+1 -1
View File
@@ -10,6 +10,6 @@ category: Hub
[Paseo Hub](https://hub.paseo.sh) is the managed service. Create an account and your organization starts on the Free plan: one seat, a monthly allowance of agent runs, no card, and no expiry. Upgrade to Pro from **Billing** in Hub when you want unlimited runs, more seats, or invitations. The [Hub page](/hub) carries the current price and the figures each plan includes.
Signing in opens **Home**, the same four-step checklist the [quickstart](/docs/hub/quickstart) walks through: connect an app, connect a daemon, create a trigger, run it.
Signing in opens **Home**, the same four-step checklist the [quickstart](/docs/hub/quickstart) walks through: connect an integration, connect a daemon, create a trigger, run it.
To start now, [run Hub yourself](/docs/hub/self-hosting). Projects, configuration, triggers, daemons, and activity use the same model in both forms. The managed service owns its GitHub App, Slack app, and Discord application; a self-hosted Hub uses apps you create and control, and has no plans or billing at all.
+1 -1
View File
@@ -8,6 +8,6 @@ nav: 托管版
[Paseo Hub](https://hub.paseo.sh) 是托管服务。创建账号后,你的组织从 Free 套餐开始:一个席位、每月一定额度的 Agent 运行次数,无需绑卡,也不会过期。需要无限次运行、更多席位或邀请成员时,在 Hub 的 **Billing** 中升级到 Pro。[Hub 页面](/hub)列出了当前价格以及各套餐包含的额度。
登录后会打开 **Home**,也就是[快速开始](/docs/hub/quickstart)中走过的四步清单:连接一个应用、连接一个 daemon、创建一个触发器、运行它。
登录后会打开 **Home**,也就是[快速开始](/docs/hub/quickstart)中走过的四步清单:连接一个集成、连接一个 daemon、创建一个触发器、运行它。
想现在就开始,可以[自己运行 Hub](/docs/hub/self-hosting)。两种形式中的项目、配置、触发器、daemon 和活动使用相同的模型。托管服务拥有自己的 GitHub App、Slack 应用和 Discord 应用;自托管的 Hub 使用你自己创建和控制的应用,并且完全没有套餐或计费。
+1 -1
View File
@@ -36,7 +36,7 @@ Your daemons keep running agents where they always did. Hub decides when to ask
└── slack-help.yml
```
The file names the app connection, allowed user, daemon, working directory, agent runtime, prompt, and outputs. Setup validates it and asks whether to deploy. Mentioning the bot then starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter trigger](/docs/hub/configuration#generated-starter-trigger) shows what setup wrote.
The file names the integration connection, allowed user, daemon, working directory, agent runtime, prompt, and outputs. Setup validates it and asks whether to deploy. Mentioning the bot then starts an agent on your machine. [Quickstart](/docs/hub/quickstart) runs it end to end; the [generated starter trigger](/docs/hub/configuration#generated-starter-trigger) shows what setup wrote.
## Reading order
+1 -1
View File
@@ -34,7 +34,7 @@ daemon 在一台机器上为你运行 Agent。Paseo Hub 是位于 daemon 之上
└── slack-help.yml
```
这个文件指定了应用连接、允许的用户、daemon、工作目录、Agent 运行时、提示词和输出。设置流程会校验它并询问是否部署。之后提及机器人,就会在你的机器上启动一个 Agent。[快速开始](/docs/hub/quickstart)完整地跑通了这个过程;[生成的起步触发器](/docs/hub/configuration#generated-starter-trigger)展示了设置流程写入的内容。
这个文件指定了集成连接、允许的用户、daemon、工作目录、Agent 运行时、提示词和输出。设置流程会校验它并询问是否部署。之后提及机器人,就会在你的机器上启动一个 Agent。[快速开始](/docs/hub/quickstart)完整地跑通了这个过程;[生成的起步触发器](/docs/hub/configuration#generated-starter-trigger)展示了设置流程写入的内容。
## 阅读顺序 {#reading-order}
+4 -4
View File
@@ -22,8 +22,6 @@ Open the address it prints, normally <http://localhost:3000>, and create the ope
The first run needs no database, Docker, environment variables, or API keys. Hub creates an embedded database and your organization.
Hub opens **Home**, a checklist of the four things that have to be true before a mention starts an agent: connect an app, connect a daemon, create a trigger, run it. The steps below are those four. Home derives each one from what your organization actually has, prints the command the next step needs, and collapses once all four are done.
## 2. Connect Slack
**Set up your apps** explains how to create the Slack app and gives you a manifest to paste into Slack. Keep **Socket Mode** selected. It connects out from Hub and needs no public address or HTTPS.
@@ -48,6 +46,8 @@ Run it on the machine where your code lives, in the repository the agent should
Approve the login in the browser tab that opens. Leave the Hub tab open: it watches for the daemon and shows **Daemon connected** by itself. Choose **Continue** when the daemon is connected.
Hub opens **Home** with four **Get started** steps: **Connect an integration**, **Connect a daemon**, **Create a trigger**, and **Run it**. The first unfinished step shows a hint and an action. Completed steps show a check and their result; later steps show only their title. The checklist collapses once all four are done.
## 4. Create the starter trigger
After approving login, answer **Yes** to **Connect this daemon to Paseo Hub?** and **Allow Hub automations to run agents on this daemon?**. Execution permission defaults to no, so enable it explicitly for this setup. If you answered no, Home shows the daemon as connected but unable to run agents, with the command that fixes it: `paseo hub permissions grant hub.execute`.
@@ -58,7 +58,7 @@ Then run:
paseo hub init
```
Choose **Custom endpoint** and confirm `http://localhost:3000`. Setup reuses your login and daemon connection, then lists the app connections ready for this trigger. With one Slack workspace connected, it selects that connection automatically. With several usable connections, choose the **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files.
Choose **Custom endpoint** and confirm `http://localhost:3000`. Setup reuses your login and daemon connection, then lists the integration connections ready for this trigger. With one Slack workspace connected, it selects that connection automatically. With several usable connections, choose the **Trigger connection**. If none is ready, setup sends you to **Hub → Apps** and stops before selecting an agent or writing files.
| Question | What it wants |
| --------------------------------------- | --------------------------------------------------------------------------------------------- |
@@ -68,7 +68,7 @@ Choose **Custom endpoint** and confirm `http://localhost:3000`. Setup reuses you
Providers must expose a selectable model and execution mode. If there is no default mode, choose the one the agent should use. Hub runs agents unattended, so it accepts only Claude, Codex, and OpenCode, and it checks the model, mode, and options against your daemon before deploying. Another provider, or a value the daemon does not offer, is refused here rather than at the first run.
[Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) explains how to copy your member ID. The Slack workspace comes from the selected app connection.
[Find your Slack IDs](/docs/hub/triggers/slack#find-your-slack-ids) explains how to copy your member ID. The Slack workspace comes from the selected connection.
Setup validates the trigger and writes:
+4 -4
View File
@@ -20,8 +20,6 @@ npx @getpaseo/hub
首次运行不需要数据库、Docker、环境变量或 API key。Hub 会创建内嵌数据库和你的组织。
Hub 会打开 **Home**,这是一份清单,列出了提及能够启动 Agent 之前必须满足的四件事:连接一个应用、连接一个 daemon、创建一个触发器、运行它。下面的步骤就是这四件事。Home 会根据你的组织实际拥有的内容判断每一项,打印下一步需要的命令,并在四项全部完成后收起。
## 2. 连接 Slack {#2-connect-slack}
**Set up your apps** 说明了如何创建 Slack 应用,并提供一份可以粘贴到 Slack 中的 manifest。保持选择 **Socket Mode**。它由 Hub 主动向外连接,不需要公开地址或 HTTPS。
@@ -46,6 +44,8 @@ paseo hub login http://localhost:3000
在打开的浏览器标签页中批准登录。保持 Hub 标签页打开:它会监测 daemon,并自动显示 **Daemon connected**。daemon 连接后,选择 **Continue**。
Hub 会打开 **Home**,其中的 **Get started** 包含四个步骤:**Connect an integration**、**Connect a daemon**、**Create a trigger** 和 **Run it**。第一个未完成的步骤会显示提示和操作。已完成的步骤会显示勾选标记和结果,后续步骤只显示标题。四项全部完成后,清单会收起。
## 4. 创建起步触发器 {#4-create-the-starter-trigger}
批准登录后,对 **Connect this daemon to Paseo Hub?** 和 **Allow Hub automations to run agents on this daemon?** 都回答 **Yes**。执行权限默认为否,因此请为本次设置显式开启。如果你回答了否,Home 会显示 daemon 已连接但无法运行 Agent,并给出修复命令:`paseo hub permissions grant hub.execute`。
@@ -56,7 +56,7 @@ paseo hub login http://localhost:3000
paseo hub init
```
选择 **Custom endpoint** 并确认 `http://localhost:3000`。设置流程会复用你的登录和 daemon 连接,然后列出可用于此触发器的应用连接。如果只连接了一个 Slack 工作区,它会自动选择该连接。如果有多个可用连接,请选择 **Trigger connection**。如果没有任何连接就绪,设置流程会把你引导到 **Hub → Apps**,并在选择 Agent 或写入文件之前停止。
选择 **Custom endpoint** 并确认 `http://localhost:3000`。设置流程会复用你的登录和 daemon 连接,然后列出可用于此触发器的集成连接。如果只连接了一个 Slack 工作区,它会自动选择该连接。如果有多个可用连接,请选择 **Trigger connection**。如果没有任何连接就绪,设置流程会把你引导到 **Hub → Apps**,并在选择 Agent 或写入文件之前停止。
| 问题 | 需要填写的内容 |
| ---------------------------------- | -------------------------------------------------------------------------- |
@@ -66,7 +66,7 @@ paseo hub init
Provider 必须提供可选择的模型和执行模式。如果没有默认模式,请选择 Agent 应该使用的模式。Hub 以无人值守的方式运行 Agent,因此只接受 Claude、Codex 和 OpenCode,并会在部署前根据你的 daemon 检查模型、模式和选项。其他 Provider,或 daemon 不提供的值,会在这里就被拒绝,而不是等到第一次运行。
[查找 Slack ID](/docs/hub/triggers/slack#find-your-slack-ids) 说明了如何复制你的成员 ID。Slack 工作区来自所选的应用连接。
[查找 Slack ID](/docs/hub/triggers/slack#find-your-slack-ids) 说明了如何复制你的成员 ID。Slack 工作区来自所选的集成连接。
设置流程会校验触发器并写入:
+1 -1
View File
@@ -44,4 +44,4 @@ Leading declared inputs follow the mention. Hub exposes the remaining text as `$
Turn on **Settings → Advanced → Developer Mode**, then right-click to **Copy Server ID** for `guild`, **Copy Channel ID** for `channels`, and **Copy User ID** for `from_users`. All three are numeric snowflakes; quote them so YAML keeps them as strings.
[Guided setup](/docs/hub/quickstart) fills `guild` from the Discord app you connected and asks you for your user ID.
[Guided setup](/docs/hub/quickstart) selects the Discord connection and asks for your user ID. The connection limits the trigger to that server.
+1 -1
View File
@@ -42,4 +42,4 @@ Discord 过滤器使用 ID,而不是服务器名称、显示名称或 Hub 连
打开 **Settings → Advanced → Developer Mode**,然后右键选择 **Copy Server ID** 获取 `guild`,**Copy Channel ID** 获取 `channels`,**Copy User ID** 获取 `from_users`。三者都是数字 snowflake;请加上引号,让 YAML 把它们当作字符串。
[引导式设置](/docs/hub/quickstart)会根据你连接的 Discord 应用填好 `guild`,并询问你的用户 ID。
[引导式设置](/docs/hub/quickstart)会选择 Discord 连接,并询问你的用户 ID。该连接会将触发器限制在对应的服务器。
+1 -1
View File
@@ -54,4 +54,4 @@ Hub consumes consecutive declared headers and exposes the remainder as `${{ pase
| `from_users` | Your avatar → **Profile** → **⋮** → **Copy member ID**. Member IDs start with `U`. |
| `channels` | Channel name → **About**. The channel ID is at the bottom of the panel and starts with `C`. |
[Guided setup](/docs/hub/quickstart) fills `workspace` from the Slack app you connected and asks you for your member ID, so a generated starter already carries both.
[Guided setup](/docs/hub/quickstart) selects the Slack connection and asks for your member ID. The connection limits the trigger to that workspace.
+1 -1
View File
@@ -52,4 +52,4 @@ Hub 会读取连续的已声明头部,并把剩余部分作为 `${{ paseo.prom
| `from_users` | 你的头像 → **Profile** → **⋮** → **Copy member ID**。成员 ID 以 `U` 开头。 |
| `channels` | 频道名称 → **About**。频道 ID 位于面板底部,以 `C` 开头。 |
[引导式设置](/docs/hub/quickstart)会根据你连接的 Slack 应用填好 `workspace`,并询问你的成员 ID,因此生成的起步配置已经包含这两项。
[引导式设置](/docs/hub/quickstart)会选择 Slack 连接,并询问你的成员 ID。该连接会将触发器限制在对应的工作区。
+3 -6
View File
@@ -1279,12 +1279,9 @@ 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.
`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`.
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`.
## Theme and layout
+1 -3
View File
@@ -1060,9 +1060,7 @@ async function publishReview(agentId: string, { paseo }: PluginHandlerContext) {
| `version` | 正整数 | 是 | 选择渲染器契约的版本。 |
| `data` | 兼容 JSON | 是 | 渲染器负载,JSON 序列化后最多 64 KiB。 |
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。
`kind` 以小写字母开头,只包含小写字母、数字或连字符,与 `addTimelineRenderer` 使用的规则相同。daemon 会拒绝其他任何 `kind`。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
daemon 会根据发起调用的插件会话写入 `pluginId`,并拒绝来自非插件会话的此 RPC。该行会实时出现,在时间线重新获取后依然保留,并且对于同一插件和 `id` 只保留最新的值。如果它的渲染器缺失,Paseo 会显示现有的“不可用”行。daemon 会拒绝超过限制的 `data`,而不是截断它。支持此操作的 daemon 会声明 `server_info.features.pluginTimelineItems`。
## 主题与布局 {#theme-and-layout}