From f22775a8e14908052e4b933c7e8007663449c7c4 Mon Sep 17 00:00:00 2001 From: Derick Wells Date: Sat, 17 Oct 2026 21:39:05 +0800 Subject: [PATCH 01/23] Default agent-to-agent messages to steering in CLI and MCP (#5164) * Let agents steer a running turn from send_agent_prompt and paseo send When an agent used the send_agent_prompt MCP tool on a target that was mid-turn, sendPromptToAgent ran without activeTurnBehavior, so the prompt path fell through to replaceAgentRun. That cancelled the target's active turn and every provider subagent it had in flight. The web UI, child finish notifications and schedules already pass "steer"; the MCP tool and the CLI had no way to ask for it. Add an optional activeTurnBehavior input to send_agent_prompt. It defaults to "steer" for agent-scoped callers and to "interrupt" for top-level callers, so existing top-level behaviour is unchanged. Add --steer to paseo send. Document both and cover them with tests. * fix(agents): retain finish notifications during steering acknowledgement Observe completion before dispatch and hold delivery until acceptance. A replacement discards the displaced turn observation and starts a fresh listener before its iterator runs. * fix(agents): preserve notification ownership when follow-ups fail Keep the previous observation until dispatch succeeds and suppress duplicate delivery during acknowledgement. Decide whether to retain the running turn from the dispatch result, including side commands requested with interrupt. * fix(agents): deduplicate notifications across overlapping follow-ups --------- Co-authored-by: Mohamed Boudra --- packages/cli/src/cli-surface.test.ts | 16 + packages/cli/src/commands/agent/send.ts | 14 +- .../server/src/server/agent/agent-prompt.ts | 66 +++- .../src/server/agent/mcp-server.test.ts | 310 +++++++++++++++++- .../src/server/agent/tools/paseo-tools.ts | 52 ++- public-docs/cli.md | 5 +- public-docs/orchestration-workflows.md | 2 +- skills/paseo/SKILL.md | 2 +- 8 files changed, 440 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/cli-surface.test.ts b/packages/cli/src/cli-surface.test.ts index 18f4348ff7..83db3408e6 100644 --- a/packages/cli/src/cli-surface.test.ts +++ b/packages/cli/src/cli-surface.test.ts @@ -2,6 +2,22 @@ import { describe, expect, it } from "vitest"; import { createCli } from "./cli.js"; describe("canonical CLI surface", () => { + it.each(["send", "agent send"])("%s accepts an explicit behavior override", async (path) => { + const cli = createCli(); + const owner = + path === "send" ? cli : cli.commands.find((command) => command.name() === "agent")!; + const send = owner.commands.find((command) => command.name() === "send")!; + send.action(() => {}); + await send.parseAsync(["target", "message", "--behavior", "interrupt"], { from: "user" }); + expect(send.opts().behavior).toBe("interrupt"); + await send.parseAsync(["target", "message", "--behavior", "steer"], { from: "user" }); + expect(send.opts().behavior).toBe("steer"); + send.exitOverride().configureOutput({ writeErr: () => {} }); + await expect( + send.parseAsync(["target", "message", "--behavior", "queue"], { from: "user" }), + ).rejects.toThrow(); + }); + it("offers daemon host selection as a global option", () => { expect(createCli().helpInformation()).toContain("--host "); }); diff --git a/packages/cli/src/commands/agent/send.ts b/packages/cli/src/commands/agent/send.ts index 69e0c36d9d..1f288d4ca2 100644 --- a/packages/cli/src/commands/agent/send.ts +++ b/packages/cli/src/commands/agent/send.ts @@ -1,4 +1,5 @@ -import { type Command } from "commander"; +import { type Command, Option } from "commander"; +import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages"; import { collectMultiple } from "../../utils/command-options.js"; import { connectToDaemon } from "../../utils/client.js"; import type { @@ -28,6 +29,7 @@ export const agentSendSchema: OutputSchema = { }; export interface AgentSendOptions extends CommandOptions { + behavior?: ActiveTurnBehavior; wait?: boolean; image?: string[]; prompt?: string; @@ -42,6 +44,12 @@ export function addSendOptions(cmd: Command): Command { .option("--prompt ", "Provide the message inline as a flag") .option("--prompt-file ", "Read the message from a UTF-8 text file") .option("--image ", "Attach image(s) to the message", collectMultiple, []) + .addOption( + new Option( + "--behavior ", + "Active-turn behavior (agent default: steer; otherwise: interrupt)", + ).choices(["interrupt", "steer"]), + ) .option("--no-wait", "Return immediately without waiting for completion"); } @@ -191,10 +199,12 @@ export async function runSendCommand( const images = options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined; + const sourceAgentId = process.env.PASEO_AGENT_ID?.trim() || undefined; // Send the message await client.sendAgentMessage(agentIdArg, promptInput, { images, - sourceAgentId: process.env.PASEO_AGENT_ID?.trim() || undefined, + sourceAgentId, + activeTurnBehavior: options.behavior ?? (sourceAgentId ? "steer" : "interrupt"), }); // If --no-wait, return immediately diff --git a/packages/server/src/server/agent/agent-prompt.ts b/packages/server/src/server/agent/agent-prompt.ts index 4044fd84bc..bf6d50480b 100644 --- a/packages/server/src/server/agent/agent-prompt.ts +++ b/packages/server/src/server/agent/agent-prompt.ts @@ -402,6 +402,8 @@ export interface SetupFinishNotificationParams { childAgentId: string; callerAgentId: string; requireParentOwnership?: boolean; + /** Retain pre-dispatch outcomes only when dispatch keeps the existing turn. */ + dispatchResult?: Promise; /** A replacement must observe its new start, not the displaced running snapshot. */ waitForTurnStart?: boolean; logger: Logger; @@ -466,10 +468,17 @@ interface NotifySafelyOptions { permissionRequest?: AgentPermissionRequest; } -// A caller waits on a child through one armed notification. Arming again, such as a -// follow-up prompt while the child still runs, replaces the earlier one so the child's -// next finish reaches the caller once. -const armedFinishNotifications = new WeakMap void>>(); +// Pending follow-ups coexist until acknowledgement. A successful dispatch replaces +// every earlier observation for the caller/child pair; a failure removes only itself. +interface ArmedFinishNotification { + stop(): void; + supersedeWhen(accepted: Promise): void; +} + +const armedFinishNotifications = new WeakMap< + AgentManager, + Map> +>(); export function setupFinishNotification(params: SetupFinishNotificationParams): () => void { const { @@ -491,15 +500,49 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): const armedByManager = armedFinishNotifications.get(agentManager) ?? new Map(); armedFinishNotifications.set(agentManager, armedByManager); const armedKey = JSON.stringify([childAgentId, callerAgentId]); - armedByManager.get(armedKey)?.(); - armedByManager.set(armedKey, stop); + const registrations = armedByManager.get(armedKey) ?? new Set(); + armedByManager.set(armedKey, registrations); + const previous = [...registrations]; + let superseded = Promise.resolve(false); + let dispatchPending = Boolean(params.dispatchResult); + const registration: ArmedFinishNotification = { + stop, + supersedeWhen(accepted) { + superseded = superseded.then( + async (alreadySuperseded) => alreadySuperseded || (await accepted), + ); + }, + }; + registrations.add(registration); + if (params.dispatchResult) { + const dispatchAccepted = params.dispatchResult.then((result) => result !== false); + for (const observer of previous) observer.supersedeWhen(dispatchAccepted); + void params.dispatchResult.then((accepted) => { + dispatchPending = false; + if (accepted) { + for (const observer of previous) observer.stop(); + if (stopped || accepted === "turn_started") stop(); + } else { + stop(); + } + return undefined; + }); + } else { + for (const observer of previous) { + observer.supersedeWhen(Promise.resolve(true)); + observer.stop(); + } + } function stop(): void { - if (stopped) return; stopped = true; unsubscribe?.(); - if (armedByManager.get(armedKey) === stop) { - armedByManager.delete(armedKey); + // Pending observations remain visible to overlapping sends until acknowledgement. + if (!dispatchPending) { + registrations.delete(registration); + if (registrations.size === 0 && armedByManager.get(armedKey) === registrations) { + armedByManager.delete(armedKey); + } } } @@ -507,6 +550,11 @@ export function setupFinishNotification(params: SetupFinishNotificationParams): reason: FinishNotificationReason, permissionRequest?: AgentPermissionRequest, ): Promise { + if (params.dispatchResult) { + const result = await params.dispatchResult; + if (result === false || result === "turn_started") return; + } + if (await superseded) return; const callerRecord = await agentStorage.get(callerAgentId); if (callerRecord?.archivedAt) { return; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 33467b22f6..9246f463d0 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -1,3 +1,4 @@ +import { setupFinishNotification, type PromptDispatchDisposition } from "./agent-prompt.js"; import { parseAgentMessage } from "./agent-messages/index.js"; import { execFileSync } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -242,6 +243,7 @@ function buildAgentManagerSpies() { appendTimelineItem: vi.fn().mockResolvedValue(undefined), emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined), hasInFlightRun: vi.fn().mockReturnValue(false), + steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }), tryRunOutOfBand: vi.fn().mockReturnValue(false), subscribe: vi.fn().mockReturnValue(() => {}), streamAgent: vi.fn(() => (async function* noop() {})()), @@ -3860,6 +3862,9 @@ const HELD_TURN_CAPABILITIES = { } as const; interface HeldTurnOptions { + finishDuringSteer?: boolean; + rejectSteer?: boolean; + steerResult?: "accepted" | "unavailable"; /** Run the whole turn before the turn-start request returns. */ finishTurnsDuringStart?: boolean; } @@ -3872,6 +3877,7 @@ class HeldTurnAgentSession implements AgentSession { readonly capabilities = HELD_TURN_CAPABILITIES; readonly id = randomUUID(); readonly prompts: string[] = []; + readonly steeredPrompts: AgentPromptInput[] = []; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private activeTurnId: string | null = null; private turnStartGate: Promise | null = null; @@ -3904,6 +3910,14 @@ class HeldTurnAgentSession implements AgentSession { return prompt === "/side-command" ? { run: async () => {} } : null; } + async steerActiveTurn(prompt: AgentPromptInput) { + const status = this.options.steerResult ?? "unavailable"; + if (status === "accepted") this.steeredPrompts.push(prompt); + if (status === "accepted" && this.options.finishDuringSteer) this.finishTurn(); + if (this.options.rejectSteer) throw new Error("Steer rejected"); + return { status }; + } + async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> { this.prompts.push(typeof prompt === "string" ? prompt : JSON.stringify(prompt)); await this.turnStartGate; @@ -4050,6 +4064,295 @@ class HeldTurnAgentClient implements AgentClient { } describe("send_agent_prompt MCP tool", () => { + it.each([ + { agentScoped: true, behavior: undefined, available: true, steered: true }, + { agentScoped: true, behavior: "interrupt", available: true, steered: false }, + { agentScoped: true, behavior: "steer", available: true, steered: true }, + { agentScoped: false, behavior: undefined, available: true, steered: false }, + { agentScoped: false, behavior: "steer", available: true, steered: true }, + { agentScoped: true, behavior: undefined, available: false, steered: false }, + ] as const)( + "dispatches behavior $behavior for agent-scoped=$agentScoped, steering available=$available", + async ({ agentScoped, behavior, available, steered }) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true, { + steerResult: available ? "accepted" : "unavailable", + }); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: agentScoped ? parent.id : undefined, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + const tool = registeredTool(server, "send_agent_prompt"); + await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "Original work", + notifyOnFinish: false, + }); + const originalTurn = manager.getAgent(child.id)!.activeTurnId; + const response = await invokeToolWithParsedInput(tool, { + agentId: child.id, + prompt: "New message", + notifyOnFinish: false, + ...(behavior ? { activeTurnBehavior: behavior } : {}), + }); + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: steered ? "steered" : "turn_started", + }); + expect(client.sessions[1]!.prompts).toHaveLength(steered ? 1 : 2); + expect(client.sessions[1]!.steeredPrompts).toHaveLength(steered ? 1 : 0); + expect(manager.getAgent(child.id)!.activeTurnId === originalTurn).toBe(steered); + } finally { + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + + it.each([ + { rejected: false, priorNotification: false }, + { rejected: true, priorNotification: false }, + { rejected: false, priorNotification: true }, + ])( + "buffers completion until steer acknowledgement (rejected: $rejected, prior notification: $priorNotification)", + async ({ rejected, priorNotification }) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true, { + steerResult: "accepted", + finishDuringSteer: true, + rejectSteer: rejected, + }); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let mcpClient: Client | undefined; + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { workspaceId: "wks_parent" }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + mcpClient = await connectInMemoryMcpClient(server); + const send = (input: Record) => + mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + await send({ + agentId: child.id, + prompt: "Original work", + notifyOnFinish: priorNotification, + }); + const sending = send({ + agentId: child.id, + prompt: "New message", + notifyOnFinish: true, + }); + if (rejected) { + expect(await sending).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Steer rejected") }], + }); + // Flush asynchronous notification work before checking that a rejected + // dispatch did not notify. Then prove a later successful send still can. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(client.sessions[0]!.prompts).toHaveLength(0); + await send({ + agentId: child.id, + prompt: "Retry after rejection", + }); + client.sessions[1]!.finishTurn(); + } else { + const response = await sending; + expect(response.structuredContent).toMatchObject({ + success: true, + disposition: "steered", + }); + } + await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1)); + expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: child.id, event: "finished" }, + }); + } finally { + await mcpClient?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + + it.each([false, true])( + "preserves the original notification after rejection (completion during send: %s)", + async (finishDuringSteer) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true, { + steerResult: "accepted", + finishDuringSteer, + rejectSteer: true, + }); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let mcpClient: Client | undefined; + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { + workspaceId: "wks_parent", + }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { + workspaceId: "wks_parent", + }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + mcpClient = await connectInMemoryMcpClient(server); + const send = (input: Record) => + mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + await send({ + agentId: child.id, + prompt: "Original work", + notifyOnFinish: true, + }); + const sending = send({ + agentId: child.id, + prompt: "New message", + notifyOnFinish: true, + }); + expect(await sending).toMatchObject({ + isError: true, + content: [{ type: "text", text: expect.stringContaining("Steer rejected") }], + }); + if (!finishDuringSteer) { + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(client.sessions[0]!.prompts).toHaveLength(0); + client.sessions[1]!.finishTurn(); + } + await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1)); + expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: child.id, event: "finished" }, + }); + } finally { + await mcpClient?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + + it.each([ + [false, true], + [true, false], + [false, false], + [true, true], + ])( + "overlapping follow-ups notify once (first accepted: %s, second accepted: %s)", + async (firstAccepted, secondAccepted) => { + const workdir = await mkdtemp(join(tmpdir(), "mcp-send-behavior-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldTurnAgentClient("codex", true, { + steerResult: "accepted", + finishDuringSteer: false, + rejectSteer: true, + }); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + let mcpClient: Client | undefined; + try { + const parent = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { + workspaceId: "wks_parent", + }, + ); + const child = await manager.createAgent( + { provider: "codex", cwd: existingCwd }, + undefined, + { + workspaceId: "wks_parent", + }, + ); + const server = await createAgentMcpServer({ + agentManager: manager, + agentStorage: storage, + callerAgentId: parent.id, + providerSnapshotManager: createOpenCodeManager().manager, + logger, + }); + mcpClient = await connectInMemoryMcpClient(server); + const send = (input: Record) => + mcpClient!.callTool({ name: "send_agent_prompt", arguments: input }); + await send({ + agentId: child.id, + prompt: "Original work", + notifyOnFinish: true, + }); + let firstResolve!: (result: PromptDispatchDisposition | false) => void; + let secondResolve!: (result: PromptDispatchDisposition | false) => void; + const first = new Promise((resolve) => { + firstResolve = resolve; + }); + const second = new Promise((resolve) => { + secondResolve = resolve; + }); + for (const dispatchResult of [first, second]) { + setupFinishNotification({ + agentManager: manager, + agentStorage: storage, + childAgentId: child.id, + callerAgentId: parent.id, + dispatchResult, + logger, + }); + } + firstResolve(firstAccepted ? "steered" : false); + await first; + secondResolve(secondAccepted ? "steered" : false); + await second; + client.sessions[1]!.finishTurn(); + await vi.waitFor(() => expect(client.sessions[0]!.prompts).toHaveLength(1)); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(client.sessions[0]!.prompts).toHaveLength(1); + expect(parseAgentMessage(client.sessions[0]!.prompts[0])).toMatchObject({ + source: { kind: "agent-notification", agentId: child.id, event: "finished" }, + }); + } finally { + await mcpClient?.close(); + await removeAgentStateDir(manager, storage, workdir); + } + }, + ); + const logger = createTestLogger(); const existingCwd = process.cwd(); @@ -4100,7 +4403,7 @@ describe("send_agent_prompt MCP tool", () => { const response = await tool.handler(parsed.data as Record); - expect(spies.agentManager.subscribe).toHaveBeenCalledTimes(1); + expect(spies.agentManager.subscribe).toHaveBeenCalled(); expect(spies.agentManager.waitForAgentEvent).not.toHaveBeenCalled(); expect(response.structuredContent.guidance).toBe( "You will get notified when the prompted agent finishes, errors, or needs permission. Do not poll for status; continue with other work until the notification arrives.", @@ -4270,7 +4573,7 @@ describe("send_agent_prompt MCP tool", () => { }, ); - it.each(["out_of_band", "startup_failure"] as const)( + it.each(["out_of_band", "out_of_band_interrupt", "startup_failure"] as const)( "preserves %s dispatch notification semantics", async (scenario) => { const workdir = await mkdtemp(join(tmpdir(), "mcp-dispatch-notification-")); @@ -4306,10 +4609,11 @@ describe("send_agent_prompt MCP tool", () => { prompt: "Ongoing task", notifyOnFinish: false, }); - if (scenario === "out_of_band") { + if (scenario !== "startup_failure") { const response = await invokeToolWithParsedInput(tool, { agentId: child.id, prompt: "/side-command", + activeTurnBehavior: scenario === "out_of_band_interrupt" ? "interrupt" : "steer", }); expect(response.structuredContent).toMatchObject({ success: true, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 68e9d99a40..64d36d3b52 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -6,7 +6,7 @@ import type { Logger } from "pino"; import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentManager } from "../agent-manager.js"; -import { AgentProfileSchema } from "@getpaseo/protocol/messages"; +import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages"; import type { DaemonConfigStore } from "../../daemon-config-store.js"; import { AgentFeatureSchema, @@ -1209,6 +1209,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase .object(legacyTopLevelCreateAgentInputSchema) .strict(); const commonSendAgentPromptInputSchema = { + activeTurnBehavior: ActiveTurnBehaviorSchema.optional().describe( + 'How to send into a running turn: "steer" joins it, falling back to interrupt when unavailable; "interrupt" replaces it. Defaults to steer for agent callers and interrupt otherwise.', + ), agentId: z.string(), prompt: z.string(), sessionMode: z.string().optional().describe("Optional mode to set before running the prompt."), @@ -1955,28 +1958,55 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase inputSchema: sendAgentPromptInputSchema, outputSchema: { success: z.boolean(), - disposition: z.enum(["turn_started", "out_of_band"]).optional(), + disposition: z.enum(["turn_started", "out_of_band", "steered"]).optional(), status: AgentStatusEnum, guidance: z.string().optional(), }, }, - async ({ agentId, prompt, sessionMode, notifyOnFinish = Boolean(callerAgentId) }) => { + async ({ + agentId, + prompt, + sessionMode, + notifyOnFinish = Boolean(callerAgentId), + activeTurnBehavior = callerAgentId ? "steer" : "interrupt", + }) => { let stopNotification: (() => void) | undefined; - function armFinishNotification(disposition: PromptDispatchDisposition): void { - if (!callerAgentId || !notifyOnFinish) { - return; - } + let acceptDispatch!: (result: PromptDispatchDisposition | false) => void; + const dispatchResult = new Promise((resolve) => { + acceptDispatch = resolve; + }); + const notifying = Boolean(callerAgentId && notifyOnFinish); + if (callerAgentId && notifyOnFinish) { stopNotification = setupFinishNotification({ agentManager, agentStorage, childAgentId: agentId, callerAgentId, - waitForTurnStart: disposition === "turn_started", + waitForTurnStart: false, + dispatchResult, + logger: childLogger, + }); + } + function confirmDispatch(disposition: PromptDispatchDisposition): void { + // A new turn must ignore outcomes from the displaced turn. Its iterator + // has not started yet, so rearm here; steering keeps the early observer. + if (disposition !== "turn_started") { + acceptDispatch(disposition); + return; + } + acceptDispatch(disposition); + if (!callerAgentId || !notifyOnFinish) return; + stopNotification?.(); + stopNotification = setupFinishNotification({ + agentManager, + agentStorage, + childAgentId: agentId, + callerAgentId, + waitForTurnStart: true, logger: childLogger, }); } - const notifying = Boolean(callerAgentId && notifyOnFinish); const { disposition } = await sendPromptToAgent({ agentManager, agentStorage, @@ -1984,9 +2014,11 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase prompt, source: callerAgentId ? { kind: "agent-message", agentId: callerAgentId } : undefined, sessionMode, - onDispatch: armFinishNotification, + activeTurnBehavior, + onDispatch: confirmDispatch, logger: childLogger, }).catch((error) => { + acceptDispatch(false); stopNotification?.(); if (error instanceof AgentNotFoundError) { throw new PaseoToolInputError(error.message); diff --git a/public-docs/cli.md b/public-docs/cli.md index c8f3cc0e1a..f118d35894 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -289,9 +289,12 @@ Use the recipient's agent ID from `paseo ls`, or [copy it from the agent's tab]( ```bash paseo send "now run the tests" paseo send --image screenshot.png "what's wrong here?" -paseo send --no-wait "queue this task" +paseo send --no-wait "run this task" +paseo send --behavior interrupt "stop and do this instead" ``` +Agent-originated sends (`PASEO_AGENT_ID` set) default to steering the running turn. Other sends default to interrupting it. Use `--behavior steer` or `--behavior interrupt` to override the default. Steering uses the same fallback as the client: if steering is unavailable, the running turn is interrupted and replaced. + ## Viewing logs ```bash diff --git a/public-docs/orchestration-workflows.md b/public-docs/orchestration-workflows.md index e7e5f271c3..61343e5cff 100644 --- a/public-docs/orchestration-workflows.md +++ b/public-docs/orchestration-workflows.md @@ -51,7 +51,7 @@ Agents can prompt each other by **agent ID**, the identifier Paseo uses to addre > Use Paseo to send this prompt to agent [paste agent ID here]: “Review the parser changes in your workspace and report any missing test cases.” -The receiving agent gets the prompt in its existing conversation. To have it send a separate message back, give it the sender's agent ID too. +The receiving agent gets the prompt in its existing conversation. Agent-originated sends default to steering a running turn, with interruption fallback when steering is unavailable. Set MCP `activeTurnBehavior` or CLI `--behavior` to `steer` or `interrupt` to override that default. To have it send a separate message back, give it the sender's agent ID too. Underneath, the agent calls `send_agent_prompt` with the recipient's `agentId` and a `prompt`. Agents can also discover IDs with `list_agents`; the CLI uses [`paseo send `](/docs/cli#sending-messages). For another host, use the [remote CLI workflow](#work-on-another-machine). diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index b7fe95811d..83d329f948 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -63,7 +63,7 @@ Detach is an explicit user action in the subagents track, not an agent tool. A c Agent-scoped `create_agent` defaults `notifyOnFinish` to true. Set it to `false` only for truly fire-and-forget agents. -**`send_agent_prompt`** — `{ agentId, prompt }`. Use for follow-ups to an existing agent. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback. +**`send_agent_prompt`** — `{ agentId, prompt, activeTurnBehavior? }`. Use for follow-ups to an existing agent. Agent-scoped calls default to `steer`; top-level calls default to `interrupt`. Set `activeTurnBehavior` to `steer` or `interrupt` to override the default. Steering falls back to interruption when unavailable. CLI agent sends use the same defaults and accept `--behavior steer|interrupt`. Returns after dispatch acknowledgement. Agent-scoped calls default `notifyOnFinish` to true; top-level calls have no parent callback. **`update_agent`** — `{ agentId, name?, labels?, settings? }`. Use `settings` for runtime changes on an existing agent: `modeId`, `model`, `thinkingOptionId`, and provider-specific `features`. For Codex fast mode, pass `settings: { features: { "fast_mode": true } }`. From 6e5a4682eeeaed24a7b1bfab48df89b31f1fe0fe Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 17 Oct 2026 21:36:41 +0700 Subject: [PATCH 02/23] fix(markdown): render own-line images as blocks (#7237) An image alone on its line, inside a paragraph with other lines, rendered inline on iOS and web: the library only treats sole-child paragraph images as blocks, and with hardSoftBreaks every newline is a break node. The source adaptation now gives such images their own paragraph (keeping quote markers and list indentation), so all platforms render them as blocks with image margins. Images sharing a line with text stay inline. Verified through the library's md4c wasm parser in the renderer tests. --- docs/markdown-renderer.md | 2 +- .../src/components/markdown/renderer.test.ts | 53 +++++++++++++++++++ .../app/src/components/markdown/source.ts | 42 ++++++++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index 418095730a..a4d6a93b5e 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -8,7 +8,7 @@ Native selection belongs to the library's text view. Keep an assistant message i Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. -Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. +Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline. ## Engine constraints diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 218cdc8884..8791cf40de 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,5 +1,8 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; +import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; +import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; +import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; import { QueryClient } from "@tanstack/react-query"; import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding"; import { describe, expect, it } from "vitest"; @@ -31,6 +34,56 @@ describe("shared Markdown source adaptation", () => { "> - ![Image]()\n\n[picture]: ../a.png", ); }); + it("gives an image alone on its line its own paragraph", () => { + expect( + prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(), + ).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it."); + expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe( + "![A](https://x/a.png)\n\n![B](https://x/b.png)", + ); + expect( + prepareMarkdownSource( + "Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three", + ).render(), + ).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three"); + }); + it("keeps images that share a line with text inline", () => { + const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line"; + expect(prepareMarkdownSource(source).render()).toBe(source); + }); + it("splits own-line images inside quotes and list items without ending the container", () => { + expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe( + "> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More", + ); + expect( + prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(), + ).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next"); + expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe( + "- ![Shot](https://x/a.png)\n\n Caption line", + ); + }); + it("renders own-line images as blocks through the library parser", async () => { + const source = + "Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)"; + const tree = await parseMarkdown(prepareMarkdownSource(source).render(), { + hardSoftBreaks: true, + latexMath: false, + }); + markInlineImages(tree); + const images: ASTNode[] = []; + const collect = (node: ASTNode) => { + if (node.type === "Image") images.push(node); + node.children?.forEach(collect); + }; + collect(tree); + expect( + images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]), + ).toEqual([ + ["https://x/a.png", false], + ["https://x/i.png", true], + ["https://x/q.png", false], + ]); + }); it("escapes spoiler delimiters only in prose", () => { expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe( "Show \\|\\|words\\|\\| and `||code||`.", diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index fc6f7463a5..9fda25b728 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -5,7 +5,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; import type { AssistantFileLinkSource } from "@/assistant-file-links"; import { decodeString } from "micromark-util-decode-string"; import { fromMarkdown } from "mdast-util-from-markdown"; -import type { Link, Nodes, Root } from "mdast"; +import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast"; // Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex. // The Unicode flag makes Metro expand it to escapes rejected by native engines. @@ -130,10 +130,50 @@ export function prepareMarkdownSource( end, }); } + // Line boundaries already turned into paragraph breaks, by offset of their newline. + const splitBoundaries = new Set(); + function isImageLike(node: PhrasingContent): boolean { + if (node.type === "image" || node.type === "imageReference") return true; + return ( + (node.type === "link" || node.type === "linkReference") && + node.children.length === 1 && + isImageLike(node.children[0]) + ); + } + // An image alone on its line becomes its own paragraph, so every platform renders + // it as a block. The library only treats sole-child paragraph images as blocks; + // with hardSoftBreaks every newline is a break node, which made these inline. + function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) { + for (const child of paragraph.children) { + const childStart = child.position?.start.offset; + const childEnd = child.position?.end.offset; + if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue; + const lineStart = source.lastIndexOf("\n", childStart - 1) + 1; + const newline = source.indexOf("\n", childEnd); + const lineEnd = newline === -1 ? source.length : newline; + const prefix = source.slice(lineStart, childStart); + const suffix = source.slice(childEnd, lineEnd); + const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix); + const aloneAfter = /^\s*\\?$/.test(suffix); + if (!aloneBefore || !aloneAfter) continue; + // Continuation lines inside quotes and lists keep their quote markers on the blank line. + const blank = prefix.replace(/[^>]/g, " ").trimEnd(); + const split = (boundary: number) => { + if (splitBoundaries.has(boundary)) return; + splitBoundaries.add(boundary); + // A trailing backslash was a hard break; at a paragraph end it would render literally. + const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary; + edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` }); + }; + if (lineStart > start) split(lineStart - 1); + if (lineEnd < end) split(lineEnd); + } + } function visit(node: Nodes) { const start = node.position?.start.offset; const end = node.position?.end.offset; if (start !== undefined && end !== undefined) { + if (node.type === "paragraph") splitStandaloneImages(node, start, end); if (node.type === "link") { const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot); if (title !== null) From 86ab49153ae7aa43017fadda2eb115879d05c5b8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:54:51 +0200 Subject: [PATCH 03/23] Let Copilot implement plans after selecting Allow All (#7234) Selecting Allow All now moves Copilot to Agent mode before enabling allow_all, so a session that was in Plan can implement the plan. Closes #3855. Co-authored-by: KiraPC --- .../server/agent/providers/acp-agent.test.ts | 14 +++- .../agent/providers/copilot-acp-agent.ts | 4 + .../copilot-allow-all-mode.real.e2e.test.ts | 82 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/server/agent/providers/copilot-allow-all-mode.real.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 33c9696900..a050aa9077 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1617,7 +1617,12 @@ describe("ACPAgentSession Zed parity", () => { configId: "allow_all", value: "on", }); - expect(setSessionMode).not.toHaveBeenCalled(); + // Allow All is agent mode with permissions pre-granted, so it has to leave + // whatever mode the session is in, not just flip the flag. + expect(setSessionMode).toHaveBeenCalledWith({ + sessionId: "session-1", + modeId: "https://agentclientprotocol.com/protocol/session-modes#agent", + }); await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID); expect(events.some((event) => event.type === "permission_requested")).toBe(false); }); @@ -1648,7 +1653,12 @@ describe("ACPAgentSession Zed parity", () => { configId: "allow_all", value: "on", }); - expect(setSessionMode).not.toHaveBeenCalled(); + // Allow All is agent mode with permissions pre-granted, so it has to leave + // whatever mode the session is in, not just flip the flag. + expect(setSessionMode).toHaveBeenCalledWith({ + sessionId: "session-1", + modeId: "https://agentclientprotocol.com/protocol/session-modes#agent", + }); await expect(session.getCurrentMode()).resolves.toBe(COPILOT_ALLOW_ALL_MODE_ID); }); diff --git a/packages/server/src/server/agent/providers/copilot-acp-agent.ts b/packages/server/src/server/agent/providers/copilot-acp-agent.ts index b9497d910b..5307fe0d5e 100644 --- a/packages/server/src/server/agent/providers/copilot-acp-agent.ts +++ b/packages/server/src/server/agent/providers/copilot-acp-agent.ts @@ -209,6 +209,10 @@ export async function writeCopilotProviderMode( if (!requestsAllowAll) { return { handled: false }; } + await context.connection.setSessionMode({ + sessionId: context.sessionId, + modeId: COPILOT_AGENT_MODE_ID, + }); const response = await context.connection.setSessionConfigOption({ sessionId: context.sessionId, configId: COPILOT_ALLOW_ALL_CONFIG_ID, diff --git a/packages/server/src/server/agent/providers/copilot-allow-all-mode.real.e2e.test.ts b/packages/server/src/server/agent/providers/copilot-allow-all-mode.real.e2e.test.ts new file mode 100644 index 0000000000..840ad29695 --- /dev/null +++ b/packages/server/src/server/agent/providers/copilot-allow-all-mode.real.e2e.test.ts @@ -0,0 +1,82 @@ +import { beforeAll, describe, expect, test } from "vitest"; +import { spawn } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; + +import { isCommandAvailable } from "../../../executable-resolution/executable-resolution.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { terminateWithTreeKill } from "../../../utils/tree-kill.js"; +import { resolveACPModeSelection } from "./acp-agent.js"; +import { + COPILOT_ALLOW_ALL_MODE_ID, + COPILOT_MODES, + writeCopilotProviderMode, +} from "./copilot-acp-agent.js"; + +const AGENT_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#agent"; +const PLAN_MODE_ID = "https://agentclientprotocol.com/protocol/session-modes#plan"; + +describe("Copilot ACP provider (real) Allow All mode", () => { + let canRun = false; + + beforeAll(async () => { + canRun = await isCommandAvailable("copilot"); + }); + + test("selecting Allow All from Plan leaves the real provider in Agent mode", async (context) => { + if (!canRun) { + context.skip(); + } + const cwd = await mkdtemp(join(tmpdir(), "copilot-allow-all-real-")); + const child = spawn("copilot", ["--acp"], { cwd, stdio: ["pipe", "pipe", "ignore"] }); + const connection = new ClientSideConnection( + () => ({ + sessionUpdate: async () => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + }), + ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)), + ); + + try { + await connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }); + const session = await connection.newSession({ cwd, mcpServers: [] }); + const plan = await connection.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "mode", + value: PLAN_MODE_ID, + }); + expect(plan.configOptions).toContainEqual( + expect.objectContaining({ id: "mode", currentValue: PLAN_MODE_ID }), + ); + + // This is the same provider writer called by ACPAgentSession. Assert its + // untransformed ACP response, since Paseo's displayed mode hides this bug. + const result = await writeCopilotProviderMode({ + connection, + sessionId: session.sessionId, + requestedModeId: COPILOT_ALLOW_ALL_MODE_ID, + currentModeId: PLAN_MODE_ID, + selection: resolveACPModeSelection({ + modeId: COPILOT_ALLOW_ALL_MODE_ID, + availableModes: COPILOT_MODES, + configOptions: plan.configOptions, + }), + configOptions: plan.configOptions, + logger: createTestLogger(), + }); + + expect(result.configOptions).toContainEqual( + expect.objectContaining({ id: "mode", currentValue: AGENT_MODE_ID }), + ); + expect(result.configOptions).toContainEqual( + expect.objectContaining({ id: "allow_all", currentValue: "on" }), + ); + } finally { + await terminateWithTreeKill(child, { gracefulTimeoutMs: 1000, forceTimeoutMs: 1000 }); + await rm(cwd, { recursive: true, force: true }); + } + }, 60_000); +}); From 86817a24fc01469a50ee7a437574dcfd3c7dc782 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 17 Oct 2026 22:06:09 +0700 Subject: [PATCH 04/23] perf(app): fetch project and workspace directories concurrently (#7242) --- .../src/runtime/directory-sync/index.test.ts | 54 +++++++++++++++++++ .../app/src/runtime/directory-sync/index.ts | 28 ++++++---- 2 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 2dea552239..98e0ea29b3 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1207,6 +1207,60 @@ describe("DirectorySync session readiness", () => { directory.dispose(); }); + it.each(["projects", "workspaces"] as const)( + "requests projects and workspaces together and commits once when %s responds first", + async (firstResponse) => { + const serverId = `parallel-directory-${firstResponse}`; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, projectList: true }, + }); + const releaseProjects = client.holdProjectFetch(); + const releaseWorkspaces = client.holdWorkspaceFetch(); + const project = { + projectId: "parallel-project", + projectDisplayName: "Project baseline", + projectRootPath: "/repo/parallel", + projectKind: "git" as const, + }; + const projects: ProjectListResult = { requestId: "projects", projects: [project] }; + const workspaces: WorkspaceFetchResult = { + requestId: "workspaces", + entries: [createWorkspaceEntry("parallel-workspace")], + emptyProjects: [{ ...project, projectDisplayName: "Newer workspace overlay" }], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }; + const refresh = directory.refreshWorkspaces(); + try { + await expect.poll(() => client.listProjectsCalls).toBe(1); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); + if (firstResponse === "projects") releaseProjects(projects); + else releaseWorkspaces(workspaces); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(useSessionStore.getState().sessions[serverId].workspaces.size).toBe(0); + expect(useSessionStore.getState().sessions[serverId].projects.size).toBe(0); + releaseProjects(projects); + releaseWorkspaces(workspaces); + await refresh; + const session = useSessionStore.getState().sessions[serverId]; + expect(session.workspaces.has("parallel-workspace")).toBe(true); + expect(session.projects.get(project.projectId)?.projectDisplayName).toBe( + "Newer workspace overlay", + ); + } finally { + releaseProjects(projects); + releaseWorkspaces(workspaces); + await refresh; + directory.dispose(); + } + }, + ); + it("keeps the accepted baseline when an old connection finishes its workspace request", async () => { const serverId = "superseded-workspace-baseline"; const { client, directory } = createDirectory(serverId); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 28b217fea6..b59440d782 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -563,16 +563,20 @@ export class DirectorySync { const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; const supportsProjectList = serverInfo?.features?.projectList === true; const supportsDirectorySync = serverInfo?.features?.directorySync === true; - if (supportsProjectList) { - await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync); - } - await this.fetchWorkspaceSnapshot( - client, - source, - transaction, - input?.subscribe === true, - supportsDirectorySync, - ); + const projectsReady = supportsProjectList + ? this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync) + : Promise.resolve(); + await Promise.all([ + projectsReady, + this.fetchWorkspaceSnapshot( + client, + source, + transaction, + input?.subscribe === true, + supportsDirectorySync, + projectsReady, + ), + ]); if (!supportsProjectList) { this.buildLegacyProjectSnapshot(transaction.snapshot); (transaction.snapshot.syncModes ??= {}).projects = @@ -592,6 +596,7 @@ export class DirectorySync { transaction: DirectoryTransaction, initialSubscribe: boolean, supportsDirectorySync: boolean, + projectsReady: Promise, ): Promise { let cursor: string | null = null; let subscribe = initialSubscribe; @@ -618,6 +623,9 @@ export class DirectorySync { }); payload = await subscription.ready; } else payload = await client.fetchWorkspaces(query); + // Fetch both directories together, but apply the project baseline before the + // workspace page's empty-project overlays, regardless of response order. + await projectsReady; this.assertWorkspaceTransactionCurrent(client, source, transaction); applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; From ecd42015b98f27f2ddf4708f7266a5267ac638d4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 17 Oct 2026 22:27:55 +0700 Subject: [PATCH 05/23] Redesign the compact New workspace form as full-width field triggers (#7239) * Redesign the compact New workspace form as full-width field triggers Each compact field is one full-width trigger with its label inside, above the value, so the whole field is the tap target and presses highlight. The stack stays anchored on the composer with Isolation and Branch rows reserved, so nothing shifts as they come and go. Import session moves into the compact header, and the title is gone; the fields name themselves. The form's presentational code moves out of new-workspace-screen.tsx into screens/new-workspace/form, where desktop badges and compact fields share one FieldTrigger. * Restore the desktop badge pressed colour Main pressed badges to surface0; the field-trigger extraction folded it into the surface2 hover state. * Use base text size for compact field values docs/design.md sets interface text at fontSize.base; hierarchy comes from the muted label above. --- packages/app/src/i18n/resources/ar.ts | 3 +- packages/app/src/i18n/resources/en.ts | 3 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ja.ts | 3 +- packages/app/src/i18n/resources/ko.ts | 3 +- packages/app/src/i18n/resources/pt-BR.ts | 3 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 3 +- .../app/src/screens/new-workspace-screen.tsx | 1038 +---------------- .../new-workspace/form/field-trigger.tsx | 217 ++++ .../src/screens/new-workspace/form/icons.tsx | 49 + .../src/screens/new-workspace/form/index.tsx | 392 +++++++ .../new-workspace/form}/launch-control.tsx | 72 +- .../screens/new-workspace/form/options.tsx | 166 +++ .../new-workspace/import-session-button.tsx | 7 +- 16 files changed, 914 insertions(+), 1054 deletions(-) create mode 100644 packages/app/src/screens/new-workspace/form/field-trigger.tsx create mode 100644 packages/app/src/screens/new-workspace/form/icons.tsx create mode 100644 packages/app/src/screens/new-workspace/form/index.tsx rename packages/app/src/{new-workspace-launch => screens/new-workspace/form}/launch-control.tsx (79%) create mode 100644 packages/app/src/screens/new-workspace/form/options.tsx diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 2f1fd7397f..4adf945b61 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1334,8 +1334,9 @@ export const ar: TranslationResources = { }, fields: { project: "المشروع", - base: "الأساس", + branch: "الفرع", baseNotApplicable: "غير قابل للتطبيق", + host: "المضيف", }, titlePlaceholder: "العنوان (اختياري)", errors: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 14cde732e7..9bb0460036 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1342,8 +1342,9 @@ export const en = { }, fields: { project: "Project", - base: "Base", + branch: "Branch", baseNotApplicable: "Not applicable", + host: "Host", }, titlePlaceholder: "Title (optional)", errors: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 18eb17f291..9ed1e85572 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1370,8 +1370,9 @@ export const es: TranslationResources = { }, fields: { project: "Proyecto", - base: "Base", + branch: "Rama", baseNotApplicable: "No aplicable", + host: "Host", }, titlePlaceholder: "Título (opcional)", errors: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 2bae7b8647..6563b1100e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1363,8 +1363,9 @@ export const fr: TranslationResources = { }, fields: { project: "Projet", - base: "Base", + branch: "Branche", baseNotApplicable: "Non applicable", + host: "Hôte", }, titlePlaceholder: "Titre (facultatif)", errors: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 58e2706b95..eb22dd663a 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1348,8 +1348,9 @@ export const ja: TranslationResources = { }, fields: { project: "プロジェクト", - base: "ベース", + branch: "ブランチ", baseNotApplicable: "該当なし", + host: "ホスト", }, titlePlaceholder: "タイトル(任意)", errors: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index fa962a5562..afdb8974e9 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1341,8 +1341,9 @@ export const ko: TranslationResources = { }, fields: { project: "프로젝트", - base: "기준", + branch: "브랜치", baseNotApplicable: "해당 없음", + host: "호스트", }, titlePlaceholder: "제목(선택 사항)", errors: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c5c6607ca6..f89fb97bf0 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1360,8 +1360,9 @@ export const ptBR: TranslationResources = { }, fields: { project: "Projeto", - base: "Base", + branch: "Branch", baseNotApplicable: "Não aplicável", + host: "Host", }, titlePlaceholder: "Título (opcional)", errors: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 4df3f721bb..a1a70822b4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1352,8 +1352,9 @@ export const ru: TranslationResources = { }, fields: { project: "Проект", - base: "Базовая ветка", + branch: "Ветка", baseNotApplicable: "Неприменимо", + host: "Хост", }, titlePlaceholder: "Заголовок (необязательно)", errors: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index bad9cc4690..ca221d7d3a 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1323,8 +1323,9 @@ export const zhCN: TranslationResources = { }, fields: { project: "项目", - base: "基线", + branch: "分支", baseNotApplicable: "不适用", + host: "主机", }, titlePlaceholder: "标题(可选)", errors: { diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 6d8fd3de5a..3cb01ae9df 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -6,15 +6,13 @@ import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/ import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; -import type { ReactElement, ReactNode, RefObject } from "react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import type { TFunction } from "i18next"; -import { Pressable, Text, View } from "react-native"; -import type { PressableStateCallbackType } from "react-native"; -import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { router } from "expo-router"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; import { Composer } from "@/composer"; import { ComposerDock } from "@/composer/dock"; import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session"; @@ -23,14 +21,6 @@ import { resolveComposerAttachmentSubmitFormat, splitComposerAttachmentsForSubmit, } from "@/composer/attachments/submit"; -import { HostStatusDot } from "@/components/host-status-dot"; -import { HostPicker } from "@/components/hosts/host-picker"; -import { ProjectIconView } from "@/components/project-icon-view"; -import { Combobox, ComboboxItem } from "@/components/ui/combobox"; -import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox"; -import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; -import { Shortcut } from "@/components/ui/shortcut"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { MenuHeader } from "@/components/headers/menu-header"; import { useIsCompactFormFactor } from "@/constants/layout"; @@ -42,7 +32,6 @@ import { ensureCheckoutStatus } from "@/git/checkout-status-cache"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { resolveTerminalProfiles } from "@getpaseo/protocol/terminal-profiles"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; -import { LaunchControl } from "@/new-workspace-launch/launch-control"; import { resolveLaunchTarget, type LaunchTarget } from "@/new-workspace-launch/target"; import { useTerminalComposerState } from "@/new-workspace-launch/composer-state"; import { runCreateTerminalWorkspace } from "./new-workspace-terminal"; @@ -71,12 +60,10 @@ import { import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher"; import { useFormPreferences } from "@/hooks/use-form-preferences"; -import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { toErrorMessage } from "@/utils/error-messages"; -import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; +import { buildSettingsAddHostRoute } from "@/utils/host-routes"; import { - getHostProjectSourceDirectory, getHostProjectId, getWorktreeSupportForHostProject, hostProjectFromRoute, @@ -86,7 +73,6 @@ import { type HostProjectListItem, } from "@/projects/host-projects"; import { useProjectIcons } from "@/projects/icons"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; @@ -122,6 +108,7 @@ import { } from "./new-workspace-initial-context"; import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +import { useNewWorkspaceForm } from "./new-workspace/form"; import { ImportSessionButton } from "./new-workspace/import-session-button"; import { useImportSession } from "@/hooks/use-import-session"; import { @@ -132,12 +119,6 @@ import { import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff"; import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence"; -const ThemedFolderPlus = withUnistyles(FolderPlus); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const addProjectIcon = ( - -); - function useIsNewWorkspaceDraftHandoffActive(input: { draftId: string | undefined; selectedServerId: string; @@ -198,504 +179,8 @@ const NO_TERMINAL_ATTACHMENTS: UserComposerAttachment[] = []; function noopChangeAttachments() {} function noopClearDraft() {} -const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; -const ThemedChevronDown = withUnistyles(ChevronDown); -const chevronExtraMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); - -// Every picker chip on this screen shares one chevron so they stay a single -// visual family. Extra-muted: the chevron is an affordance, not information, -// and it should sit behind the label it belongs to. -function MetaChevron(): ReactElement { - return ( - - - - ); -} - -const metaChevron = ; - // Stable reference so the keyboard-action handler doesn't re-register each render. const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"]; -// Height of a single picker-trigger badge. The Base-row spacer reserves exactly -// this so toggling Isolation to Local hides the row without shifting the form. -const BADGE_HEIGHT = 28; - -function RefPickerBadgeContent({ - selectedItem, - triggerLabel, - iconColor, - iconSize, -}: { - selectedItem: PickerItem | null; - triggerLabel: string; - iconColor: string; - iconSize: number; -}) { - return ( - <> - - {selectedItem?.kind === "github-pr" ? ( - - ) : ( - - )} - - - {triggerLabel} - - - ); -} - -function RefPickerTrigger({ - pickerAnchorRef, - onPress, - disabled, - badgePressableStyle, - selectedItem, - triggerLabel, - accessibilityLabel, - tooltipLabel, - iconColor, - iconSize, -}: { - pickerAnchorRef: React.RefObject; - onPress: () => void; - disabled: boolean; - badgePressableStyle: React.ComponentProps["style"]; - selectedItem: PickerItem | null; - triggerLabel: string; - accessibilityLabel: string; - tooltipLabel: string; - iconColor: string; - iconSize: number; -}) { - return ( - - - - - - - - {tooltipLabel} - - - ); -} - -function ProjectPickerTrigger({ - pickerAnchorRef, - onPress, - disabled, - badgePressableStyle, - label, - tooltipLabel, - projectViewKey, - iconDataUri, - iconColor, - iconSize, -}: { - pickerAnchorRef: React.RefObject; - onPress: () => void; - disabled: boolean; - badgePressableStyle: React.ComponentProps["style"]; - label: string; - tooltipLabel: string; - projectViewKey: string | null; - iconDataUri: string | null; - iconColor: string; - iconSize: number; -}) { - const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label); - const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?"; - return ( - - - - - {projectViewKey ? ( - - ) : ( - - )} - - - {label} - - - - - {tooltipLabel} - - - ); -} - -function PickerOptionItem({ - testID, - label, - description, - selected, - active, - disabled, - onPress, - isBranch, - trailingLabel, - accessibilityLabel, - iconColor, - iconSize, -}: { - testID: string; - label: string; - description: string | undefined; - selected: boolean; - active: boolean; - disabled: boolean; - onPress: () => void; - isBranch: boolean; - trailingLabel?: string; - accessibilityLabel?: string; - iconColor: string; - iconSize: number; -}) { - const leadingSlot = useMemo( - () => ( - - {isBranch ? ( - - ) : ( - - )} - - ), - [isBranch, iconSize, iconColor], - ); - const trailingSlot = useMemo( - () => - trailingLabel ? {trailingLabel} : undefined, - [trailingLabel], - ); - return ( - - ); -} - -function IsolationOptionItem({ - optionId, - label, - selected, - active, - disabled, - onPress, - iconColor, - iconSize, -}: { - optionId: string; - label: string; - selected: boolean; - active: boolean; - disabled: boolean; - onPress: () => void; - iconColor: string; - iconSize: number; -}) { - const leadingSlot = useMemo( - () => ( - - {optionId === "worktree" ? ( - - ) : ( - - )} - - ), - [optionId, iconSize, iconColor], - ); - return ( - - ); -} - -function ProjectOptionItem({ - testID, - projectViewKey, - iconDataUri, - label, - description, - selected, - active, - disabled, - onPress, -}: { - testID: string; - projectViewKey: string; - iconDataUri: string | null; - label: string; - description: string | undefined; - selected: boolean; - active: boolean; - disabled: boolean; - onPress: () => void; -}) { - const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label); - const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?"; - const leadingSlot = useMemo( - () => ( - - - - ), - [iconDataUri, placeholderInitial, projectViewKey], - ); - - return ( - - ); -} - -function NewWorkspacePickerOption({ - option, - selected, - active, - onPress, - itemById, - isPending, -}: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - itemById: Map; - isPending: boolean; -}) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const item = itemById.get(option.id); - if (!item) return ; - - const isBranch = item.kind === "branch"; - const testID = isBranch - ? `new-workspace-ref-picker-branch-${item.name}` - : `new-workspace-ref-picker-pr-${item.item.number}`; - const description = - !isBranch && item.item.baseRefName - ? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName }) - : undefined; - - return ( - - ); -} - -function NewWorkspaceProjectPickerOption({ - option, - selected, - active, - onPress, - projectByOptionId, - projectIconDataByProjectViewKey, - selectedServerId, - isPending, - supportsWorkspaceMultiplicity, -}: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - projectByOptionId: Map; - projectIconDataByProjectViewKey: Map; - selectedServerId: string; - isPending: boolean; - supportsWorkspaceMultiplicity: boolean; -}) { - const project = projectByOptionId.get(option.id); - if (!project) return ; - const sourceDirectory = - getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir; - - return ( - host.worktreeSupport !== "unsupported")) - } - onPress={onPress} - /> - ); -} - -function AddProjectPickerAction({ onPress }: { onPress: () => void }) { - const { t } = useTranslation(); - const openProjectKeys = useShortcutKeys("new-agent"); - const shortcut = useMemo( - () => (openProjectKeys ? : null), - [openProjectKeys], - ); - - return ( - - ); -} - -function newWorkspaceHostOptionTestID(serverId: string): string { - return `new-workspace-host-picker-option-${serverId}`; -} - -function IsolationPickerTrigger({ - pickerAnchorRef, - onPress, - disabled, - badgePressableStyle, - isolation, - label, - tooltipLabel, - iconColor, - iconSize, -}: { - pickerAnchorRef: React.RefObject; - onPress: () => void; - disabled: boolean; - badgePressableStyle: React.ComponentProps["style"]; - isolation: "local" | "worktree"; - label: string; - tooltipLabel: string; - iconColor: string; - iconSize: number; -}) { - return ( - - - - - {isolation === "worktree" ? ( - - ) : ( - - )} - - - {label} - - - - - {tooltipLabel} - - - ); -} - -// Wraps a single argument control in the mobile vertical stack. On desktop the -// controls are laid out in one horizontal row, so no per-control wrapper is used. -// Decorative padding must not block the dock background from iOS hit testing. -function FormRow({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} interface WorkspaceIsolationState { isolation: "local" | "worktree"; @@ -738,12 +223,6 @@ function useWorkspaceIsolation(input: { }; } -function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string { - return isolation === "worktree" - ? t("newWorkspace.isolation.worktree") - : t("newWorkspace.isolation.local"); -} - function normalizeBranchDetails( data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined, ): BranchPickerDetail[] { @@ -1367,264 +846,6 @@ function useNewWorkspaceInitialContext({ }; } -type RefPickerRenderOption = NonNullable; - -interface FormPickerControl { - anchorRef: RefObject; - open: () => void; - openState: boolean; - onOpenChange: (open: boolean) => void; -} - -interface NewWorkspaceFormStackInput { - isCompact: boolean; - isPending: boolean; - project: FormPickerControl & { - options: ComboboxOptionType[]; - triggerLabel: string; - selectedProject: HostProjectListItem | null; - iconDataByProjectViewKey: Map; - selectedOptionId: string; - onSelect: (id: string) => void; - onAddProject: () => void; - renderOption: RefPickerRenderOption; - }; - host: FormPickerControl & { - allHosts: HostProfile[]; - selectedServerId: string; - onSelect: (id: string) => void; - }; - isolation: FormPickerControl & { - effectiveIsolation: "local" | "worktree"; - options: ComboboxOptionType[]; - onSelect: (id: string) => void; - renderOption: RefPickerRenderOption; - canCreateWorktree: boolean; - }; - base: FormPickerControl & { - selectedSourceDirectory: string | null; - selectedItem: PickerItem | null; - triggerLabel: string; - options: ComboboxOptionType[]; - selectedOptionId: string; - onSelect: (id: string) => void; - setSearchQuery: (query: string) => void; - emptyText: string; - renderOption: RefPickerRenderOption; - showRefPicker: boolean; - }; - launch: { - serverId: string; - target: LaunchTarget; - onChange: (target: LaunchTarget) => void; - profiles: readonly TerminalProfile[]; - disabled: boolean; - }; -} - -function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactElement { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const { isCompact, isPending, project, host, isolation, base, launch } = input; - - const selectedHostLabel = - host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; - const showHostControl = host.allHosts.length > 1; - const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation); - const addProjectAction = useMemo( - () => , - [project.onAddProject], - ); - - const badgePressableStyle = useCallback( - ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ - styles.badge, - Boolean(hovered) && !isPending && styles.badgeHovered, - pressed && !isPending && styles.badgePressed, - isPending && styles.badgeDisabled, - ], - [isPending], - ); - - const desktopControlStyle = isCompact ? undefined : styles.desktopControl; - - const projectControl = ( - - - - - ); - - const hostControl = showHostControl ? ( - - - - - - - - - - {selectedHostLabel} - - {metaChevron} - - - - {t("newWorkspace.tooltips.host")} - - - - - ) : null; - - const isolationControl = isolation.canCreateWorktree ? ( - - - - - ) : null; - - const baseControl = base.showRefPicker ? ( - - - - - ) : null; - - const launchControl = ( - - ); - - return isCompact ? ( - - {projectControl} - {hostControl ? {hostControl} : null} - {isolationControl ? {isolationControl} : null} - {baseControl ? {baseControl} : null} - {launchControl} - {/* Keep fixed stack height without separating the visible controls. */} - {isolationControl ? null : } - {baseControl ? null : } - - ) : ( - - {projectControl} - {hostControl} - {isolationControl} - {baseControl} - - {launchControl} - - ); -} - export function NewWorkspaceScreen({ serverId, sourceDirectory: sourceDirectoryProp, @@ -1633,7 +854,6 @@ export function NewWorkspaceScreen({ draftId, }: NewWorkspaceScreenProps) { const queryClient = useQueryClient(); - const { theme } = useUnistyles(); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); @@ -1946,6 +1166,10 @@ export function NewWorkspaceScreen({ openAddProjectPicker(selectedServerId); }, [openAddProjectPicker, selectedServerId]); + const handleAddHost = useCallback(() => { + router.push(buildSettingsAddHostRoute(Date.now())); + }, []); + const openPicker = useCallback(() => { setPickerOpen(true); }, []); @@ -1978,50 +1202,6 @@ export function NewWorkspaceScreen({ setIsolationPickerOpen(nextOpen); }, []); - // "New worktree" is omitted entirely (not disabled) when the project isn't a - // git checkout, since worktree isolation is impossible there. - const isolationOptions = useMemo(() => { - const localOption = { id: "local", label: isolationLabel(t, "local") }; - if (!canCreateWorktree) return [localOption]; - return [localOption, { id: "worktree", label: isolationLabel(t, "worktree") }]; - }, [canCreateWorktree, t]); - - const handleSelectIsolationOption = useCallback( - (id: string) => { - setIsolation(id === "worktree" ? "worktree" : "local"); - setIsolationPickerOpen(false); - }, - [setIsolation], - ); - - const renderIsolationOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - }) => { - return ( - - ); - }, - [isPending, theme.colors.foregroundMuted, theme.iconSize.sm], - ); - const handleClearDraft = useCallback(() => { // No-op: screen navigates away on success, text should stay for retry on error }, []); @@ -2261,41 +1441,6 @@ export function NewWorkspaceScreen({ withConnectedClient, ]); - const renderPickerOption = useCallback( - (props: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - }) => , - [isPending, itemById], - ); - - const renderProjectOption = useCallback( - (props: { - option: ComboboxOptionType; - selected: boolean; - active: boolean; - onPress: () => void; - }) => ( - - ), - [ - isPending, - projectByOptionId, - projectIconDataByProjectViewKey, - selectedServerId, - supportsWorkspaceMultiplicity, - ], - ); - const agentControlsWithDisabled = useMemo( () => composerState @@ -2312,57 +1457,58 @@ export function NewWorkspaceScreen({ ? t("newWorkspace.refPicker.searching") : t("newWorkspace.refPicker.noMatchingRefs"); - const formStack = useNewWorkspaceFormStack({ + const formStack = useNewWorkspaceForm({ isCompact, isPending, project: { anchorRef: projectPickerAnchorRef, open: openProjectPicker, - options: projectPickerOptions, - triggerLabel: projectTriggerLabel, - selectedProject, - iconDataByProjectViewKey: projectIconDataByProjectViewKey, - selectedOptionId: selectedProjectOptionId, - onSelect: handleSelectProjectOption, - onAddProject: handleAddProject, openState: projectPickerOpen, onOpenChange: handleProjectPickerOpenChange, - renderOption: renderProjectOption, + options: projectPickerOptions, + projectByOptionId, + iconDataByProjectViewKey: projectIconDataByProjectViewKey, + selectedProject, + selectedOptionId: selectedProjectOptionId, + triggerLabel: projectTriggerLabel, + selectedServerId, + supportsWorkspaceMultiplicity, + onSelect: handleSelectProjectOption, + onAddProject: handleAddProject, }, host: { + anchorRef: hostPickerAnchorRef, + open: openHostPicker, + openState: hostPickerOpen, + onOpenChange: handleHostPickerOpenChange, allHosts, selectedServerId, onSelect: handleSelectWorkspaceHost, - openState: hostPickerOpen, - onOpenChange: handleHostPickerOpenChange, - anchorRef: hostPickerAnchorRef, - open: openHostPicker, + onAddHost: handleAddHost, }, isolation: { anchorRef: isolationPickerAnchorRef, open: openIsolationPicker, - effectiveIsolation, - options: isolationOptions, - onSelect: handleSelectIsolationOption, openState: isolationPickerOpen, onOpenChange: handleIsolationPickerOpenChange, - renderOption: renderIsolationOption, + effectiveIsolation, canCreateWorktree, + onSelect: setIsolation, }, base: { anchorRef: pickerAnchorRef, open: openPicker, + openState: pickerOpen, + onOpenChange: handlePickerOpenChange, selectedSourceDirectory, selectedItem, triggerLabel, options, + itemById, selectedOptionId, onSelect: handleSelectOption, - openState: pickerOpen, - onOpenChange: handlePickerOpenChange, setSearchQuery: setPickerSearchQuery, emptyText: pickerEmptyText, - renderOption: renderPickerOption, showRefPicker, }, launch: { @@ -2491,11 +1637,17 @@ function NewWorkspaceLayout({ onImportSession: () => void; children: ReactNode; }) { - // At the top of the screen on compact layouts, under the composer otherwise. + // In the header on compact layouts, under the composer otherwise. const importSessionButton = ; - const setupFields = ( + // Compact has no title: the fields name themselves. The free space above pushes them down onto + // the composer, where the thumb is. + const setupFields = isCompact ? ( + <> + + {formStack} + + ) : ( <> - {isCompact ? {importSessionButton} : null} {title} @@ -2504,7 +1656,9 @@ function NewWorkspaceLayout({ ); return ( <> - {fullscreen ? null : } + {fullscreen ? null : ( + + )} @@ -2529,11 +1683,8 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", flex: 1, }, - // Takes the free space above the setup fields, so its button sits at the top of the screen. - // The inset puts the ghost button's icon on the setup rows' icon rail. - compactTopActions: { + compactSetupSpace: { flex: 1, - paddingHorizontal: theme.spacing[3], }, composerTitleContainer: { marginBottom: theme.spacing[8], @@ -2550,105 +1701,4 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.destructive, lineHeight: 20, }, - formStack: { - marginBottom: theme.spacing[8], - gap: theme.spacing[2], - }, - formStackDesktop: { - flexDirection: "row", - alignItems: "center", - // Matches the gap between the composer and the Import session pill below it. - marginBottom: theme.spacing[4], - // The badge adds its own left padding; offset it so the project icon's left - // edge lands exactly on the "New workspace" title's left edge. The trailing - // inset mirrors it so the launch chip stops on the composer's inner content - // rather than running out to the composer's border. - paddingLeft: theme.spacing[4], - paddingRight: theme.spacing[4], - gap: theme.spacing[2], - }, - desktopControl: { - minWidth: 0, - flexShrink: 1, - }, - // The row's left inset matches the heading's text x (composerTitleContainer - // paddingLeft) so the control aligns with the "New workspace" glyph. The badge - // adds its own left padding, so the row inset is reduced by that amount. - row: { - flexDirection: "row", - alignItems: "center", - paddingLeft: theme.spacing[4], - gap: theme.spacing[1], - }, - baseSpacer: { - height: BADGE_HEIGHT, - }, - // Pushes the launch control to the trailing edge of the desktop meta row, - // next to project/host/branch. The row's own right inset (formStackDesktop) - // lands it on the composer's inner content, matching the left chips. - launchSpacer: { - flex: 1, - }, - badge: { - flexDirection: "row", - alignItems: "center", - height: BADGE_HEIGHT, - maxWidth: 240, - overflow: "hidden", - paddingHorizontal: theme.spacing[2], - borderRadius: theme.borderRadius["2xl"], - gap: theme.spacing[1], - }, - badgeHovered: { - backgroundColor: theme.colors.surface2, - }, - badgePressed: { - backgroundColor: theme.colors.surface0, - }, - badgeDisabled: { - opacity: 0.6, - }, - badgeText: { - minWidth: 0, - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - flexShrink: 1, - }, - tooltipText: { - fontSize: theme.fontSize.base, - color: theme.colors.popoverForeground, - }, - refDivergenceLabel: { - fontSize: theme.fontSize.sm, - color: theme.colors.foregroundMuted, - fontVariant: ["tabular-nums"], - }, - chevronContainer: { - flexShrink: 0, - transform: [{ translateY: 1 }], - }, - badgeIconBox: { - width: theme.iconSize.md, - height: theme.iconSize.md, - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, - projectIconFallbackText: { - // Single uppercase initial inside an iconSize.md (16px) square — below the - // smallest font-size token, so it stays a literal sized to the box. - fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE, - fontWeight: "600", - }, - rowIconBox: { - width: theme.iconSize.md, - height: theme.iconSize.md, - alignItems: "center", - justifyContent: "center", - }, - hostStatusDot: { - width: 8, - height: 8, - borderRadius: 4, - }, })); diff --git a/packages/app/src/screens/new-workspace/form/field-trigger.tsx b/packages/app/src/screens/new-workspace/form/field-trigger.tsx new file mode 100644 index 0000000000..c82cadb14a --- /dev/null +++ b/packages/app/src/screens/new-workspace/form/field-trigger.tsx @@ -0,0 +1,217 @@ +import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react"; +import { Text, View, type PressableStateCallbackType } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { ChevronDown } from "lucide-react-native"; +import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +/** + * `badge` is the desktop meta row: a borderless pill that names its field in a tooltip. + * `field` is the compact form: a full-width filled box with the field's label inside, above the + * value, so the whole field is one tap target. + */ +export type FieldTriggerVariant = "badge" | "field"; + +const ThemedChevronDown = withUnistyles(ChevronDown); +const extraMutedColor = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); + +// Extra-muted: the chevron is an affordance, not information, and sits behind its label. +function Chevron(): ReactElement { + return ( + + + + ); +} + +const chevron = ; + +export interface FieldTriggerContentProps { + variant: FieldTriggerVariant; + /** The field's name, shown inside a `field`. */ + label: string; + icon: ReactNode; + value: string; + /** Secondary text after the value, such as a terminal profile's name. */ + detail?: string; +} + +export function FieldTriggerContent({ + variant, + label, + icon, + value, + detail, +}: FieldTriggerContentProps): ReactElement { + const valueLine = ( + <> + {icon} + + {value} + {detail ? {detail} : null} + + + ); + if (variant === "badge") { + return ( + <> + {valueLine} + {chevron} + + ); + } + return ( + <> + + + {label} + + {valueLine} + + {chevron} + + ); +} + +/** Pressable style for a field trigger, for triggers that bring their own Pressable. */ +export function useFieldTriggerStyle(variant: FieldTriggerVariant, disabled: boolean) { + return useCallback( + (state: PressableStateCallbackType & { hovered?: boolean }) => [ + variant === "field" ? styles.field : styles.badge, + !disabled && Boolean(state.hovered) && styles.highlighted, + !disabled && + state.pressed && + (variant === "field" ? styles.highlighted : styles.badgePressed), + disabled && styles.disabled, + ], + [disabled, variant], + ); +} + +export interface FieldTriggerProps extends FieldTriggerContentProps { + /** What a screen reader announces; defaults to `label`. */ + accessibilityLabel?: string; + onPress: () => void; + disabled?: boolean; + /** Desktop hover hint; badges only, since a field shows its label. */ + tooltip?: string; + testID?: string; +} + +/** A field trigger that anchors a Combobox. */ +export const FieldTrigger = forwardRef(function FieldTrigger( + { accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content }, + ref, +): ReactElement { + const style = useFieldTriggerStyle(content.variant, disabled); + const trigger = ( + + + + ); + if (content.variant === "field" || !tooltip) return trigger; + return ( + + + {trigger} + + + {tooltip} + + + ); +}); + +// Height of a badge. The desktop row sizes from it. +export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight; +// Height of a compact field: label line plus value line. The compact stack reserves rows from it. +export const FIELD_HEIGHT = 56; + +const styles = StyleSheet.create((theme) => ({ + badge: { + flexDirection: "row", + alignItems: "center", + height: FIELD_BADGE_HEIGHT, + maxWidth: 240, + overflow: "hidden", + paddingHorizontal: theme.spacing[2], + borderRadius: theme.borderRadius["2xl"], + gap: theme.spacing[1], + }, + field: { + flexDirection: "row", + alignItems: "center", + height: FIELD_HEIGHT, + paddingLeft: theme.spacing[3], + paddingRight: theme.spacing[3], + borderRadius: theme.borderRadius.xl, + backgroundColor: theme.colors.surface1, + gap: theme.spacing[2], + }, + highlighted: { + backgroundColor: theme.colors.surface2, + }, + badgePressed: { + backgroundColor: theme.colors.surface0, + }, + disabled: { + opacity: 0.6, + }, + // Optical: the chevron's ink sits high in its box. + chevron: { + flexShrink: 0, + transform: [{ translateY: 1 }], + }, + iconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + value: { + minWidth: 0, + flexShrink: 1, + fontSize: theme.fontSize.base, + color: theme.colors.foregroundMuted, + }, + fieldBody: { + flex: 1, + minWidth: 0, + gap: theme.spacing[0.5], + }, + fieldLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + }, + fieldValueLine: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + }, + fieldValue: { + minWidth: 0, + flexShrink: 1, + fontSize: theme.fontSize.base, + color: theme.colors.foreground, + }, + detail: { + color: theme.colors.foregroundExtraMuted, + }, + tooltipText: { + fontSize: theme.fontSize.base, + color: theme.colors.popoverForeground, + }, +})); diff --git a/packages/app/src/screens/new-workspace/form/icons.tsx b/packages/app/src/screens/new-workspace/form/icons.tsx new file mode 100644 index 0000000000..9fa277542d --- /dev/null +++ b/packages/app/src/screens/new-workspace/form/icons.tsx @@ -0,0 +1,49 @@ +import type { ReactElement } from "react"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native"; +import { ProjectIconView } from "@/components/project-icon-view"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; + +const ThemedFolder = withUnistyles(Folder); +const ThemedFolderPlus = withUnistyles(FolderPlus); +const ThemedGitBranch = withUnistyles(GitBranch); +const ThemedGitPullRequest = withUnistyles(GitPullRequest); +const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +export const folderIcon = ; +export const addProjectIcon = ; +export const branchIcon = ; +export const pullRequestIcon = ; + +// Single uppercase initial inside an iconSize.md (16px) square — below the smallest font-size +// token, so it stays a literal sized to the box. +const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; + +export function ProjectIcon({ + projectViewKey, + projectName, + iconDataUri, +}: { + projectViewKey: string; + projectName: string; + iconDataUri: string | null; +}): ReactElement { + const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + fallbackText: { + fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE, + fontWeight: theme.fontWeight.semibold, + }, +})); diff --git a/packages/app/src/screens/new-workspace/form/index.tsx b/packages/app/src/screens/new-workspace/form/index.tsx new file mode 100644 index 0000000000..d05adb2e61 --- /dev/null +++ b/packages/app/src/screens/new-workspace/form/index.tsx @@ -0,0 +1,392 @@ +import { useCallback, useMemo, type ReactElement, type ReactNode, type RefObject } from "react"; +import { View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox"; +import { HostStatusDot } from "@/components/host-status-dot"; +import { HostPicker } from "@/components/hosts/host-picker"; +import type { LaunchTarget } from "@/new-workspace-launch/target"; +import type { HostProjectListItem } from "@/projects/host-projects"; +import type { HostProfile } from "@/types/host-connection"; +import type { PickerItem } from "../../new-workspace-picker-item"; +import { + FIELD_BADGE_HEIGHT, + FIELD_HEIGHT, + FieldTrigger, + type FieldTriggerVariant, +} from "./field-trigger"; +import { branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons"; +import { LaunchControl } from "./launch-control"; +import { AddProjectOption, IsolationOption, ProjectOption, RefOption } from "./options"; + +type Isolation = "local" | "worktree"; +type RenderOption = NonNullable; + +interface PickerControl { + anchorRef: RefObject; + open: () => void; + openState: boolean; + onOpenChange: (open: boolean) => void; +} + +export interface NewWorkspaceFormProps { + isCompact: boolean; + isPending: boolean; + project: PickerControl & { + options: ComboboxOption[]; + projectByOptionId: Map; + iconDataByProjectViewKey: Map; + selectedProject: HostProjectListItem | null; + selectedOptionId: string; + triggerLabel: string; + selectedServerId: string; + /** COMPAT(workspaceMultiplicity): without it only worktree-capable projects are selectable. */ + supportsWorkspaceMultiplicity: boolean; + onSelect: (id: string) => void; + onAddProject: () => void; + }; + host: PickerControl & { + allHosts: HostProfile[]; + selectedServerId: string; + onSelect: (id: string) => void; + onAddHost: () => void; + }; + isolation: PickerControl & { + effectiveIsolation: Isolation; + canCreateWorktree: boolean; + onSelect: (isolation: Isolation) => void; + }; + base: PickerControl & { + selectedSourceDirectory: string | null; + selectedItem: PickerItem | null; + triggerLabel: string; + options: ComboboxOption[]; + itemById: Map; + selectedOptionId: string; + onSelect: (id: string) => void; + setSearchQuery: (query: string) => void; + emptyText: string; + showRefPicker: boolean; + }; + launch: { + serverId: string; + target: LaunchTarget; + onChange: (target: LaunchTarget) => void; + profiles: readonly TerminalProfile[]; + disabled: boolean; + }; +} + +function hostOptionTestID(serverId: string): string { + return `new-workspace-host-picker-option-${serverId}`; +} + +function isProjectSelectable( + project: HostProjectListItem, + supportsWorkspaceMultiplicity: boolean, +): boolean { + return ( + supportsWorkspaceMultiplicity || + project.hosts.some((host) => host.worktreeSupport !== "unsupported") + ); +} + +function useProjectPicker( + project: NewWorkspaceFormProps["project"], + isPending: boolean, +): { renderOption: RenderOption; footer: ReactNode } { + const { projectByOptionId, iconDataByProjectViewKey, selectedServerId } = project; + const { supportsWorkspaceMultiplicity, onAddProject } = project; + const renderOption = useCallback( + (row) => { + const item = projectByOptionId.get(row.option.id); + return ( + + ); + }, + [ + iconDataByProjectViewKey, + isPending, + projectByOptionId, + selectedServerId, + supportsWorkspaceMultiplicity, + ], + ); + const footer = useMemo(() => , [onAddProject]); + return { renderOption, footer }; +} + +function useIsolationPicker( + isolation: NewWorkspaceFormProps["isolation"], + isPending: boolean, +): { options: ComboboxOption[]; onSelect: (id: string) => void; renderOption: RenderOption } { + const { t } = useTranslation(); + const { canCreateWorktree, onSelect, onOpenChange } = isolation; + // "New worktree" is omitted entirely (not disabled) when the project isn't a + // git checkout, since worktree isolation is impossible there. + const options = useMemo(() => { + const local = { id: "local", label: t("newWorkspace.isolation.local") }; + if (!canCreateWorktree) return [local]; + return [local, { id: "worktree", label: t("newWorkspace.isolation.worktree") }]; + }, [canCreateWorktree, t]); + const handleSelect = useCallback( + (id: string) => { + onSelect(id === "worktree" ? "worktree" : "local"); + onOpenChange(false); + }, + [onOpenChange, onSelect], + ); + const renderOption = useCallback( + (row) => , + [isPending], + ); + return { options, onSelect: handleSelect, renderOption }; +} + +function useRefRenderOption(itemById: Map, isPending: boolean): RenderOption { + return useCallback( + (row) => , + [isPending, itemById], + ); +} + +/** + * The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack + * of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field + * that does not apply to the selected project is left out, and Host only shows with a second host. + */ +export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement { + const { t } = useTranslation(); + const { isCompact, isPending, project, host, isolation, base, launch } = props; + const variant: FieldTriggerVariant = isCompact ? "field" : "badge"; + const projectPicker = useProjectPicker(project, isPending); + const isolationPicker = useIsolationPicker(isolation, isPending); + const renderRefOption = useRefRenderOption(base.itemById, isPending); + + const showHost = host.allHosts.length > 1; + const selectedHostLabel = + host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; + const selectedProject = project.selectedProject; + const selectedProjectIconUri = selectedProject + ? (project.iconDataByProjectViewKey.get(selectedProject.viewKey) ?? null) + : null; + const projectIcon = useMemo( + () => + selectedProject ? ( + + ) : ( + folderIcon + ), + [selectedProject, selectedProjectIconUri], + ); + const hostIcon = useMemo( + () => , + [host.selectedServerId], + ); + const isWorktree = isolation.effectiveIsolation === "worktree"; + const isolationValue = isWorktree + ? t("newWorkspace.isolation.worktree") + : t("newWorkspace.isolation.local"); + const refIcon = base.selectedItem?.kind === "github-pr" ? pullRequestIcon : branchIcon; + + const projectField = ( + + ); + const hostField = showHost ? ( + + + + ) : null; + const isolationField = isolation.canCreateWorktree ? ( + + ) : null; + const baseField = base.showRefPicker ? ( + + ) : null; + const launchField = ( + + ); + + const pickers = ( + <> + + + + + ); + + if (isCompact) { + return ( + + {projectField} + {hostField} + {isolationField} + {baseField} + {launchField} + {/* Isolation and Base come and go with the project; their rows stay reserved so the + stack keeps one height and nothing above the composer jumps. */} + {isolationField ? null : } + {baseField ? null : } + {pickers} + + ); + } + + return ( + + {projectField} + {hostField ? {hostField} : null} + {isolationField ? {isolationField} : null} + {baseField ? {baseField} : null} + + {launchField} + {pickers} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + compactStack: { + marginBottom: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + gap: theme.spacing[2], + }, + reservedField: { + height: FIELD_HEIGHT, + }, + desktopRow: { + flexDirection: "row", + alignItems: "center", + minHeight: FIELD_BADGE_HEIGHT, + // Matches the gap between the composer and the Import session pill below it. + marginBottom: theme.spacing[4], + // The badge adds its own left padding; offset it so the project icon's left + // edge lands exactly on the "New workspace" title's left edge. The trailing + // inset mirrors it so the launch chip stops on the composer's inner content + // rather than running out to the composer's border. + paddingHorizontal: theme.spacing[4], + gap: theme.spacing[2], + }, + desktopControl: { + minWidth: 0, + flexShrink: 1, + }, + // Pushes the launch control to the trailing edge of the desktop meta row. + launchSpacer: { + flex: 1, + }, +})); diff --git a/packages/app/src/new-workspace-launch/launch-control.tsx b/packages/app/src/screens/new-workspace/form/launch-control.tsx similarity index 79% rename from packages/app/src/new-workspace-launch/launch-control.tsx rename to packages/app/src/screens/new-workspace/form/launch-control.tsx index 785cfb56ac..fb6af66022 100644 --- a/packages/app/src/new-workspace-launch/launch-control.tsx +++ b/packages/app/src/screens/new-workspace/form/launch-control.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo, useState, type ReactElement } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { useRouter } from "expo-router"; -import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native"; +import { MessageCircle, SquareTerminal } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { DropdownMenu, @@ -30,14 +30,17 @@ import { resolveLaunchProfile, terminalLaunchTarget, type LaunchTarget, -} from "./target"; +} from "@/new-workspace-launch/target"; +import { + FieldTriggerContent, + useFieldTriggerStyle, + type FieldTriggerVariant, +} from "./field-trigger"; const ThemedMessageCircle = withUnistyles(MessageCircle); const ThemedSquareTerminal = withUnistyles(SquareTerminal); -const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); const chatIcon = ; const blankTerminalIcon = ; @@ -83,12 +86,7 @@ export interface LaunchControlProps { onChange: (target: LaunchTarget) => void; profiles: readonly TerminalProfile[]; disabled?: boolean; - /** - * The meta row's shared badge style. Passed in rather than redeclared so this - * trigger stays pixel-identical to the project, host, and branch triggers it - * sits beside; a local copy drifts the moment one of them changes. - */ - badgePressableStyle: React.ComponentProps["style"]; + variant: FieldTriggerVariant; } function TriggerIcon({ @@ -108,8 +106,8 @@ function TriggerIcon({ } /** - * The meta-row control choosing what New workspace submits to: the chat agent, - * or a terminal. One trigger shaped like every other chip on that row, opening + * The form control choosing what New workspace submits to: the chat agent, + * or a terminal. One trigger shaped like every other field trigger, opening * a menu whose Chat and Terminal groups are what keep a terminal profile named * "Codex" from reading as a chat Codex agent. */ @@ -119,13 +117,18 @@ export function LaunchControl({ onChange, profiles, disabled = false, - badgePressableStyle, + variant, }: LaunchControlProps) { const { t } = useTranslation(); const router = useRouter(); const [open, setOpen] = useState(false); const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]); + const triggerStyle = useFieldTriggerStyle(variant, disabled); + const triggerIcon = useMemo( + () => , + [selectedProfile, target], + ); // A profile's own name and logo say nothing about where it runs: a row // reading "(openai logo) Codex" is indistinguishable from a chat Codex agent. @@ -149,32 +152,26 @@ export function LaunchControl({ return ( - {/* The trigger is the Pressable itself, styled by the meta row's shared - badge style, so it lands pixel-identical to the project, host, and - branch chips it sits beside. */} {/* MenuTrigger is not a forwardRef component and pins its own ref, so the tooltip anchors to a wrapping View instead. Suppressed while the menu is open so the two surfaces never overlap. */} - + - - - - - {triggerLabel} - {selectedProfile ? ( - {selectedProfile.name} - ) : null} - - + @@ -232,27 +229,10 @@ export function LaunchControl({ } const styles = StyleSheet.create((theme) => ({ - iconBox: { - width: theme.iconSize.md, - height: theme.iconSize.md, - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - }, - label: { - minWidth: 0, - fontSize: theme.fontSize.base, - color: theme.colors.foregroundMuted, - flexShrink: 1, - }, tooltipText: { fontSize: theme.fontSize.base, color: theme.colors.popoverForeground, }, - profileName: { - fontSize: theme.fontSize.base, - color: theme.colors.foregroundExtraMuted, - }, command: { fontFamily: theme.fontFamily.mono, fontSize: theme.fontSize.sm, diff --git a/packages/app/src/screens/new-workspace/form/options.tsx b/packages/app/src/screens/new-workspace/form/options.tsx new file mode 100644 index 0000000000..88f5c5d4dd --- /dev/null +++ b/packages/app/src/screens/new-workspace/form/options.tsx @@ -0,0 +1,166 @@ +import { useMemo, type ReactElement } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { getHostProjectSourceDirectory, type HostProjectListItem } from "@/projects/host-projects"; +import { pickerItemLabel, type PickerItem } from "../../new-workspace-picker-item"; +import { addProjectIcon, branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons"; + +interface OptionRowState { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; +} + +function IconBox({ children }: { children: ReactElement }): ReactElement { + return {children}; +} + +export function ProjectOption({ + option, + selected, + active, + onPress, + project, + iconDataUri, + selectedServerId, + disabled, +}: OptionRowState & { + project: HostProjectListItem | undefined; + iconDataUri: string | null; + selectedServerId: string; + disabled: boolean; +}): ReactElement { + const leadingSlot = useMemo( + () => + project ? ( + + + + ) : undefined, + [iconDataUri, project], + ); + if (!project) return ; + return ( + + ); +} + +export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement { + const { t } = useTranslation(); + const openProjectKeys = useShortcutKeys("new-agent"); + const shortcut = useMemo( + () => (openProjectKeys ? : null), + [openProjectKeys], + ); + return ( + + ); +} + +const worktreeLeading = {branchIcon}; +const localLeading = {folderIcon}; + +export function IsolationOption({ + option, + selected, + active, + onPress, + disabled, +}: OptionRowState & { disabled: boolean }): ReactElement { + return ( + + ); +} + +const branchLeading = {branchIcon}; +const pullRequestLeading = {pullRequestIcon}; + +export function RefOption({ + option, + selected, + active, + onPress, + item, + disabled, +}: OptionRowState & { item: PickerItem | undefined; disabled: boolean }): ReactElement { + const { t } = useTranslation(); + const divergenceLabel = item?.kind === "branch" ? item.divergenceLabel : undefined; + const trailingSlot = useMemo( + () => + divergenceLabel ? {divergenceLabel} : undefined, + [divergenceLabel], + ); + if (!item) return ; + + const isBranch = item.kind === "branch"; + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + iconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + }, + divergenceLabel: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + fontVariant: ["tabular-nums"], + }, +})); diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx index 39b55e1b2e..39d4efc1ce 100644 --- a/packages/app/src/screens/new-workspace/import-session-button.tsx +++ b/packages/app/src/screens/new-workspace/import-session-button.tsx @@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles"; import { Button } from "@/components/ui/button"; /** - * The new workspace screen's way into Import session: a ghost button at the top on compact + * The new workspace screen's way into Import session: a ghost button in the header on compact * layouts, an outlined pill under the composer otherwise. Both are muted until hovered. */ export function ImportSessionButton({ @@ -17,7 +17,7 @@ export function ImportSessionButton({ }) { const { t } = useTranslation(); return ( - + ", + ); + expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual([ + "needle", + "needle", + ]); + expect(findRenderedMatches(row, "Promptfind")).toEqual([]); + expect(findRenderedMatches(row, "find this needle")).toHaveLength(1); + expect(findMessageMatches(row.parentElement, "message-1", "needle")).toHaveLength(2); +}); + +it("leaves rows without the plugin marker limited to marked message text", () => { + const row = document.createElement("div"); + row.innerHTML = "
needle
"; + document.body.append(row); + expect(findRenderedMatches(row, "needle")).toEqual([]); +}); + +it("counts only drawn text in a plugin row, including an opened popover", () => { + const row = pluginRow( + '
visible needle
display needle
visibility needletransparent needle
popover needle
', + ); + expect(findRenderedMatches(row, "needle").map((range) => range.toString())).toEqual(["needle"]); + row.querySelector("[popover]")!.showPopover(); + expect(findRenderedMatches(row, "needle")).toHaveLength(2); +}); + +it("keeps text around nested plugin blocks in separate occurrences", () => { + const row = pluginRow("
alpha
details
beta
gamma
delta
"); + expect(findRenderedMatches(row, "alpha beta")).toEqual([]); + expect(findRenderedMatches(row, "gamma delta")).toEqual([]); + expect(findRenderedMatches(row, "details").map((range) => range.toString())).toEqual(["details"]); + expect(findRenderedMatches(row, "beta").map((range) => range.toString())).toEqual(["beta"]); +}); diff --git a/packages/app/src/agent-stream/chat-find/ranges.web.ts b/packages/app/src/agent-stream/chat-find/ranges.web.ts index ea2e901c2d..13da1034a2 100644 --- a/packages/app/src/agent-stream/chat-find/ranges.web.ts +++ b/packages/app/src/agent-stream/chat-find/ranges.web.ts @@ -21,28 +21,55 @@ export function findMessageMatches( return findMessageRows(root, messageId).flatMap((row) => findRenderedMatches(row, query)); } +function renderedTextGroups(row: HTMLElement): Text[][] { + const groups: Text[][] = []; + // A plugin renderer owns the DOM of the message it replaced and sets no markers, so the + // whole row is searched and each div (a React Native View or Text) bounds a match. + const pluginRow = row.dataset.pluginRow === "true"; + const blocks = pluginRow ? `${BLOCKS},div` : BLOCKS; + const contents = pluginRow + ? [row] + : row.querySelectorAll('[data-message-text="true"]'); + for (const content of contents) { + let group: { block: Element; texts: Text[] } | undefined; + const walker = document.createTreeWalker( + content, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + ); + let node: Node | null; + while ((node = walker.nextNode())) { + if (node instanceof Element) { + // Even an empty nested block separates the text before and after it. + if (node.matches(blocks)) group = undefined; + continue; + } + if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue; + if ( + pluginRow && + !node.parentElement?.checkVisibility({ opacityProperty: true, visibilityProperty: true }) + ) + continue; + const block = node.parentElement?.closest(blocks) ?? content; + if (group?.block !== block) { + group = { block, texts: [] }; + groups.push(group.texts); + } + group.texts.push(node); + } + } + return groups; +} + /** Local offsets belong to the DOM that supplied the text, never to a host parser. */ export function findRenderedMatches(row: HTMLElement, query: string): Range[] { if (!query.trim()) return []; - const groups = new Map(); - for (const content of row.querySelectorAll('[data-message-text="true"]')) { - const walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT); - let node: Node | null; - while ((node = walker.nextNode())) { - if (!(node instanceof Text) || node.parentElement?.closest(IGNORED)) continue; - const block = node.parentElement?.closest(BLOCKS) ?? content; - const texts = groups.get(block) ?? []; - texts.push(node); - groups.set(block, texts); - } - } const pattern = query .trim() .split(/\s+/) .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) .join("\\s+"); const ranges: Range[] = []; - for (const texts of groups.values()) { + for (const texts of renderedTextGroups(row)) { const points = texts.map((node) => ({ node, start: 0, end: 0 })); let text = ""; for (const point of points) { diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 97490c2a17..00974867e4 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1250,6 +1250,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool key={item.id} data-history-row-id={item.id} data-message-id={getStreamItemMessageId(item)} + data-plugin-row={item.kind === "plugin" ? "true" : undefined} style={streamRowStyle} > diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index cc8023b251..4a8f74af9a 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1318,6 +1318,13 @@ the source row, so updates to one streaming item do not remount its renderer. Us `useRevealedText(text, phase)` hook when a renderer should pace streaming text like Paseo's built-in assistant rows. +Chat find (Ctrl or Cmd+F) finds and highlights text in replacement rows when the query also +matches the original message. The host searches the original prompt text or the assistant reply +with Markdown syntax removed, then the app counts matches in the text your renderer draws. +Text added only by your renderer is not independently indexed. Matches stay within a rendered +block, and find skips buttons, icons, and anything with `aria-hidden="true"`. Find only runs on +the web and desktop apps. + ### Append a timeline row from the daemon A server handler can add a plugin-owned row to canonical history: From 9bd58f66b2dfc195898bf1178e84f02933c0d41e Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:57:04 +0200 Subject: [PATCH 13/23] Copy Korean terminal text without extra spaces (#7254) * fix(terminal): preserve Korean text when copying native terminal cells * fix(terminal): omit continuation cells from copied text Native terminal buffers already retain authoritative cell widths. Skip zero-width continuation cells when serializing a selection so Korean text copies with only its real spaces, while preserving the buffer representation. Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com> --------- Co-authored-by: helloimdevman <333048235+helloimdevman@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../terminal-selection.test.ts | 45 +++++++++++++++++++ .../native-renderer/terminal-selection.ts | 5 ++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts index 5f2a4bb593..3c390a1f5c 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -41,6 +41,51 @@ function rowWithText( } describe("native terminal selection", () => { + it("copies Korean output without adding spaces for continuation cells", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 }); + const copied: string[] = []; + try { + await terminal.write("한글 문장"); + const bounds = terminal.getBufferBounds(); + await copyTerminalSelection({ + terminal, + selection: { + start: { row: bounds.cursorRow, col: 0 }, + end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 }, + coordinateEpoch: bounds.coordinateEpoch, + }, + clipboard: { + writeText: async (text) => { + copied.push(text); + }, + }, + }); + expect(copied).toEqual(["한글 문장"]); + } finally { + terminal.dispose(); + } + }); + + it("copies Korean text and real spaces without wide-character continuation spaces", async () => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 }); + try { + await terminal.write("한글 문장 | 성공"); + const bounds = terminal.getBufferBounds(); + expect( + extractTerminalSelectedText({ + terminal, + selection: { + start: { row: bounds.cursorRow, col: 0 }, + end: { row: bounds.cursorRow, col: bounds.cursorCol - 1 }, + coordinateEpoch: bounds.coordinateEpoch, + }, + }), + ).toBe("한글 문장 | 성공"); + } finally { + terminal.dispose(); + } + }); + it("expands a long-press coordinate to the whole terminal word", async () => { const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 32, scrollbackLines: 20 }); await terminal.write("before PASEO_TARGET after\r\n"); diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.ts b/packages/app/src/terminal/native-renderer/terminal-selection.ts index 1bda469e42..02fb415aac 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.ts @@ -327,7 +327,10 @@ export function extractTerminalSelectedText(input: TerminalSelectedTextInput): s const startCol = startsOnThisRow ? selection.start.col : 0; const endCol = endsOnThisRow ? selection.end.col : cells.length - 1; const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1); - const line = selectedCells.map((cell) => cell.char || " ").join(""); + const line = selectedCells + .filter((cell) => cell.width !== 0) + .map((cell) => cell.char) + .join(""); if (offset > 0 && window.wrappedRows[offset] !== true) { text += "\n"; } From 90c6de058039fd6ea57f68e4b3417300835f9805 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:01:33 +0200 Subject: [PATCH 14/23] fix(app): keep streaming replies complete after reloading (#7256) A catch-up projection could replace the live reply while leaving its cached prefix in the committed rows. Completion then discarded the complete reply as a duplicate. Reconcile the covered cached prefix together with the live reply. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/src/timeline/replica.test.ts | 84 +++++++++++++++++++ .../src/timeline/session-stream-reducers.ts | 16 +++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index fa7521e487..47bd36596a 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -77,6 +77,90 @@ function applySynced(agentId: string, seq: number): void { afterEach(() => useSessionStore.getState().clearSession(SERVER_ID)); describe("viewed timeline persistence", () => { + it("keeps one complete reply when live chunks arrive before a cached chat catches up", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const partial = "Paragraph 1. Paragraph 2."; + const complete = `${partial} Paragraph 3. END`; + const owner = createOwner({ + readTimeline: async () => ({ + agentId: AGENT_ID, + items: [{ ...item("reply-1", partial, 4), messageId: "reply-1" }], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 }, + hasOlder: false, + }), + commitTimeline: () => undefined, + }); + try { + owner.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll( + () => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID) + .status, + ) + .toBe("painted"); + owner.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: " Paragraph 3.", messageId: "reply-1" }, + } as AgentStreamEventPayload, + seq: 5, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + owner.flushStreamAgent(AGENT_ID); + owner.applyTimelineResponse({ + requestId: "resume", + agentId: AGENT_ID, + agent: null, + direction: "after", + projection: "projected", + reset: false, + epoch: "epoch-1", + window: { minSeq: 1, maxSeq: 6, nextSeq: 7 }, + startCursor: { epoch: "epoch-1", seq: 5 }, + endCursor: { epoch: "epoch-1", seq: 6 }, + entries: [ + { + provider: "codex", + item: { type: "assistant_message", text: complete, messageId: "reply-1" }, + timestamp: "2026-08-26T10:00:02.000Z", + seqStart: 2, + seqEnd: 6, + sourceSeqRanges: [{ startSeq: 2, endSeq: 6 }], + collapsed: ["assistant_merge"], + }, + ], + error: null, + hasNewer: false, + hasOlder: false, + staleCursor: false, + gap: false, + }); + const replyTexts = () => { + const session = useSessionStore.getState().sessions[SERVER_ID]; + return [ + ...(session?.agentStreamTail.get(AGENT_ID) ?? []), + ...(session?.agentStreamHead.get(AGENT_ID) ?? []), + ] + .filter((entry) => entry.kind === "assistant_message") + .map((entry) => entry.text); + }; + expect.soft(replyTexts()).toEqual([complete]); + owner.enqueueStreamEvent(AGENT_ID, { + event: { type: "turn_completed", provider: "codex" } as AgentStreamEventPayload, + seq: 7, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:03.000Z"), + }); + owner.flushStreamAgent(AGENT_ID); + expect.soft(replyTexts()).toEqual([complete]); + } finally { + owner.dispose(); + } + }); + it("shares an in-flight cache preparation with the viewed owner", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index dce9daa4da..34a461d893 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -811,7 +811,7 @@ function reconcileOverlappingProjectedAssistant(params: { }; const headMatch = findMatch(params.head); - const tailMatch = headMatch ? null : findMatch(params.tail); + const tailMatch = findMatch(params.tail); const match = headMatch ?? tailMatch; if (!match) { return { tail: params.tail, head: params.head, reconciled: false }; @@ -833,8 +833,20 @@ function reconcileOverlappingProjectedAssistant(params: { }; if (headMatch) { + const tailCursor = tailMatch?.current.timelineCursor; + const replacesCachedPrefix = + tailMatch !== null && + tailCursor?.epoch === params.epoch && + unit.sourceSeqRanges.some( + (range) => range.startSeq <= tailCursor.seq && tailCursor.seq <= range.endSeq, + ); return { - tail: params.tail, + // Live chunks can arrive before catch-up while their cached prefix is still painted. + // The projected reply owns both copies; keeping the prefix would duplicate it and + // cause completion to discard the updated head as an already-committed item. + tail: replacesCachedPrefix + ? [...params.tail.slice(0, tailMatch.index), ...params.tail.slice(tailMatch.index + 1)] + : params.tail, head: replaceMatch(params.head, headMatch.index), reconciled: true, }; From e5d9307cf9ef1a719729765fa5fe59f25102f090 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:55:08 +0200 Subject: [PATCH 15/23] fix(terminal): copy wide characters selected from either half (#7260) Include the leading cell when a selection starts on a wide character continuation, matching the native renderer highlight. Keep continuation cells out of copied text so real spaces remain unchanged. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../terminal-selection.test.ts | 42 +++++++++++++++++++ .../native-renderer/terminal-selection.ts | 8 +++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts index 3c390a1f5c..ecd2b44ffa 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.test.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.test.ts @@ -41,6 +41,48 @@ function rowWithText( } describe("native terminal selection", () => { + it.each([ + { startCol: 1, endCol: 8, expected: "한글 문장" }, + { startCol: 3, endCol: 8, expected: "글 문장" }, + { startCol: 1, endCol: 1, expected: "한" }, + { startCol: 0, endCol: 0, expected: "한" }, + { startCol: 0, endCol: 1, expected: "한" }, + { startCol: 4, endCol: 6, expected: " 문" }, + ])( + "copies complete wide characters for selected columns $startCol through $endCol", + async ({ startCol, endCol, expected }) => { + const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 }); + const copied: string[] = []; + try { + await terminal.write("한글 문장"); + const bounds = terminal.getBufferBounds(); + const selection = createTerminalSelectionModel(); + selection.begin({ + coordinate: { row: bounds.cursorRow, col: endCol }, + bounds, + }); + selection.update({ + coordinate: { row: bounds.cursorRow, col: startCol }, + bounds, + }); + + await copyTerminalSelection({ + terminal, + selection: selection.getSnapshot().range, + clipboard: { + writeText: async (text) => { + copied.push(text); + }, + }, + }); + + expect(copied).toEqual([expected]); + } finally { + terminal.dispose(); + } + }, + ); + it("copies Korean output without adding spaces for continuation cells", async () => { const terminal = createNativeHeadlessTerminal({ rows: 3, cols: 30 }); const copied: string[] = []; diff --git a/packages/app/src/terminal/native-renderer/terminal-selection.ts b/packages/app/src/terminal/native-renderer/terminal-selection.ts index 02fb415aac..d222a4cc90 100644 --- a/packages/app/src/terminal/native-renderer/terminal-selection.ts +++ b/packages/app/src/terminal/native-renderer/terminal-selection.ts @@ -324,9 +324,13 @@ export function extractTerminalSelectedText(input: TerminalSelectedTextInput): s const cells = window.rows[offset] ?? []; const startsOnThisRow = row === selection.start.row; const endsOnThisRow = row === selection.end.row; - const startCol = startsOnThisRow ? selection.start.col : 0; + let startCol = Math.max(0, startsOnThisRow ? selection.start.col : 0); + // Selecting either half highlights the whole wide character, so copy its leading cell too. + if (startCol > 0 && cells[startCol]?.width === 0) { + startCol -= 1; + } const endCol = endsOnThisRow ? selection.end.col : cells.length - 1; - const selectedCells = cells.slice(Math.max(0, startCol), Math.max(0, endCol) + 1); + const selectedCells = cells.slice(startCol, Math.max(0, endCol) + 1); const line = selectedCells .filter((cell) => cell.width !== 0) .map((cell) => cell.char) From b2da58b5bf08d079e88906ee08cfd1c24a5f7695 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:51:49 +0200 Subject: [PATCH 16/23] fix(app): keep chat keyboard scrolling after closing Find (#7263) Closing Find focused its non-scrolling wrapper, leaving Page Up and Page Down unable to move the feed. Return focus through the viewport handle while preserving the reading position. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/chat-find.spec.ts | 44 +++++++++++++++++++ .../src/agent-stream/chat-find/index.web.tsx | 4 +- .../app/src/agent-stream/strategy-web.tsx | 1 + packages/app/src/agent-stream/strategy.ts | 2 + 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/app/e2e/browser/chat-find.spec.ts b/packages/app/e2e/browser/chat-find.spec.ts index a7316fe165..e8c4b279ae 100644 --- a/packages/app/e2e/browser/chat-find.spec.ts +++ b/packages/app/e2e/browser/chat-find.spec.ts @@ -5,6 +5,7 @@ import { seedMockAgentWorkspace, type MockAgentWorkspace, } from "../support/helpers/mock-agent"; +import { waitForTimelineGeometryToSettle } from "../support/helpers/timeline-pagination"; import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate"; import { expectReconnectingToastGone, @@ -124,6 +125,49 @@ async function closeFind(page: Page) { .toEqual([]); } +async function expectChatKeyboardScroll(page: Page, key: "PageUp" | "PageDown") { + // The scrolling surface has no accessible name; use its existing test id. + const viewport = page.getByTestId("agent-chat-scroll"); + await waitForTimelineGeometryToSettle(page); + const before = await viewport.evaluate((element) => element.scrollTop); + await page.keyboard.press(key); + const direction = key === "PageUp" ? -1 : 1; + await expect + .poll( + async () => direction * ((await viewport.evaluate((element) => element.scrollTop)) - before), + ) + .toBeGreaterThan(100); +} + +test("keeps keyboard scrolling the chat after closing Find", async ({ page }, testInfo) => { + const agent = await seedMockAgentWorkspace({ + repoPrefix: "chat-find-scroll-focus-", + title: "Chat Find keyboard scrolling", + initialPrompt: "Render the response", + featureValues: { mockAssistantResponse: TALL_RESPONSE }, + }); + try { + await agent.client.waitForFinish(agent.agentId, 15_000); + await openAgentRoute(page, agent); + await expect(page.getByTestId("assistant-message").first()).toBeVisible(); + await searchChat(page, "Closing paragraph"); + await expectHighlight(page, "Closing paragraph"); + await waitForTimelineGeometryToSettle(page); + await closeFind(page); + await expectChatKeyboardScroll(page, "PageUp"); + await page.screenshot({ path: testInfo.outputPath("chat-after-find-page-up.png") }); + await expectChatKeyboardScroll(page, "PageDown"); + await searchChat(page, "Closing paragraph"); + await expectHighlight(page, "Closing paragraph"); + await waitForTimelineGeometryToSettle(page); + await page.getByRole("button", { name: "Close Find", exact: true }).click(); + await expect(query(page)).toHaveCount(0); + await expectChatKeyboardScroll(page, "PageUp"); + } finally { + await agent.cleanup(); + } +}); + test("loads older history through prompt navigation and finds repeated rendered Markdown", async ({ page, }, testInfo) => { diff --git a/packages/app/src/agent-stream/chat-find/index.web.tsx b/packages/app/src/agent-stream/chat-find/index.web.tsx index c817b55028..a083dc5d9e 100644 --- a/packages/app/src/agent-stream/chat-find/index.web.tsx +++ b/packages/app/src/agent-stream/chat-find/index.web.tsx @@ -118,8 +118,8 @@ export function ChatFind({ }, [active, isInteractive, model]); const close = useCallback(() => { model.close(); - root.current?.focus({ preventScroll: true }); - }, [model]); + viewportRef.current?.focus?.(); + }, [model, viewportRef]); let status = ""; if (state.phase === "searching") status = t("paneFind.searching"); else if (state.phase === "loading") status = t("paneFind.loading"); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index 00974867e4..e57fc98e48 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -1162,6 +1162,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool useEffect(() => { const handle: StreamViewportHandle = { + focus: () => scrollContainerRef.current?.focus({ preventScroll: true }), scrollToBottom: () => { setFollowOutput(true); cancelPendingStickToBottom(); diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 5f43f8f6b1..7604b48cdd 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -51,6 +51,8 @@ export interface ScrollToMessageOccurrence { } export interface StreamViewportHandle { + /** Give keyboard navigation back to the scrolling surface on web. */ + focus?: () => void; scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void; prepareForViewportChange: () => void; scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void; From a7a21d91c2ef5556d80cd764aa47d9d0f31c858f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:52:15 +0200 Subject: [PATCH 17/23] Keep overlapping directory searches from exhausting the heap (#7265) * fix(server): keep overlapping directory searches within their scan budget Construct child paths and branch iterators as the round-robin walk consumes them. Eager sibling expansion retained hundreds of thousands of unvisited children per search and exhausted the heap when requests overlapped. * test(server): stop directory-search children before fixture cleanup --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/utils/directory-suggestions.test.ts | 97 ++++++++++++++++++- .../server/src/utils/directory-suggestions.ts | 91 +++++++++-------- 2 files changed, 138 insertions(+), 50 deletions(-) diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 711a721e48..a7d5d5e9eb 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1,5 +1,5 @@ import { createHook } from "node:async_hooks"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn, type ChildProcess } from "node:child_process"; import { mkdtempSync, mkdirSync, @@ -1066,6 +1066,16 @@ describe("relative typed-entry configuration", () => { }); }); +interface SearchProcessExit { + code: number | null; + signal: string | null; +} + +interface SearchProcess { + child: ChildProcess; + exited: Promise; +} + // Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether // every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the // read and is what made large home directories exceed the client request timeout. @@ -1073,6 +1083,7 @@ describe("home-tree scan cost", () => { const DEPTH = 8; const CHAINS = 40; let scanRoot: string; + let searchProcess: SearchProcess | null = null; beforeEach(() => { scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-"))); @@ -1083,8 +1094,16 @@ describe("home-tree scan cost", () => { } }); - afterEach(() => { - rmSync(scanRoot, { recursive: true, force: true }); + afterEach(async () => { + try { + if (searchProcess) { + searchProcess.child.kill(); + await searchProcess.exited; + } + } finally { + searchProcess = null; + rmSync(scanRoot, { recursive: true, force: true }); + } }); // Nothing matches, so the scan walks the whole tree instead of stopping at a confident result. @@ -1102,6 +1121,78 @@ describe("home-tree scan cost", () => { return stopPathContainmentMetrics(); } + it("finishes overlapping broad-tree searches within a bounded heap", async () => { + const broadRoot = path.join(scanRoot, "broad"); + mkdirSync(broadRoot); + for (let branch = 0; branch < 30; branch += 1) { + const branchRoot = path.join(broadRoot, `branch-${branch}`); + mkdirSync(branchRoot); + for (let group = 0; group < 30; group += 1) { + const groupRoot = path.join(branchRoot, `group-${group}`); + mkdirSync(groupRoot); + for (let leaf = 0; leaf < 30; leaf += 1) { + mkdirSync(path.join(groupRoot, `leaf-${leaf}`)); + } + } + } + // A smaller real tree and explicit heap budget keep the million-directory repro cheap in CI. + const moduleUrl = new URL("./directory-suggestions.ts", import.meta.url).href; + const source = ` + const { searchDirectoryEntries } = await import(${JSON.stringify(moduleUrl)}); + const results = await Promise.all(Array.from({ length: 14 }, (_, index) => + searchDirectoryEntries({ + root: ${JSON.stringify(broadRoot)}, + query: "z".repeat(index + 1), + pathFormat: "absolute", + includeFiles: false, + includeDirectories: true, + pathQueryPolicy: "rooted", + rootAliases: ["~"], + blankQueryBehavior: "none", + confidentResultScanThreshold: 5000, + maxEntriesScanned: 2000, + limit: 30, + }) + )); + process.stdout.write(JSON.stringify(results)); + `; + const child = spawn( + process.execPath, + [ + "--max-old-space-size=128", + "--conditions=source", + "--import", + "tsx", + "--input-type=module", + "--eval", + source, + ], + { stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const exited = new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code, signal) => resolve({ code, signal })); + }); + searchProcess = { child, exited }; + const exit = await exited; + expect({ ...exit, outOfMemory: stderr.includes("heap out of memory") }).toEqual({ + code: 0, + signal: null, + outOfMemory: false, + }); + expect(JSON.parse(stdout)).toEqual(Array.from({ length: 14 }, () => [])); + }); + it("derives no containment for a tree that cannot leave the root", async () => { const checks = await countContainmentChecks(scanWholeTree); diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index e0443475bc..e731ccef0f 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -63,6 +63,13 @@ interface WalkStart { resolvedPath: string; } +interface WalkChildrenOptions { + parent: WalkStart; + depth: number; + input: SearchInput; + visited: Set; +} + interface TraversedEntry extends ChildEntry { visiblePath: string; depth: number; @@ -318,17 +325,18 @@ async function searchChildren(input: SearchInput): Promise { const discoveryInput = isInsideExcludedPath(parent, input) ? { ...input, excludedDiscoveryPaths: new Set() } : input; - const entries = await readChildren(parent); - return entries.flatMap((entry) => { + const ranked: RankedEntry[] = []; + for await (const entry of readChildren(parent)) { if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput)) - return []; + continue; const candidate: TraversedEntry = { ...entry, visiblePath: path.join(visibleParent, entry.name), depth: 1, }; - return shouldSuggest(candidate, input) ? [rank(candidate, input)] : []; - }); + if (shouldSuggest(candidate, input)) ranked.push(rank(candidate, input)); + } + return ranked; } async function searchTree( @@ -336,21 +344,8 @@ async function searchTree( start: WalkStart = { visiblePath: input.root, resolvedPath: input.root }, ): Promise { if (!(input.maxEntriesScanned > 0)) return []; - const roots = (await readChildren(start.resolvedPath)).filter((entry) => - staysInsideRoot(entry, input.root), - ); const visited = new Set([start.resolvedPath]); - const branches = roots.flatMap((entry) => - shouldDiscover(entry, input) - ? [ - walkBranch( - { ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 }, - input, - visited, - ), - ] - : [], - ); + const branches = walkChildren({ parent: start, depth: 1, input, visited }); const ranked: RankedEntry[] = []; let scanned = 0; const threshold = input.confidentResultScanThreshold; @@ -381,38 +376,39 @@ async function* walkBranch( ) return; visited.add(entry.resolvedPath); - const children = (await readChildren(entry.resolvedPath)).filter((child) => - staysInsideRoot(child, input.root), - ); - const branches = children.flatMap((child) => - shouldDiscover(child, input) - ? [ - walkBranch( - { - ...child, - visiblePath: path.join(entry.visiblePath, child.name), - depth: entry.depth + 1, - }, - input, - visited, - ), - ] - : [], - ); - yield* roundRobin(branches); + yield* roundRobin(walkChildren({ parent: entry, depth: entry.depth + 1, input, visited })); } -async function* roundRobin(branches: Array>): AsyncGenerator { - let active = branches; - while (active.length) { +// Create a child's path and iterator only when its branch gets a turn. Eagerly constructing every +// sibling's subtree retained hundreds of thousands of unvisited children beyond the scan budget. +async function* walkChildren({ + parent, + depth, + input, + visited, +}: WalkChildrenOptions): AsyncGenerator> { + for await (const child of readChildren(parent.resolvedPath)) { + if (!staysInsideRoot(child, input.root) || !shouldDiscover(child, input)) continue; + yield walkBranch( + { ...child, visiblePath: path.join(parent.visiblePath, child.name), depth }, + input, + visited, + ); + } +} + +async function* roundRobin(branches: AsyncIterable>): AsyncGenerator { + let active: AsyncIterable> | Array> = branches; + while (true) { const nextRound: Array> = []; - for (const branch of active) { + for await (const branch of active) { const next = await branch.next(); if (!next.done) { nextRound.push(branch); yield next.value; } } + if (!nextRound.length) return; active = nextRound; } } @@ -742,9 +738,9 @@ async function resolveDirectory(inputPath: string): Promise { return info?.isDirectory() ? resolved : null; } -async function readChildren(directory: string): Promise { +async function* readChildren(directory: string): AsyncGenerator { const directoryInfo = await stat(directory).catch(() => null); - if (!directoryInfo?.isDirectory()) return []; + if (!directoryInfo?.isDirectory()) return; const cached = CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA ? directoryListCache.get(directory) @@ -774,9 +770,10 @@ async function readChildren(directory: string): Promise { } } - return (await Promise.all(rawEntries.map((entry) => resolveChild(directory, entry)))) - .filter((entry): entry is ChildEntry => entry !== null) - .sort((left, right) => left.name.localeCompare(right.name)); + for (const rawEntry of rawEntries) { + const entry = await resolveChild(directory, rawEntry); + if (entry) yield entry; + } } async function loadGitIgnoredPaths(root: string): Promise> { From 1a6a9fa8d04a4a0b552eb44596201e078c118cbe Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:02:33 +0200 Subject: [PATCH 18/23] fix(claude): keep background child action logs after the parent finishes (#7266) Late child frames were discarded after the launching turn ended, and parent completion cleared accumulated child actions. Keep card updates attached to the launching turn and release child actions on task completion. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.background-subagent.test.ts | 249 ++++++++++-------- .../server/agent/providers/claude/agent.ts | 27 +- .../claude/sidechain-tracker.test.ts | 34 +++ .../providers/claude/sidechain-tracker.ts | 2 +- ...round-subagent-action-log.real.e2e.test.ts | 105 ++++++++ 5 files changed, 289 insertions(+), 128 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index d0a5bf644a..4cab1a5005 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -122,7 +122,7 @@ async function collectTimelineAcrossTurns( seq: rows.length + 1, timestamp: new Date(Date.UTC(2026, 9, 4, 0, 0, rows.length)).toISOString(), item: event.item, - ...(currentTurnId ? { turnId: currentTurnId } : {}), + ...((event.turnId ?? currentTurnId) ? { turnId: event.turnId ?? currentTurnId } : {}), }); } if (event.type === "turn_completed" && ++completedTurns === turnCount) resolve(); @@ -469,122 +469,145 @@ describe("background Claude subagents", () => { }); }); - test("keeps one logged Task card when the child streams on after the parent's turn ends", async () => { - // A child can stream across the main session's turn boundary. Claude opens the next - // main-session turn with system/init; bare child frames do not open a turn. - queryFactory.mockImplementation(() => - buildQueryMock([ - { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, - { - type: "assistant", - message: { - model: "claude-haiku-4-5", - content: [ - { - type: "tool_use", - id: "toolu_background", - name: "Agent", - input: { - subagent_type: "general-purpose", - description: "Count files here", - prompt: "Run ls and reply with the number of entries.", - run_in_background: true, + test.each(["before", "after"])( + "keeps one logged Task card when child tools start %s the parent finishes", + async (timing) => { + // A child can stream across the main session's turn boundary. Claude opens the next + // main-session turn with system/init; bare child frames do not open a turn. + queryFactory.mockImplementation(() => + buildQueryMock([ + { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, + { + type: "assistant", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_background", + name: "Agent", + input: { + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + run_in_background: true, + }, }, - }, - ], + ], + }, }, - }, - { - type: "system", - subtype: "task_started", - task_id: "bg-task", - tool_use_id: "toolu_background", - task_type: "local_agent", - subagent_type: "general-purpose", + { + type: "system", + subtype: "task_started", + task_id: "bg-task", + tool_use_id: "toolu_background", + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Count files here", + prompt: "Run ls and reply with the number of entries.", + }, + { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_background", + content: "Async agent launched successfully.", + }, + ], + }, + }, + ...(timing === "after" + ? [ + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + { + type: "system", + subtype: "init", + session_id: "bg-session", + permissionMode: "default", + }, + ] + : []), + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "toolu_ls", + name: "Bash", + input: { command: "ls -1", description: "List files" }, + }, + ], + }, + }, + ...(timing === "before" + ? [ + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + { + type: "system", + subtype: "init", + session_id: "bg-session", + permissionMode: "default", + }, + ] + : []), + { + type: "user", + parent_tool_use_id: "toolu_background", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }], + }, + }, + { + type: "assistant", + parent_tool_use_id: "toolu_background", + message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] }, + }, + { + type: "system", + subtype: "task_updated", + task_id: "bg-task", + patch: { status: "completed" }, + }, + { + type: "system", + subtype: "task_notification", + task_id: "bg-task", + tool_use_id: "toolu_background", + status: "completed", + }, + { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, + ]), + ); + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + const rows = await collectTimelineAcrossTurns(session, "delegate work", 2); + + const cards = projectTimelineRows({ rows, mode: "projected" }) + .map((entry) => entry.item) + .filter((item) => item.type === "tool_call" && item.callId === "toolu_background"); + + expect(cards).toHaveLength(1); + expect(cards[0]).toMatchObject({ + status: "completed", + detail: { + type: "sub_agent", + subAgentType: "general-purpose", description: "Count files here", - prompt: "Run ls and reply with the number of entries.", + log: "[Bash] ls -1", }, - { - type: "user", - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "toolu_background", - content: "Async agent launched successfully.", - }, - ], - }, - }, - { - type: "assistant", - parent_tool_use_id: "toolu_background", - message: { - model: "claude-haiku-4-5", - content: [ - { - type: "tool_use", - id: "toolu_ls", - name: "Bash", - input: { command: "ls -1", description: "List files" }, - }, - ], - }, - }, - { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, - { type: "system", subtype: "init", session_id: "bg-session", permissionMode: "default" }, - { - type: "user", - parent_tool_use_id: "toolu_background", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "a\nb\nc" }], - }, - }, - { - type: "assistant", - parent_tool_use_id: "toolu_background", - message: { model: "claude-haiku-4-5", content: [{ type: "text", text: "3" }] }, - }, - { - type: "system", - subtype: "task_updated", - task_id: "bg-task", - patch: { status: "completed" }, - }, - { - type: "system", - subtype: "task_notification", - task_id: "bg-task", - tool_use_id: "toolu_background", - status: "completed", - }, - { type: "result", subtype: "success", usage: {}, total_cost_usd: 0 }, - ]), - ); - const session = await new ClaudeAgentClient({ - logger: createTestLogger(), - queryFactory, - resolveBinary: async () => "/test/claude/bin", - }).createSession({ provider: "claude", cwd: process.cwd() }); - const rows = await collectTimelineAcrossTurns(session, "delegate work", 2); - - const cards = projectTimelineRows({ rows, mode: "projected" }) - .map((entry) => entry.item) - .filter((item) => item.type === "tool_call" && item.callId === "toolu_background"); - - expect(cards).toHaveLength(1); - expect(cards[0]).toMatchObject({ - status: "completed", - detail: { - type: "sub_agent", - subAgentType: "general-purpose", - description: "Count files here", - log: "[Bash] ls -1", - }, - }); - }); + }); + }, + ); test.each([ { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index b12ab706da..2af8983de0 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2250,7 +2250,7 @@ class ClaudeAgentSession implements AgentSession { private readonly sidechainTracker = new ClaudeSidechainTracker({ getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? - this.settledSubagentToolCallInCurrentTurn(toolUseId)?.input ?? + this.settledSubagentToolCalls.get(toolUseId)?.input ?? null, // Releases that predate the task protocol announce nothing, so the tracker keeps deriving // identity and status from frames for them. Detecting the capability beats comparing version @@ -2258,13 +2258,11 @@ class ClaudeAgentSession implements AgentSession { isDescriptorOwnedElsewhere: () => this.taskProtocolSource.isActive, // A background child streams its frames after Claude has already answered the call, so its // card is rebuilt from the settled call: a running one would replace the settled, labeled card. - // A card belongs to the turn that answered the call, so frames from a later turn leave it be. + // Late card updates retain the turn that answered the call. needsSyntheticParentToolCard: (toolUseId) => - (this.toolUseCache.has(toolUseId) || - this.settledSubagentToolCallInCurrentTurn(toolUseId) !== undefined) && + (this.toolUseCache.has(toolUseId) || this.settledSubagentToolCalls.has(toolUseId)) && this.taskProtocolSource.needsSyntheticParentToolCard(toolUseId), - getSettledParentToolCall: (toolUseId) => - this.settledSubagentToolCallInCurrentTurn(toolUseId)?.item, + getSettledParentToolCall: (toolUseId) => this.settledSubagentToolCalls.get(toolUseId)?.item, }); private persistedHistory: PersistedTimelineEntry[] = []; private persistedProviderSubagentEvents: Extract< @@ -4490,7 +4488,12 @@ class ClaudeAgentSession implements AgentSession { ), ).map((event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event })); const routedId = canonicalSubagentId ?? parentToolUseId; - return [...runtimeEvents, ...this.sidechainTracker.handleMessage(message, routedId)]; + const settled = this.settledSubagentToolCalls.get(routedId); + const sidechainEvents = this.sidechainTracker.handleMessage(message, routedId).map((event) => { + if (event.type === "timeline" && settled?.turnId) event.turnId = settled.turnId; + return event; + }); + return [...runtimeEvents, ...sidechainEvents]; } /** @@ -5145,7 +5148,8 @@ class ClaudeAgentSession implements AgentSession { } private notifySubscribers(event: AgentStreamEvent): void { - const turnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id; + const turnId = + getAgentStreamEventTurnId(event) ?? this.activeForegroundTurnId ?? this.autonomousTurn?.id; const tagged = turnId ? { ...event, turnId } : event; this.logger.trace( { @@ -5621,17 +5625,12 @@ class ClaudeAgentSession implements AgentSession { }); } - private settledSubagentToolCallInCurrentTurn(subagentId: string) { - const settled = this.settledSubagentToolCalls.get(subagentId); - const currentTurnId = this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null; - return settled && settled.turnId === currentTurnId ? settled : undefined; - } - /** A finished child streams no more frames, so its settled call has nothing left to update. */ private releaseFinishedSubagentToolCalls(observations: SubagentObservation[]): void { for (const observation of observations) { if (observation.kind === "status" && observation.status !== "running") { this.settledSubagentToolCalls.delete(observation.id); + this.sidechainTracker.delete(observation.id); } } } diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts index f762557879..a17c1cd58c 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -4,6 +4,40 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; describe("ClaudeSidechainTracker", () => { + it("retains a background child's actions across the parent finishing", () => { + const tracker = new ClaudeSidechainTracker({ + getToolInput: () => ({ subagent_type: "general-purpose", description: "List files" }), + isDescriptorOwnedElsewhere: () => true, + }); + const action = (id: string, command: string) => + ({ + type: "assistant", + parent_tool_use_id: "task-1", + message: { content: [{ type: "tool_use", id, name: "Bash", input: { command } }] }, + }) as unknown as SDKMessage; + tracker.handleMessage(action("ls", "ls -1"), "task-1"); + expect(tracker.finishAll("completed")).toEqual([]); + const events = tracker.handleMessage(action("pwd", "pwd"), "task-1"); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + detail: expect.objectContaining({ log: "[Bash] ls -1\n[Bash] pwd" }), + }), + }), + ); + tracker.delete("task-1"); + const next = tracker.handleMessage(action("next", "ls"), "task-1"); + expect(next).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ + detail: expect.objectContaining({ log: "[Bash] ls" }), + }), + }), + ); + }); + it("uses Claude's native agent name for the provider subagent title", () => { const tracker = new ClaudeSidechainTracker({ getToolInput: () => ({ diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index f054523126..083b82cea6 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -199,7 +199,7 @@ export class ClaudeSidechainTracker { finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] { const events: AgentStreamEvent[] = []; if (this.isDescriptorOwnedElsewhere()) { - this.activeSidechains.clear(); + // The task protocol owns child completion; the parent finishing does not end its children. return events; } for (const [id, state] of this.activeSidechains) { diff --git a/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts new file mode 100644 index 0000000000..7eba32f22a --- /dev/null +++ b/packages/server/src/server/daemon-e2e/background-subagent-action-log.real.e2e.test.ts @@ -0,0 +1,105 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { expect, test } from "vitest"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +test("background cards retain child actions after the parent finishes (#6253)", async () => { + const cwd = mkdtempSync(path.join(tmpdir(), "paseo-background-card-")); + const logger = pino({ level: "silent" }); + const daemon = await createTestPaseoDaemon({ + logger, + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ + provider: "claude", + model: "haiku", + modeId: "bypassPermissions", + cwd, + title: "Background action log reproduction", + }); + await client.sendMessage( + agent.id, + 'In one message, call Agent twice with run_in_background true: first with subagent_type "general-purpose", description "List files", prompt "Run ls -1, then run sleep 20, then reply with the number of entries."; second with subagent_type "general-purpose", description "Print cwd", prompt "Run pwd, then run sleep 20, then reply with the path." After launching both, reply "launched" and end your turn immediately without waiting.', + ); + await client.waitForFinish(agent.id, 180_000); + const atFinish = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + await expect + .poll( + async () => { + const { subagents } = await client.listProviderSubagents(agent.id); + return subagents.length === 2 && subagents.every((child) => child.status === "completed"); + }, + { timeout: 180_000, interval: 1000 }, + ) + .toBe(true); + const { subagents } = await client.listProviderSubagents(agent.id); + const children = await Promise.all( + subagents.map(async (child) => ({ + description: child.description, + timeline: await client.fetchProviderSubagentTimeline(agent.id, child.id, { + direction: "tail", + limit: 0, + }), + })), + ); + const final = await client.fetchAgentTimeline(agent.id, { + direction: "tail", + limit: 0, + projection: "projected", + }); + const { stdout } = await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${daemon.port}`, + "logs", + agent.id, + ], + { env: { ...process.env, PASEO_HOME: daemon.paseoHome }, timeout: 30_000 }, + ); + expect(stdout).toContain("[Bash] ls -1"); + expect(stdout).toContain("[Bash] pwd"); + expect( + atFinish.entries.filter( + (row) => row.item.type === "tool_call" && row.item.detail?.type === "sub_agent", + ), + ).toHaveLength(2); + const cards = final.entries + .map((row) => row.item) + .filter((item) => item.type === "tool_call" && item.detail?.type === "sub_agent"); + expect(cards).toHaveLength(2); + for (const child of children) { + expect( + child.timeline.rows.some( + (row) => row.item.type === "tool_call" && row.item.name === "Bash", + ), + ).toBe(true); + } + for (const card of cards) { + expect(card.detail?.type === "sub_agent" ? card.detail.log : "").toContain("[Bash]"); + } + } finally { + await client.close(); + await daemon.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}, 420_000); From efd6d95f91db3b6f028467e22cdf2652436555b4 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:06:08 +0200 Subject: [PATCH 19/23] fix(plugins): return unavailable when steering provider without prompt.steer capability (#7270) Co-authored-by: Simone Primarosa Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/plugin-provider.test.ts | 66 +++++++++++++++++++ .../src/server/agent/plugin-provider.ts | 3 + 2 files changed, 69 insertions(+) diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index ae8ed805d7..aa0aa1a924 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -761,6 +761,72 @@ describe("PluginAgentClientRegistry", () => { await expect.poll(harness.closeCount).toBe(1); expect(harness.inputs.map((input) => input.type)).toContain("session.close"); }); + + test("delivers a supported steer to the active turn", async () => { + const harness = createProviderHarness({ + capabilities: [...CAPABILITIES, "prompt.steer"], + handleInput: async (input, emit) => { + if (input.type !== "session.prompt") return false; + emit({ + type: "session.prompt_result", + sessionId: input.sessionId, + clientMessageId: input.prompt.clientMessageId, + result: { type: "steer", turnId: "turn-1" }, + }); + return true; + }, + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" }); + try { + await expect( + session.steerActiveTurn!("follow up", { + expectedTurnId: "turn-1", + clientMessageId: "steer-1", + clearPendingPermissions: true, + }), + ).resolves.toEqual({ status: "accepted" }); + expect(harness.inputs.find((input) => input.type === "session.prompt")).toMatchObject({ + prompt: { + delivery: "steer", + clientMessageId: "steer-1", + input: { type: "message", content: [{ type: "text", text: "follow up" }] }, + clearPendingPermissions: true, + }, + }); + } finally { + await session.close(); + registry.replace([]); + } + }); + + test("returns unavailable when steering a provider without prompt.steer capability", async () => { + const harness = createProviderHarness({ + capabilities: [ + "prompt.message", + "session.configure", + "session.persistence", + "session.subsession", + "permission", + ], + }); + const registry = new PluginAgentClientRegistry(createTestLogger()); + registry.replace([harness.registration]); + const session = await registry.clients()[harness.registration.id]!.createSession({ + provider: harness.registration.id, + cwd: "/workspace", + }); + try { + const result = await session.steerActiveTurn!("hello", { expectedTurnId: "turn-1" }); + expect(result).toEqual({ status: "unavailable" }); + expect(harness.inputs.filter((input) => input.type === "session.prompt")).toHaveLength(0); + } finally { + await session.close(); + registry.replace([]); + } + }); }); type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt"; diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index e575ff0d45..db5d7651a2 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1175,6 +1175,9 @@ class PluginAgentSession implements AgentSession { prompt: AgentPromptInput, options: SteerActiveTurnOptions, ): Promise { + if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) { + return { status: "unavailable" }; + } const result = await this.bridge.prompt({ clientMessageId: options.clientMessageId ?? randomUUID(), delivery: "steer", From cf62b009116ad1365928a605b1f4887dcbc810e6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 11:06:31 +0700 Subject: [PATCH 20/23] Keep last successful usage visible when refresh fails (#7246) * fix(usage): retain last successful data when polls fail Keep the latest poll outcome separate from its successful snapshot so errors remain visible without discarding known usage or advancing its freshness. Preserve attempt timestamps for response ordering and isolate retained values by scoped login set. * fix(usage): preserve history through discovery failures A failed account-store read does not confirm that an account was removed. Retain its existing host mapping until successful discovery replaces it, without carrying mappings across agent launch environments. --- packages/app/e2e/browser/usage-modal.spec.ts | 66 +++++- .../app/e2e/support/helpers/usage-login.ts | 11 + packages/app/src/usage/balance-bar.tsx | 10 +- packages/app/src/usage/card.tsx | 68 +++--- packages/app/src/usage/format.test.ts | 9 +- packages/app/src/usage/format.ts | 3 +- packages/app/src/usage/model.test.ts | 70 ++++++ packages/app/src/usage/model.ts | 43 +++- packages/app/src/usage/pinned.test.ts | 6 +- packages/app/src/usage/window-bar.tsx | 9 +- packages/cli/src/commands/usage/index.test.ts | 37 ++++ .../cli/src/commands/usage/presentation.ts | 73 +++++-- packages/protocol/src/messages.ts | 20 +- .../protocol/src/messages.wire-compat.test.ts | 38 ++++ .../plugins/usage-sources/index.test.ts | 205 ++++++++++++++++++ .../src/server/plugins/usage-sources/index.ts | 70 +++++- 16 files changed, 645 insertions(+), 93 deletions(-) diff --git a/packages/app/e2e/browser/usage-modal.spec.ts b/packages/app/e2e/browser/usage-modal.spec.ts index a923971d09..f51c0ad510 100644 --- a/packages/app/e2e/browser/usage-modal.spec.ts +++ b/packages/app/e2e/browser/usage-modal.spec.ts @@ -16,6 +16,7 @@ import { } from "../support/helpers/usage-reports"; import { installLoginUsage, + expectLoginUsageFreshness, installCodexWindowUsage, expectCodexReportedWindows, refreshLoginUsage, @@ -588,6 +589,69 @@ async function expectLoginErrors(page: Page, lines: string[]) { async function expectLoginUsageOnly(page: Page, percentage: string) { const card = page.getByTestId("usage-report-login-journey:account"); - await expect(card.getByText(percentage, { exact: true })).toBeVisible(); + await expect(card.getByText(new RegExp(`^${percentage}(?:$| ·)`))).toBeVisible(); await expect(card.getByTestId("usage-login-error")).toHaveCount(0); } + +for (const compact of [false, true]) { + test(`failed polls retain usage and successful freshness (${compact ? "footer" : "tooltip"})`, async ({ + page, + }) => { + const fixture = await installLoginUsage({ status: "error", error: "Initial failure" }); + try { + await page.setViewportSize( + compact ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ); + await gotoAppShell(page); + if (compact) await openCompactSidebar(page); + await openUsageFromIcon(page); + await expectLoginErrors(page, ["Claude: Initial failure"]); + await expectLoginUsageFreshness(page, "No successful update", compact); + + await fixture.setReport({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 42, resetsAt: "2000-01-01T00:00:00Z" }], + }); + // Move only the browser clock; real daemon fetches continue using the host clock. + await page.clock.setFixedTime(new Date(Date.now() + 3_900_000)); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "42%"); + await expectLoginUsageFreshness(page, "Updated 1h ago", compact); + + await fixture.setReport({ status: "error", error: "Rate limited" }); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: Rate limited"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: Rate limited"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + + await fixture.setReport({ + status: "unavailable", + problem: { kind: "no_quota", detail: "No active quota" }, + }); + await refreshLoginUsage(page); + await expectRetainedLoginUsage(page, "Claude: No active quota"); + await expectLoginUsageFreshness(page, "Last updated 1h ago", compact); + + await fixture.setReport({ + status: "available", + windows: [{ id: "weekly", label: "Weekly", usedPct: 43 }], + }); + await page.clock.setFixedTime(new Date(Date.now() + 150_000)); + await refreshLoginUsage(page); + await expectLoginUsageOnly(page, "43%"); + await expect(page.getByText("42%", { exact: true })).toHaveCount(0); + await expectLoginUsageFreshness(page, "Updated 2m ago", compact); + } finally { + await fixture.cleanup(); + } + }); +} + +async function expectRetainedLoginUsage(page: Page, message: string) { + const card = page.getByTestId("usage-report-login-journey:account"); + await expect(card.getByText(/^42%(?:$| ·)/)).toBeVisible(); + await expect(card.getByTestId("usage-login-error")).toHaveText(message); + await expect(card.getByText("Reset time passed", { exact: false })).toBeVisible(); +} diff --git a/packages/app/e2e/support/helpers/usage-login.ts b/packages/app/e2e/support/helpers/usage-login.ts index 6129f0259e..25ee01d5d5 100644 --- a/packages/app/e2e/support/helpers/usage-login.ts +++ b/packages/app/e2e/support/helpers/usage-login.ts @@ -135,3 +135,14 @@ export async function expectCodexReportedWindows(page: Page, shape: "seven-day-o ); } } + +export async function expectLoginUsageFreshness(page: Page, text: string, compact: boolean) { + const card = page.getByTestId("usage-report-login-journey:account"); + if (compact) { + await expect(card.getByTestId("usage-freshness")).toHaveText(text); + return; + } + await card.getByRole("button", { name: "Refresh Claude", exact: true }).hover(); + await expect(page.getByTestId("usage-freshness-tooltip")).toHaveText(text); + await page.mouse.move(0, 0); +} diff --git a/packages/app/src/usage/balance-bar.tsx b/packages/app/src/usage/balance-bar.tsx index 751193eec0..38b2f0804a 100644 --- a/packages/app/src/usage/balance-bar.tsx +++ b/packages/app/src/usage/balance-bar.tsx @@ -41,11 +41,17 @@ function fillToneStyle(tone: UsageTone) { } } -export function UsageBalanceBar({ balance }: { balance: UsageBalance }) { +export function UsageBalanceBar({ + balance, + retained = false, +}: { + balance: UsageBalance; + retained?: boolean; +}) { const { i18n } = useTranslation(); const { amountText, usedPct } = resolveBalance(balance, i18n.language); const tone = balance.tone ?? "default"; - const resetLabel = formatResetLabel(balance.resetsAt); + const resetLabel = formatResetLabel(balance.resetsAt, retained); const fillStyle = useMemo>( () => [styles.fill, fillToneStyle(tone), { width: `${clampPct(usedPct ?? 0)}%` }], diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index a1936e0f97..562f3805a1 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -16,7 +16,7 @@ import { useCompactTimeAgo } from "@/hooks/use-time-ago"; import { UsageBalanceBar } from "./balance-bar"; import { usageCopy } from "./copy"; import type { UsageDisplay } from "./display"; -import { formatUsageFreshness, type UsageRefresh } from "./model"; +import { formatUsageFreshness, resolveUsageReport, type UsageRefresh } from "./model"; import { useReportRefresh } from "./queries"; import { UsageSourceIcon } from "./source-icon"; import type { UsageReport, UsageReportEntry, UsageWindow } from "./types"; @@ -27,31 +27,6 @@ function statusText(report: UsageReport): string | null { return report.status === "error" ? "Error" : "Unavailable"; } -function reportContent(report: UsageReport) { - if (report.status === "available") - return { - windows: report.windows, - balances: report.balances ?? [], - details: report.details ?? [], - message: undefined, - }; - const message = - report.status === "unavailable" ? usageCopy.problem(report.problem) : report.error; - return { windows: [], balances: [], details: [], message }; -} - -function reportMessages(entry: UsageReportEntry): string[] { - if (entry.report.status === "available") return []; - if (entry.loginErrors) - return entry.loginErrors.map( - (login) => `${login.harness}: ${reportContent(login.report).message}`, - ); - // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. - return [ - entry.report.status === "error" ? entry.report.error : usageCopy.problem(entry.report.problem), - ]; -} - const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); @@ -81,8 +56,8 @@ export function UsageCard({ const usage = entry.report; const status = statusText(usage); const footer = entry.account.label ?? null; - const { windows, balances, details } = reportContent(usage); - const messages = reportMessages(entry); + const { windows, balances, details, planLabel, successfulAt, retained, messages } = + resolveUsageReport(entry); const containerStyle = useMemo( () => [styles.container, compact ? styles.containerCompact : styles.containerPadded], @@ -104,9 +79,7 @@ export function UsageCard({ {entry.sourceLabel} - {usage.status === "available" && usage.planLabel ? ( - - ) : null} + {planLabel ? : null} {status ? ( @@ -117,7 +90,8 @@ export function UsageCard({ {refreshable ? ( ))} {balances.map((balance) => ( - + ))} ) : null} @@ -170,7 +145,8 @@ export function UsageCard({
{showsFreshnessInline ? ( @@ -192,7 +168,9 @@ function CardWindowBar({ window, display, pinnable, + retained, }: { + retained: boolean; entry: UsageReportEntry; window: UsageWindow; display: UsageDisplay; @@ -207,6 +185,7 @@ function CardWindowBar({ return ( void; compact: boolean; @@ -236,12 +217,13 @@ function UsageRefreshButton({ const freshness = useMemo( () => ( ), - [fetchedAt], + [successfulAt, retained], ); return ( `, not the card. */ function UsageFreshness({ - fetchedAt, + successfulAt, + retained, style, testID, }: { - fetchedAt: string; + successfulAt: string | null; + retained: boolean; style: StyleProp; testID: string; }) { - const elapsed = useCompactTimeAgo(new Date(fetchedAt)); + const elapsed = useCompactTimeAgo(successfulAt ? new Date(successfulAt) : null); return ( - {formatUsageFreshness(elapsed)} + {successfulAt ? formatUsageFreshness(elapsed, retained) : "No successful update"} ); } diff --git a/packages/app/src/usage/format.test.ts b/packages/app/src/usage/format.test.ts index 88fe227ae6..22529c8d71 100644 --- a/packages/app/src/usage/format.test.ts +++ b/packages/app/src/usage/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { formatAmount } from "./format"; +import { formatAmount, formatResetLabel } from "./format"; describe("formatAmount", () => { it("groups thousands in the app's language", () => { @@ -13,3 +13,10 @@ describe("formatAmount", () => { expect(formatAmount(1234.5, "usd", "fr").replace(/\s/g, " ")).toBe("1 234,50 $US"); }); }); + +it("retained reset times that have passed do not claim the quota is resetting", () => { + const past = "2000-01-01T00:00:00.000Z"; + expect(formatResetLabel(past, true)).toBe("Reset time passed"); + expect(formatResetLabel(past)).toBe("resetting now"); + expect(formatResetLabel(null, true)).toBeNull(); +}); diff --git a/packages/app/src/usage/format.ts b/packages/app/src/usage/format.ts index 5adef2154d..e13a550e7b 100644 --- a/packages/app/src/usage/format.ts +++ b/packages/app/src/usage/format.ts @@ -27,10 +27,11 @@ function relativeDuration(iso: string): string | null { return `${diffMinutes}m`; } -export function formatResetLabel(iso: string | null | undefined): string | null { +export function formatResetLabel(iso: string | null | undefined, retained = false): string | null { if (!iso) return null; const rel = relativeDuration(iso); if (!rel) return null; + if (rel === "now" && retained) return "Reset time passed"; return rel === "now" ? "resetting now" : `resets ${rel}`; } diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index a2c493619d..13da42a6eb 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + resolveUsageReport, displayPercent, formatUsageFreshness, resolveAgentUsageView, @@ -325,3 +326,72 @@ describe("resolveAgentUsageView", () => { ).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" }); }); }); + +it("resolves retained usage and successful time without inventing old-daemon history", () => { + const success = entry({ windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }); + const failure: UsageReportEntry = { + ...success, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { + fetchedAt: success.fetchedAt, + report: success.report as Extract, + }, + }; + expect(resolveUsageReport(failure)).toEqual({ + windows: success.report.status === "available" ? success.report.windows : [], + balances: [], + details: [], + planLabel: null, + successfulAt: success.fetchedAt, + retained: true, + messages: ["Rate limited"], + }); + expect(resolveUsageReport({ ...failure, lastSuccessful: undefined })).toEqual({ + windows: [], + balances: [], + details: [], + planLabel: null, + successfulAt: null, + retained: false, + messages: ["Rate limited"], + }); + expect(resolveUsageReport(success)).toEqual({ + windows: success.report.status === "available" ? success.report.windows : [], + balances: [], + details: [], + planLabel: null, + successfulAt: success.fetchedAt, + retained: false, + messages: [], + }); +}); + +it("orders failed polls by attempt time even when successful data is older", () => { + const success = entry({}); + if (success.report.status !== "available") throw new Error("fixture must be available"); + const failure: UsageReportEntry = { + ...success, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { fetchedAt: success.fetchedAt, report: success.report }, + }; + expect(upsertReport([failure], success)).toEqual([failure]); + expect(settleReports([failure], [success])).toEqual([failure]); + expect(upsertReport([success], failure)).toEqual([failure]); +}); + +it("available and retained usage show the same successful completion time", () => { + const success: UsageReportEntry = { ...entry({}), successfulAt: "2026-01-01T00:00:15.000Z" }; + if (success.report.status !== "available") throw new Error("fixture must be available"); + expect(resolveUsageReport(success).successfulAt).toBe("2026-01-01T00:00:15.000Z"); + const failure: UsageReportEntry = { + ...success, + successfulAt: undefined, + fetchedAt: "2026-01-01T00:06:00.000Z", + report: { status: "error", error: "Rate limited" }, + lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report }, + }; + expect(resolveUsageReport(failure).successfulAt).toBe(resolveUsageReport(success).successfulAt); + expect(upsertReport([failure], success)).toEqual([failure]); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index ed8a39163a..1f1c82cce3 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -1,7 +1,7 @@ import { formatCompactTimeAgoAsProse } from "@/utils/time"; import { usageCopy } from "./copy"; import type { UsageDisplayAs } from "./preferences"; -import type { UsageReportEntry, UsageView, UsageWindow } from "./types"; +import type { UsageReport, UsageReportEntry, UsageView, UsageWindow } from "./types"; export function usedPercent(window: UsageWindow): number | null { if (window.usedPct != null) return window.usedPct; @@ -32,8 +32,8 @@ export function usageWindowRowLabel(input: { } /** When a report was fetched, from its compact relative time: "Updated 3m ago". */ -export function formatUsageFreshness(compactTimeAgo: string): string { - return `${usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; +export function formatUsageFreshness(compactTimeAgo: string, retained = false): string { + return `${retained ? "Last updated" : usageCopy.updated} ${formatCompactTimeAgoAsProse(compactTimeAgo)}`; } /** A user-requested refresh of one report. The previous report stays on screen throughout. */ @@ -192,3 +192,40 @@ export function resolveUsageModalHostId(choice: UsageHostChoice): string | null const picked = connected.find((host) => host.serverId === choice.pickedServerId); return picked?.serverId ?? resolveUsageHostId(choice) ?? connected[0]?.serverId ?? null; } + +/** Current outcome owns messages; only a successful poll owns values and freshness. */ +export function resolveUsageReport(entry: UsageReportEntry) { + const available = entry.report.status === "available"; + // COMPAT(usageLastSuccessful): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Older daemons cannot provide history; a failed attempt is never a successful update. + const snapshot = available ? null : (entry.lastSuccessful ?? null); + const content = entry.report.status === "available" ? entry.report : (snapshot?.report ?? null); + const messages: string[] = []; + if (!available) { + if (entry.loginErrors) + messages.push( + ...entry.loginErrors.map((login) => `${login.harness}: ${usageProblemText(login.report)}`), + ); + // COMPAT(usageLoginErrors): added in v0.11.0, remove after 2027-04-05 once daemon floor >= v0.11.0. + else messages.push(usageProblemText(entry.report)); + } + return { + windows: content?.windows ?? [], + balances: content?.balances ?? [], + details: content?.details ?? [], + planLabel: content?.planLabel ?? null, + // COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Old daemons expose only attempt time for available reports, never for failures. + successfulAt: available + ? (entry.successfulAt ?? entry.fetchedAt) + : (snapshot?.fetchedAt ?? null), + retained: snapshot !== null, + messages, + }; +} + +function usageProblemText(report: UsageReport): string { + if (report.status === "error") return report.error; + if (report.status === "unavailable") return usageCopy.problem(report.problem); + return ""; +} diff --git a/packages/app/src/usage/pinned.test.ts b/packages/app/src/usage/pinned.test.ts index 9965251595..8c2c2e58fd 100644 --- a/packages/app/src/usage/pinned.test.ts +++ b/packages/app/src/usage/pinned.test.ts @@ -257,9 +257,12 @@ describe("resolvePinnedUsage", () => { }); }); -it("keeps unavailable and error reports out of the sidebar summary", () => { +it("keeps unavailable and error reports out of the sidebar summary even with retained data", () => { + if (claude.report.status !== "available" || codex.report.status !== "available") + throw new Error("fixtures must be available"); const expired: UsageReportEntry = { ...claude, + lastSuccessful: { fetchedAt: claude.fetchedAt, report: claude.report }, report: { status: "unavailable", problem: { kind: "expired", expiresAt: "2026-10-01T00:00:00.000Z", refreshedBy: "claude" }, @@ -267,6 +270,7 @@ it("keeps unavailable and error reports out of the sidebar summary", () => { }; const failed: UsageReportEntry = { ...codex, + lastSuccessful: { fetchedAt: codex.fetchedAt, report: codex.report }, report: { status: "error", error: "Store deleted" }, }; expect(resolvePinnedUsage([expired, failed], preferences(null))).toEqual([]); diff --git a/packages/app/src/usage/window-bar.tsx b/packages/app/src/usage/window-bar.tsx index 5260a1c44b..79ea6b9f2d 100644 --- a/packages/app/src/usage/window-bar.tsx +++ b/packages/app/src/usage/window-bar.tsx @@ -26,7 +26,9 @@ export function UsageWindowBar({ onTogglePin, pinLabel, pinTestID, + retained = false, }: { + retained?: boolean; window: UsageWindow; displayAs: UsageDisplayAs; /** Whether the row pins the window to the sidebar. When false the row is a plain bar. */ @@ -40,10 +42,13 @@ export function UsageWindowBar({ const shownPct = displayPercent(window, displayAs); const tone = windowTone(window); - const isAtRisk = window.runsOutAt != null && window.shortfallPct != null; + const isAtRisk = + window.runsOutAt != null && + window.shortfallPct != null && + (!retained || Date.parse(window.runsOutAt) > Date.now()); const trailing = isAtRisk ? `runs out ${formatResetLabel(window.runsOutAt)?.replace("resets ", "") ?? ""}`.trim() - : formatResetLabel(window.resetsAt); + : formatResetLabel(window.resetsAt, retained); const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—"; const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]); diff --git a/packages/cli/src/commands/usage/index.test.ts b/packages/cli/src/commands/usage/index.test.ts index 9c2e3e8bbf..d6d5b62ee1 100644 --- a/packages/cli/src/commands/usage/index.test.ts +++ b/packages/cli/src/commands/usage/index.test.ts @@ -277,3 +277,40 @@ describe("usage CLI registration", () => { expect(usage.commands[1]!.helpInformation()).toContain(""); }); }); + +it("shows retained values with current failures and separates successful time from latest attempt", async () => { + const success: UsageReportEntry = { ...reports[0]!, successfulAt: "2026-10-09T12:00:15Z" }; + const current = await runUsageCommand( + options, + { kind: "inspect", id: success.id }, + host([success]).connect, + ); + expect(render(current)).toContain("Latest attempt: 2026-10-09T12:00:00Z"); + expect(render(current)).toContain("Last successful: 2026-10-09T12:00:15Z"); + const currentList = await runUsageCommand(options, { kind: "list" }, host([success]).connect); + expect(render(currentList)).toContain("Last successful: 2026-10-09T12:00:15Z"); + if (success.report.status !== "available") throw new Error("fixture must be available"); + const retained: UsageReportEntry = { + ...success, + fetchedAt: "2026-10-09T12:06:00Z", + report: { status: "error", error: "Rate limited" }, + successfulAt: undefined, + lastSuccessful: { fetchedAt: success.successfulAt!, report: success.report }, + }; + const daemon = host([retained]); + const listed = await runUsageCommand(options, { kind: "list" }, daemon.connect); + expect(render(listed)).toContain("Retained: Five hours: 12.5% used"); + expect(render(listed)).toContain("Last successful: 2026-10-09T12:00:15Z"); + expect(render(listed)).toContain("Rate limited"); + expect(JSON.parse(render(listed, { format: "json" }))).toEqual([retained]); + const inspected = await runUsageCommand( + options, + { kind: "inspect", id: retained.id }, + daemon.connect, + ); + expect(render(inspected)).toContain("Latest attempt: 2026-10-09T12:06:00Z"); + expect(render(inspected)).toContain("Last successful: 2026-10-09T12:00:15Z"); + expect(render(inspected)).toContain("Retained usage:"); + expect(render(inspected)).toContain("Five hours: 12.5% used"); + expect(render(inspected)).toContain("Reason: Rate limited"); +}); diff --git a/packages/cli/src/commands/usage/presentation.ts b/packages/cli/src/commands/usage/presentation.ts index ecc4aba405..2ecadafc59 100644 --- a/packages/cli/src/commands/usage/presentation.ts +++ b/packages/cli/src/commands/usage/presentation.ts @@ -44,8 +44,18 @@ function summary(entry: UsageReportEntry): string[] { const lines = [failureText(report)]; for (const login of entry.loginErrors ?? []) lines.push(`${login.harness}: ${failureText(login.report)}`); + if (entry.lastSuccessful) { + lines.push(`Last successful: ${entry.lastSuccessful.fetchedAt}`); + lines.push( + ...availableSummary(entry.lastSuccessful.report).map((line) => `Retained: ${line}`), + ); + } else lines.push("No successful update"); return lines; } + return [...availableSummary(report), `Last successful: ${successfulTime(entry)}`]; +} + +function availableSummary(report: Extract): string[] { const lines = report.windows.map(windowText); lines.push(...(report.balances ?? []).map(balanceText)); return lines.length ? lines : ["-"]; @@ -68,7 +78,7 @@ const summarySchema: OutputSchema = { { header: "ACCOUNT", field: "account" }, { header: "STATUS", field: "status" }, { header: "USAGE", field: "usage" }, - { header: "FETCHED", field: "fetched" }, + { header: "FETCHED (LATEST ATTEMPT)", field: "fetched" }, ], }; @@ -79,30 +89,15 @@ function inspect(entry: UsageReportEntry): string { `Source ID: ${entry.sourceId}`, `Account: ${value(entry.account.label)}`, `Status: ${entry.report.status}`, - `Fetched: ${value(entry.fetchedAt)}`, + `Latest attempt: ${value(entry.fetchedAt)}`, ]; - const { report } = entry; - if (report.status === "available") { - lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); - if (!report.windows.length) lines.push(" -"); - for (const window of report.windows) { - lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); - if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); - if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); - } - lines.push("Balances:"); - if (!report.balances?.length) lines.push(" -"); - for (const balance of report.balances ?? []) { - lines.push( - ` ${balanceText(balance)} [${balance.id}]`, - ` Resets: ${value(balance.resetsAt)}`, - ); - } - lines.push("Details:"); - if (!report.details?.length) lines.push(" -"); - for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); - } else { - lines.push(`Reason: ${failureText(report)}`); + const report = entry.report.status === "available" ? entry.report : entry.lastSuccessful?.report; + const successfulAt = successfulTime(entry); + lines.push(successfulAt ? `Last successful: ${successfulAt}` : "No successful update"); + if (entry.report.status !== "available") lines.push(`Reason: ${failureText(entry.report)}`); + if (report) { + if (entry.report.status !== "available") lines.push("Retained usage:"); + lines.push(...availableDetails(report)); } if (entry.loginErrors?.length) { lines.push("Login failures:"); @@ -135,3 +130,33 @@ export const usageSchema: OutputSchema = { return renderTable({ type: "list", data: rows, schema: summarySchema }, options); }, }; + +function availableDetails(report: Extract): string[] { + const lines: string[] = []; + lines.push(`Plan: ${value(report.planLabel)}`, "Windows:"); + if (!report.windows.length) lines.push(" -"); + for (const window of report.windows) { + lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`); + if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`); + if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`); + } + lines.push("Balances:"); + if (!report.balances?.length) lines.push(" -"); + for (const balance of report.balances ?? []) { + lines.push( + ` ${balanceText(balance)} [${balance.id}]`, + ` Resets: ${value(balance.resetsAt)}`, + ); + } + lines.push("Details:"); + if (!report.details?.length) lines.push(" -"); + for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`); + return lines; +} + +function successfulTime(entry: UsageReportEntry): string | undefined { + // COMPAT(usageSuccessfulAt): added in v0.12.0, remove after 2027-04-17 once daemon floor >= v0.12.0. + // Only available reports from older daemons can use their attempt timestamp as freshness. + if (entry.report.status === "available") return entry.successfulAt ?? entry.fetchedAt; + return entry.lastSuccessful?.fetchedAt; +} diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c8fe60ff67..a7a2182d94 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -6306,14 +6306,15 @@ export const UsageProblemSchema = z.discriminatedUnion("kind", [ }), z.object({ kind: z.literal("no_quota"), detail: z.string() }), ]); +const AvailableUsageReportSchema = z.object({ + status: z.literal("available"), + planLabel: z.string().optional(), + windows: z.array(ProviderUsageWindowSchema), + balances: z.array(ProviderUsageBalanceSchema).optional(), + details: z.array(ProviderUsageDetailSchema).optional(), +}); export const UsageReportSchema = z.discriminatedUnion("status", [ - z.object({ - status: z.literal("available"), - planLabel: z.string().optional(), - windows: z.array(ProviderUsageWindowSchema), - balances: z.array(ProviderUsageBalanceSchema).optional(), - details: z.array(ProviderUsageDetailSchema).optional(), - }), + AvailableUsageReportSchema, z.object({ status: z.literal("unavailable"), problem: UsageProblemSchema }), z.object({ status: z.literal("error"), error: z.string() }), ]); @@ -6325,6 +6326,11 @@ export const UsageReportEntrySchema = z.object({ sourceLabel: z.string(), icon: z.string().optional(), report: UsageReportSchema, + // Successful completion time; fetchedAt remains the attempt timestamp for ordering. + successfulAt: z.string().optional(), + lastSuccessful: z + .object({ fetchedAt: z.string(), report: AvailableUsageReportSchema }) + .optional(), loginErrors: z .array( z.object({ diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index 2298d680a4..6903aa2cd1 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { z } from "zod"; import { + UsageReportEntrySchema, AgentSnapshotPayloadSchema, AgentTimelineItemPayloadSchema, ServerInfoStatusPayloadSchema, @@ -436,3 +437,40 @@ test("usage login errors are additive and older reports still parse", () => { expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage); expect(legacy.parse(newMessage)).toEqual(oldMessage); }); + +test("usage entries accept older failures and additive successful snapshots", () => { + const oldEntry = { + id: "source:account", + sourceId: "source", + sourceLabel: "Source", + account: {}, + fetchedAt: "2026-10-17T10:06:00Z", + report: { status: "error", error: "Rate limited" }, + }; + expect(UsageReportEntrySchema.parse(oldEntry)).toEqual(oldEntry); + const retained = { + ...oldEntry, + lastSuccessful: { + fetchedAt: "2026-10-17T10:00:00Z", + report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] }, + }, + }; + expect(UsageReportEntrySchema.parse(retained)).toEqual(retained); + const available = { + ...oldEntry, + report: retained.lastSuccessful.report, + successfulAt: "2026-10-17T10:00:15Z", + }; + expect(UsageReportEntrySchema.parse(available)).toEqual(available); + expect( + UsageReportEntrySchema.omit({ lastSuccessful: true, successfulAt: true }).parse(available), + ).toEqual({ ...oldEntry, report: available.report }); + // An older client's entry shape strips the additive snapshot and still sees the failure. + expect(UsageReportEntrySchema.omit({ lastSuccessful: true }).parse(retained)).toEqual(oldEntry); + expect( + UsageReportEntrySchema.safeParse({ + ...oldEntry, + lastSuccessful: { fetchedAt: oldEntry.fetchedAt, report: oldEntry.report }, + }).success, + ).toBe(false); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.test.ts b/packages/server/src/server/plugins/usage-sources/index.test.ts index eeea242788..f0173691c8 100644 --- a/packages/server/src/server/plugins/usage-sources/index.test.ts +++ b/packages/server/src/server/plugins/usage-sources/index.test.ts @@ -556,3 +556,208 @@ test("a hung login records its own timeout and still tries the next login", asyn { harness: "OpenCode", report: { status: "error", error: "Login rejected" } }, ]); }); + +test("retains the last successful poll across failures, cache expiry, and recovery", async () => { + let now = 0; + let report: unknown = { + status: "available", + windows: [{ id: "week", label: "Weekly", usedPct: 42 }], + }; + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "quota", + discover: async () => [{ key: "account", input: {} }], + fetch: async () => report, + }), + ); + registry.register(source({ id: "other", discover: async () => [{ key: "account", input: {} }] })); + const [success] = await registry.listReports(); + now = 360_000; + // An unrelated write evicts the expired fetch result, but must not discard its snapshot. + await registry.listReports({ reportIds: ["other:account"], forceRefresh: true }); + report = { status: "error", error: "Rate limited" }; + const [failure] = await registry.listReports({ reportIds: ["quota:account"] }); + expect(failure).toMatchObject({ + fetchedAt: new Date(now).toISOString(), + report, + lastSuccessful: { fetchedAt: success!.fetchedAt, report: success!.report }, + }); + now += 360_000; + const [repeated] = await registry.listReports({ reportIds: ["quota:account"] }); + expect(repeated?.lastSuccessful).toEqual(failure?.lastSuccessful); + report = { status: "unavailable", problem: { kind: "no_quota", detail: "No quota" } }; + const [unavailable] = await registry.listReports({ + reportIds: ["quota:account"], + forceRefresh: true, + }); + expect(unavailable?.lastSuccessful).toEqual(failure?.lastSuccessful); + now += 1000; + report = { status: "available", windows: [{ id: "week", label: "Weekly", usedPct: 43 }] }; + const [recovered] = await registry.listReports({ + reportIds: ["quota:account"], + forceRefresh: true, + }); + expect(recovered?.report).toEqual(report); + expect(recovered?.lastSuccessful).toBeUndefined(); + expect(recovered?.fetchedAt).toBe(new Date(now).toISOString()); +}); + +test("successful history is isolated by login set and pruned when identities retire", async () => { + let login = "old"; + let present = true; + let available = true; + const registry = new UsageSourceRegistry(); + const fixture = source({ + id: "source", + discover: async () => (present ? [{ key: "same", input: login }] : []), + fetch: async () => + available ? { status: "available", windows: [] } : { status: "error", error: "failed" }, + }); + registry.register(fixture); + await registry.listReports(); + available = false; + login = "new"; + expect((await registry.listReports({ forceRefresh: true }))[0]?.lastSuccessful).toBeUndefined(); + login = "old"; + expect((await registry.listReports({ forceRefresh: true }))[0]?.lastSuccessful).toBeUndefined(); + available = true; + await registry.listReports({ forceRefresh: true }); + present = false; + expect(await registry.listReports()).toEqual([]); + present = true; + available = false; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); + available = true; + await registry.listReports({ forceRefresh: true }); + registry.unregister("source"); + registry.register(fixture); + available = false; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); +}); + +test("host fallback history never supplies an agent's failed login, and removed agents lose history", async () => { + const sessions = new Map([["agent", { provider: "codex", env: {}, sessionKey: "one" }]]); + let available = true; + const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, { + hasAgent: (id) => sessions.has(id), + usageSession: (id) => sessions.get(id) ?? null, + }); + registry.register({ + id: "source", + label: "Source", + discover: async (scope) => + (scope.kind === "global" ? ["host", "agent"] : ["agent"]).map((input) => ({ + key: "same", + input, + })), + fetch: async () => + available ? { status: "available", windows: [] } : { status: "error", error: "failed" }, + }); + await registry.listReports(); + available = false; + expect((await registry.listReports({ agentId: "agent" }))[0]?.lastSuccessful).toBeUndefined(); + available = true; + const [success] = await registry.listReports({ agentId: "agent", forceRefresh: true }); + available = false; + expect( + (await registry.listReports({ agentId: "agent", forceRefresh: true }))[0]?.lastSuccessful + ?.report, + ).toEqual(success?.report); + sessions.delete("agent"); + await registry.listReports(); + sessions.set("agent", { provider: "codex", env: {}, sessionKey: "two" }); + expect((await registry.listReports({ agentId: "agent" }))[0]?.lastSuccessful).toBeUndefined(); +}); + +test("a timed out poll retains successful values and late completion cannot replace the failure", async () => { + let now = 1000; + let finish!: (value: unknown) => void; + let slow = false; + const registry = new UsageSourceRegistry(() => now, 300_000, undefined, undefined, 10); + registry.register( + source({ + id: "source", + discover: async () => [{ key: "same", input: {} }], + fetch: async () => + slow + ? new Promise((resolve) => { + finish = resolve; + }) + : { status: "available", windows: [] }, + }), + ); + const [success] = await registry.listReports(); + now = 2000; + slow = true; + const [failure] = await registry.listReports({ forceRefresh: true }); + expect(failure?.report).toEqual({ status: "error", error: "Usage fetch timed out" }); + expect(failure?.lastSuccessful).toEqual({ + fetchedAt: success?.fetchedAt, + report: success?.report, + }); + finish({ status: "available", windows: [{ id: "late", label: "Late" }] }); + expect((await registry.listReports())[0]).toEqual(failure); +}); + +test("attempt ordering keeps its timestamp while retained freshness records successful completion", async () => { + let now = 1000; + let available = true; + const registry = new UsageSourceRegistry(() => now); + registry.register( + source({ + id: "source", + discover: async () => [{ key: "same", input: {} }], + fetch: async () => { + if (available) now = 2000; + return available + ? { status: "available", windows: [] } + : { status: "error", error: "failed" }; + }, + }), + ); + const [success] = await registry.listReports(); + expect(success?.fetchedAt).toBe(new Date(1000).toISOString()); + expect(success?.successfulAt).toBe(new Date(2000).toISOString()); + available = false; + now = 3000; + const [failure] = await registry.listReports({ forceRefresh: true }); + expect(failure?.fetchedAt).toBe(new Date(3000).toISOString()); + expect(failure?.lastSuccessful?.fetchedAt).toBe(success?.successfulAt); +}); + +test("failed discovery preserves history until discovery confirms account removal", async () => { + let discoveryFails = false; + let present = true; + let available = true; + const registry = new UsageSourceRegistry(() => 1000, 300_000, { warn: () => {} }); + registry.register( + source({ + id: "source", + discover: async () => { + if (discoveryFails) throw new Error("Login store temporarily unavailable"); + return present ? [{ key: "account", input: {} }] : []; + }, + fetch: async () => + available + ? { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] } + : { status: "error", error: "Rate limited" }, + }), + ); + const [success] = await registry.listReports(); + available = false; + discoveryFails = true; + const [duringDiscoveryFailure] = await registry.listReports({ forceRefresh: true }); + expect(duringDiscoveryFailure?.lastSuccessful).toEqual({ + fetchedAt: success?.successfulAt, + report: success?.report, + }); + discoveryFails = false; + const [afterDiscoveryRecovery] = await registry.listReports({ forceRefresh: true }); + expect(afterDiscoveryRecovery?.lastSuccessful).toEqual(duringDiscoveryFailure?.lastSuccessful); + expect(afterDiscoveryRecovery?.report).toEqual({ status: "error", error: "Rate limited" }); + present = false; + expect(await registry.listReports()).toEqual([]); + present = true; + expect((await registry.listReports())[0]?.lastSuccessful).toBeUndefined(); +}); diff --git a/packages/server/src/server/plugins/usage-sources/index.ts b/packages/server/src/server/plugins/usage-sources/index.ts index c0c9d781e5..e9e2e9c8af 100644 --- a/packages/server/src/server/plugins/usage-sources/index.ts +++ b/packages/server/src/server/plugins/usage-sources/index.ts @@ -56,6 +56,7 @@ export class UsageSourceRegistry { private defaults = new Map(); private readonly byAgent = new Map(); private readonly cache = new Map(); + private readonly successful = new Map>(); private readonly pending = new Map>(); constructor( @@ -81,6 +82,8 @@ export class UsageSourceRegistry { } for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key); for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key); + for (const key of this.successful.keys()) + if (key.startsWith(`${id}:`)) this.successful.delete(key); for (const key of this.pending.keys()) if (key.startsWith(`${id}:`)) this.pending.delete(key); } @@ -96,7 +99,7 @@ export class UsageSourceRegistry { } else if (options.reportIds !== undefined) { ids = options.reportIds; } else { - this.defaults = await this.discover({ kind: "global" }); + this.defaults = await this.discover({ kind: "global" }, this.defaults); this.mergeKnown(); ids = [...this.known.keys()]; } @@ -124,7 +127,11 @@ export class UsageSourceRegistry { private async discoverAgent(agentId: string): Promise { if (!this.agents.hasAgent(agentId)) throw new Error(`Unknown agent: ${agentId}`); const session = this.agents.usageSession(agentId); - if (!session) return []; + if (!session) { + this.byAgent.delete(agentId); + this.mergeKnown(); + return []; + } const previous = this.byAgent.get(agentId); const sameScope = previous?.sessionKey === session.sessionKey && @@ -168,9 +175,26 @@ export class UsageSourceRegistry { } } } + // Fetch freshness expires independently of successful snapshots. Retain only live identities. + const active = this.activeCacheKeys(); + for (const key of this.successful.keys()) if (!active.has(key)) this.successful.delete(key); + for (const key of this.cache.keys()) if (!active.has(key)) this.cache.delete(key); } - private async discover(scope: UsageScope): Promise> { + private activeCacheKeys(): Set { + return new Set( + [ + this.known, + this.defaults, + ...[...this.byAgent.values()].map((agent) => agent.reports), + ].flatMap((reports) => [...reports].map(([id, report]) => cacheIdentity(id, report))), + ); + } + + private async discover( + scope: UsageScope, + previous: ReadonlyMap = new Map(), + ): Promise> { const discovered = await Promise.all( [...this.sources.values()].map(async (source) => { const reports = new Map(); @@ -197,6 +221,11 @@ export class UsageSourceRegistry { } } catch (error) { this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed"); + // A failed store read does not confirm removal. Session discovery has no previous + // mapping because a resumed agent may use a different launch environment. + for (const [id, report] of previous) { + if (report.source === source) reports.set(id, report); + } } return reports; }), @@ -235,25 +264,42 @@ export class UsageSourceRegistry { private fetchId(id: string, known: KnownReport, forceRefresh = false): Promise { // Account identity groups cards; the ordered login set identifies a fetch result. - const cacheKey = `${id}:${createHash("sha256") - .update(JSON.stringify([known.label, known.logins.map(loginKey)])) - .digest("hex")}`; + const cacheKey = cacheIdentity(id, known); const cached = this.cache.get(cacheKey); if (!forceRefresh && cached && this.now() - cached.at < this.ttlMs) return Promise.resolve(cached.entry); const pending = this.pending.get(cacheKey); if (pending) return pending; const request = (async () => { + const fetchedAt = new Date(this.now()).toISOString(); + const outcome = await this.fetchWithFallback(known); + const completedAt = new Date(this.now()).toISOString(); const entry: UsageReportEntry = { id, sourceId: known.source.id, sourceLabel: known.source.label, icon: known.source.icon, account: { label: known.label }, - fetchedAt: new Date(this.now()).toISOString(), - ...(await this.fetchWithFallback(known)), + fetchedAt, + ...outcome, }; - if (this.sources.get(known.source.id) === known.source) this.writeCache(cacheKey, entry); + if (entry.report.status === "available") { + entry.successfulAt = completedAt; + } else { + entry.lastSuccessful = this.successful.get(cacheKey); + } + if ( + this.sources.get(known.source.id) === known.source && + this.activeCacheKeys().has(cacheKey) + ) { + if (entry.report.status === "available") { + this.successful.set(cacheKey, { + fetchedAt: completedAt, + report: entry.report, + }); + } + this.writeCache(cacheKey, entry); + } return entry; })(); this.pending.set(cacheKey, request); @@ -346,3 +392,9 @@ function loginKey(login: Login): string { : value, ); } + +function cacheIdentity(id: string, known: KnownReport): string { + return `${id}:${createHash("sha256") + .update(JSON.stringify([known.label, known.logins.map(loginKey)])) + .digest("hex")}`; +} From df7f1f0b75a18fb78541cb63f6fe3e9e5ab01fa1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 12:48:10 +0700 Subject: [PATCH 21/23] Restore Markdown link hover underline and spacing after code blocks on web (#7276) * fix(markdown): restore link hover underline and code block spacing on web The web renderer swap dropped both. Links: the library styles anchors inline with text-decoration: none, so the link renderer has to track hover itself; a stylesheet :hover rule cannot win. Spacing: the patched renderers.codeBlock override returned the custom block bare, so it lost the library's codeBlock margins and whatever followed sat flush against it. The override now keeps those margins, including the zeroed trailing margin of the last block, so web and native share one spacing config. * test(desktop): expect Find to return focus to the chat viewport #7263 moved focus on closing Find from the no-drag focus scope to the scroll viewport so Page Up and Page Down keep working. The viewport emits no window region by design (#5233), so the old assertion that the focused element is no-drag failed on every run. Assert the new focus target and re-check that the titlebar still has no drag exclusions. --- docs/markdown-renderer.md | 2 +- .../e2e/browser/plan-card-markdown.spec.ts | 68 +++++++++++++++++++ .../src/components/markdown/renderers.web.tsx | 23 +++++-- .../desktop/e2e/workspace-restart.spec.ts | 21 +++--- ...own+1.2.0-nightly-20261014-766fc0e67.patch | 16 +++-- 5 files changed, 111 insertions(+), 19 deletions(-) diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index a4d6a93b5e..0afbcc1cb3 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -18,7 +18,7 @@ Math and video are disabled. Task lists and admonitions are enabled; task checkb Mermaid fences use the existing isolated diagram runtime and fullscreen viewer. Web dispatches them through the library’s code-block override. Native has no block-renderer hook, so the renderer extracts only Mermaid fences and places the existing WebView host between library-rendered segments. This is the only block exception; see [native selection limitations](native-text-selection.md) for diagram grouping and leading inline images. Streaming keeps the last valid diagram visible while the next revision renders. -Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it. +Native code blocks use the library's copy control and compiled grammars. Web code blocks use `HighlightedCodeBlock` through the library's `renderers.codeBlock` override. The patch also keeps the library's block spacing around the web code block override, preserves web list numbering and link titles, marks pending parses with `aria-busy` so Find waits for rendered text, and carries link syntax through native and web tap events. Web inline code inside a link inherits only the link colour; its code font, size and background stay intact. Keep link variants colour-only: native pills replace text runs and prevent paths from wrapping; Android pills also use the surrounding block’s font size. The package patch is tracked by [upstream PR #997](https://github.com/software-mansion/enriched-markdown/pull/997); remove the patch when the pinned release includes it. The package patch also keeps overflowing native horizontal blocks in control of their drags: Android claims parent interception at touch-down and releases it for vertical drags so the chat list can scroll; iOS keeps its scroll recognizer active for outward drags at the leading edge. This prevents compact sidebar pans from taking block scrolling without changing sidebar swipes on prose or non-overflowing blocks. Native changes require a client rebuild. diff --git a/packages/app/e2e/browser/plan-card-markdown.spec.ts b/packages/app/e2e/browser/plan-card-markdown.spec.ts index 13428ce445..90bdb8b6db 100644 --- a/packages/app/e2e/browser/plan-card-markdown.spec.ts +++ b/packages/app/e2e/browser/plan-card-markdown.spec.ts @@ -259,3 +259,71 @@ async function openInlineRegressionFile(page: Page, path: string) { .getByRole("link", { name: path, exact: true }) .click(); } + +// Web links are styled inline by the library, so hover state has to restyle the anchor. +test("markdown links underline on hover", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "markdown-link-hover-", + title: "Markdown link hover", + featureValues: { + mockAssistantResponse: "See [the docs](https://example.com/docs) and [README](README.md).", + }, + initialPrompt: "Show links", + }); + try { + await session.client.waitForFinish(session.agentId, 30_000); + await openAgentRoute(page, session); + const message = page.getByTestId("assistant-message"); + for (const name of ["the docs", "README"]) { + const link = message.getByRole("link", { name, exact: true }); + await expect(link).toHaveCSS("text-decoration-line", "none"); + await link.hover(); + await expect(link).toHaveCSS("text-decoration-line", "underline"); + await page.mouse.move(0, 0); + await expect(link).toHaveCSS("text-decoration-line", "none"); + } + } finally { + await session.cleanup(); + } +}); + +// The web code block replaces the library's own block, which must keep its spacing. +test("blocks around a web code block keep their spacing", async ({ page }) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "markdown-block-spacing-", + title: "Markdown block spacing", + featureValues: { + mockAssistantResponse: + "Intro.\n\n```sh\nnpx playwright test\n```\n\n| Step | Result |\n| --- | --- |\n| hover | none |\n\nMiddle.\n\n```ts\nconst a = 1;\n```\n\nClosing.", + }, + initialPrompt: "Show blocks", + }); + try { + await session.client.waitForFinish(session.agentId, 30_000); + await openAgentRoute(page, session); + const message = page.getByTestId("assistant-message"); + await expect(message.getByRole("table")).toBeVisible(); + const gaps = await measureBlockGaps(message.locator(".enrm-text").first()); + for (const { between, gap } of gaps) expect(gap, between).toBeGreaterThanOrEqual(8); + } finally { + await session.cleanup(); + } +}); + +async function measureBlockGaps(root: Locator) { + return root.evaluate((element) => { + const blocks = Array.from(element.children).map((block) => { + // Wrappers with display: contents have no box; measure their first rendered child. + let box: Element = block; + while (box.getBoundingClientRect().height === 0 && box.firstElementChild) { + box = box.firstElementChild; + } + const rect = box.getBoundingClientRect(); + return { text: (block.textContent ?? "").slice(0, 20), top: rect.top, bottom: rect.bottom }; + }); + return blocks.slice(1).map((block, index) => ({ + between: `${blocks[index].text} -> ${block.text}`, + gap: Math.round(block.top - blocks[index].bottom), + })); + }); +} diff --git a/packages/app/src/components/markdown/renderers.web.tsx b/packages/app/src/components/markdown/renderers.web.tsx index 6f0afc666b..1d05b3b603 100644 --- a/packages/app/src/components/markdown/renderers.web.tsx +++ b/packages/app/src/components/markdown/renderers.web.tsx @@ -1,5 +1,5 @@ import { Text } from "react-native"; -import { forwardRef, useMemo, type ComponentProps } from "react"; +import { forwardRef, useCallback, useMemo, useState, type ComponentProps } from "react"; import type { CodeBlockRendererProps, LinkRendererProps, @@ -27,14 +27,29 @@ function WebCodeBlock({ code, language }: CodeBlockRendererProps) { return ; } +// The library styles links inline, so a stylesheet :hover rule cannot underline them. +const LINK_HOVER_STYLE = { textDecoration: "underline" } as const; + function WebLink({ link, anchorProps, children }: LinkRendererProps) { const { tooltipPath, onHoverIn } = useFileLink(markdownLinkSource(link.url, link.sourceType)); + const [hovered, setHovered] = useState(false); + const handleMouseEnter = useCallback(() => setHovered(true), []); + const handleMouseLeave = useCallback(() => setHovered(false), []); const anchorStyle = useMemo( - () => ({ ...anchorProps.style, overflowWrap: "anywhere" as const }), - [anchorProps.style], + () => ({ + ...anchorProps.style, + overflowWrap: "anywhere" as const, + ...(hovered ? LINK_HOVER_STYLE : null), + }), + [anchorProps.style, hovered], ); const anchor = ( - + {children} ); diff --git a/packages/desktop/e2e/workspace-restart.spec.ts b/packages/desktop/e2e/workspace-restart.spec.ts index 67bc8408ea..9f01b351f6 100644 --- a/packages/desktop/e2e/workspace-restart.spec.ts +++ b/packages/desktop/e2e/workspace-restart.spec.ts @@ -232,17 +232,18 @@ test("scrolled chat does not exclude the workspace titlebar from dragging", asyn } await page.getByRole("button", { name: "Close Find", exact: true }).click(); await expect(find).toBeHidden(); + // Closing Find returns keyboard scrolling to the chat viewport, which emits no window region. await expect - .poll(() => - chat.evaluate((scroll) => ({ - focusReturned: document.activeElement?.contains(scroll), - tabindex: document.activeElement?.getAttribute("tabindex"), - appRegion: getComputedStyle(document.activeElement!).getPropertyValue( - "-webkit-app-region", - ), - })), - ) - .toEqual({ focusReturned: true, tabindex: "-1", appRegion: "no-drag" }); + .poll(() => chat.evaluate((scroll) => document.activeElement === scroll)) + .toBe(true); + await expect + .poll(() => chat.evaluate(inspectChatDragExclusions)) + .toEqual({ + contentCrossesHeader: true, + contentCrossesTabRow: true, + focusScopeRegion: "no-drag", + exclusions: 0, + }); await page.screenshot({ path: testInfo.outputPath("scrolled-chat-titlebar.png") }); } finally { await agent.cleanup(); diff --git a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch index b41700d65b..535f61b6ef 100644 --- a/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch +++ b/patches/react-native-enriched-markdown+1.2.0-nightly-20261014-766fc0e67.patch @@ -1304,7 +1304,7 @@ diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/renderer index dd70713..ca0385a 100644 --- a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js +++ b/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js -@@ -101,12 +101,20 @@ function AdmonitionRenderer({ +@@ -101,12 +101,28 @@ function AdmonitionRenderer({ }); } function CodeBlockRenderer({ @@ -1317,9 +1317,17 @@ index dd70713..ca0385a 100644 const language = node.attributes?.language; + const CustomCodeBlock = capabilities.renderers?.codeBlock; + if (CustomCodeBlock) { -+ return /*#__PURE__*/_jsx(CustomCodeBlock, { -+ code: extractNodeText(node), -+ language: language ?? '' ++ // The custom renderer owns the block's look; the library keeps its spacing, ++ // including the zeroed trailing margin of the last block. ++ return /*#__PURE__*/_jsx("div", { ++ style: { ++ marginTop: styles.codeBlock.marginTop, ++ marginBottom: styles.codeBlock.marginBottom ++ }, ++ children: /*#__PURE__*/_jsx(CustomCodeBlock, { ++ code: extractNodeText(node), ++ language: language ?? '' ++ }) + }); + } const label = language ? `Code block: ${language}` : 'Code block'; From bd5061e7bd99b863dd5275de5210c41472788790 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 13:05:33 +0700 Subject: [PATCH 22/23] fix(app): stop blank rows while scrolling tall Android chats (#7278) Two causes, both reproduced on a clone of a real long thinking-partner chat (replies and thinking rows several screens tall, GFM tables). 1. The window lowered in e42771970 (initial 2 / batch 2 / window 5) holds only two or three tall rows, so a fling outruns the mounted range and the viewport crosses unmounted rows: 65 blank frames over a 4,152-frame traversal. Keep the small initial render that bounds cold-open measurement, but refill a window of 10 in batches of 10: 0 blank frames over 4,347, p95 25 ms. 2. Scroll-to-bottom from deep history animated across every row in between, which no finite window can mount in time: 9 blank frames (~150 ms) per jump. Animate only when the current offset is within the bottom-side overscan, otherwise jump instantly. Near-bottom jumps still animate. Web already does not animate this jump. Cold open on the same chat is unchanged (first content 569 -> 568 ms); the deeper window costs first-scroll acknowledgement (2.58 -> 2.94 s in a debug build). --- .../native-scroll-animation.test.ts | 34 +++++++++++++++++++ .../agent-stream/native-scroll-animation.ts | 10 ++++++ .../app/src/agent-stream/strategy-native.tsx | 18 +++++++--- 3 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 packages/app/src/agent-stream/native-scroll-animation.test.ts create mode 100644 packages/app/src/agent-stream/native-scroll-animation.ts diff --git a/packages/app/src/agent-stream/native-scroll-animation.test.ts b/packages/app/src/agent-stream/native-scroll-animation.test.ts new file mode 100644 index 0000000000..947b8c3d1d --- /dev/null +++ b/packages/app/src/agent-stream/native-scroll-animation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation"; + +describe("native scroll-to-bottom animation", () => { + it("jumps across unmounted history instead of animating through blank rows", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 60_000, viewportHeight: 600, windowSize: 10 }), + ).toBe(false); + }); + + it("keeps near-bottom movement eligible for animation", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 300, viewportHeight: 600, windowSize: 10 }), + ).toBe(true); + }); + + it("bounds travel by the bottom-side overscan, not the whole window", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 2700, viewportHeight: 600, windowSize: 10 }), + ).toBe(true); + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 2701, viewportHeight: 600, windowSize: 10 }), + ).toBe(false); + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 1500, viewportHeight: 600, windowSize: 5 }), + ).toBe(false); + }); + + it("does not animate before the viewport has been measured", () => { + expect( + shouldAnimateNativeScrollToBottom({ offsetY: 0, viewportHeight: 0, windowSize: 10 }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/agent-stream/native-scroll-animation.ts b/packages/app/src/agent-stream/native-scroll-animation.ts new file mode 100644 index 0000000000..72bc39108c --- /dev/null +++ b/packages/app/src/agent-stream/native-scroll-animation.ts @@ -0,0 +1,10 @@ +export function shouldAnimateNativeScrollToBottom(input: { + offsetY: number; + viewportHeight: number; + windowSize: number; +}): boolean { + // VirtualizedList splits the extra window around the viewport. An animated + // jump farther than the bottom-side overscan crosses unmounted history. + const bottomOverscanHeight = ((input.windowSize - 1) / 2) * input.viewportHeight; + return input.viewportHeight > 0 && input.offsetY <= bottomOverscanHeight; +} diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 66409f23e1..e7568edfe8 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -23,6 +23,7 @@ import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { useStableEvent } from "@/hooks/use-stable-event"; import { useKeyboardStreamInset } from "@/keyboard/shift"; +import { shouldAnimateNativeScrollToBottom } from "./native-scroll-animation"; import { useRevisedHistoryRows } from "./history-row-revision"; import { useBottomAnchorController } from "./bottom-anchor-controller"; import { useScrollKeyboardDismiss } from "./scroll-keyboard-dismiss/use-scroll-keyboard-dismiss"; @@ -64,6 +65,7 @@ const historyStartSlotStyle: ViewStyle = { flexShrink: 0, }; const HISTORY_START_SETTLE_FRAMES = 2; +const NATIVE_STREAM_WINDOW_SIZE = 10; function keyExtractor(item: { id: string }): string { return item.id; @@ -238,7 +240,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat programmaticScrollEventBudgetRef.current = 3; flatListRef.current?.scrollToOffset({ offset: 0, - animated, + animated: + animated && + shouldAnimateNativeScrollToBottom({ + offsetY: scrollOffsetYRef.current, + viewportHeight: streamViewportMetricsRef.current.viewportHeight, + windowSize: NATIVE_STREAM_WINDOW_SIZE, + }), }); scrollOffsetYRef.current = 0; streamViewportMetricsRef.current = { @@ -591,11 +599,11 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat scrollEventThrottle={16} onContentSizeChange={handleContentSizeChange} maintainVisibleContentPosition={maintainVisibleContentPosition} - // A history row can contain an entire long reply. Keep synchronous native - // Markdown measurement bounded while FlatList fills the surrounding window. + // Tall Markdown rows exhaust a shallow window during flings. Keep the initial + // render small, then refill a deeper window in batches large enough to keep up. initialNumToRender={2} - maxToRenderPerBatch={2} - windowSize={5} + maxToRenderPerBatch={10} + windowSize={NATIVE_STREAM_WINDOW_SIZE} removeClippedSubviews={false} scrollEnabled={scrollEnabled} showsVerticalScrollIndicator From 9521ee99552d1ecd6c9f103566874778cdf37ec5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 18 Oct 2026 13:10:09 +0700 Subject: [PATCH 23/23] feat(app): label Paseo tool calls verb-first with their context (#7283) Paseo tool rows put the variable part first ("QA messenger finished"), so truncation hid the fixed part, and MCP calls showed only the humanized tool name with no context. Every Paseo tool now has one catalog entry in protocol/paseo-tool-presentation owning its fixed label, the input field shown as context, and its detail layout. Agent-message events read noun-first ("Agent finished" + sender) and use the Paseo icon; the app ignored it before because the wire detail's legacy "bot" icon won. Summaries are capped in code at 120 characters because prompts can be one enormous line; the renderer is not trusted to clip them. The activity curator behind `paseo logs` and get_agent_activity printed a bare "[Agent message]" because the server tool-call type never declared the agentMessage field the projection sets; it now prints the event, sender, and body. --- docs/timeline-sync.md | 3 +- ...paseo-tool-call-details.codex.real.spec.ts | 2 +- packages/app/src/agent-stream/view.tsx | 3 +- packages/app/src/components/message.tsx | 9 +- .../app/src/components/tool-call-details.tsx | 2 +- .../app/src/tool-calls/presentation.test.ts | 77 ++-- packages/app/src/tool-calls/presentation.ts | 7 +- .../src/tool-calls/use-target-agent-title.ts | 19 + .../app/src/utils/tool-call-icon-name.test.ts | 10 + packages/app/src/utils/tool-call-icon-name.ts | 11 +- packages/app/src/utils/tool-call-icon.ts | 4 +- .../protocol/src/agent-message-display.ts | 25 -- .../paseo-tool-presentation/agent-message.ts | 31 ++ .../src/paseo-tool-presentation/catalog.ts | 349 ++++++++++++++++++ .../detail-sections.test.ts} | 2 +- .../detail-sections.ts} | 160 +------- .../src/paseo-tool-presentation/index.test.ts | 141 +++++++ .../src/paseo-tool-presentation/index.ts | 85 +++++ .../paseo-tool-presentation/summary-text.ts | 21 ++ packages/protocol/src/tool-call-display.ts | 31 +- .../src/server/agent/activity-curator.test.ts | 23 ++ .../src/server/agent/activity-curator.ts | 17 + .../src/server/agent/agent-sdk-types.ts | 3 + .../src/server/agent/mcp-server.test.ts | 35 ++ 24 files changed, 840 insertions(+), 230 deletions(-) create mode 100644 packages/app/src/tool-calls/use-target-agent-title.ts delete mode 100644 packages/protocol/src/agent-message-display.ts create mode 100644 packages/protocol/src/paseo-tool-presentation/agent-message.ts create mode 100644 packages/protocol/src/paseo-tool-presentation/catalog.ts rename packages/protocol/src/{paseo-tool-call-detail.test.ts => paseo-tool-presentation/detail-sections.test.ts} (98%) rename packages/protocol/src/{paseo-tool-call-detail.ts => paseo-tool-presentation/detail-sections.ts} (54%) create mode 100644 packages/protocol/src/paseo-tool-presentation/index.test.ts create mode 100644 packages/protocol/src/paseo-tool-presentation/index.ts create mode 100644 packages/protocol/src/paseo-tool-presentation/summary-text.ts diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index d1a770f219..118ad628ce 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -251,7 +251,8 @@ do not imply local ownership or authorization. Capture the sender title at deliv removed senders must remain understandable without a live directory lookup. The optional `agentMessage` payload carries event, sender, and delivered text. Shared client -presentation (`protocol/agent-message-display`) owns its heading and expanded details. The daemon +presentation (`protocol/paseo-tool-presentation`) owns its heading and expanded details, next to the +labels for every Paseo MCP tool. The daemon must not construct UI labels or prepend sender labels to the semantic message body. The existing `tool_call` / `plain_text` envelope is a compatibility adapter: older clients still parse the row and expand its body. New clients use the typed payload, never infer the event from a tool name or parse diff --git a/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts b/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts index 87afd783a4..597a1ebd54 100644 --- a/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts +++ b/packages/app/e2e/browser/paseo-tool-call-details.codex.real.spec.ts @@ -84,7 +84,7 @@ test.describe("real Codex Paseo tool-call presentation", () => { expectedText: ["Prompt", GREETING, "Greeting child", "codex/gpt-5.4-mini", "Result"], }); await captureToolCall(page, testInfo, { - label: "Send agent prompt", + label: "Send message", artifact: "send-prompt-presentation", expectedText: ["Prompt", GREETING, "Agent", "Result"], }); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index c9b44ed679..c1466b3682 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -771,6 +771,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], + [context.cwd, resolvedServerId, setInlineDetailsExpanded, handleToolCallOpenFile], ); // Read through a stable event so live group updates do not change the renderer identity diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 314f414d48..d5a2244505 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -26,6 +26,7 @@ import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { resolveToolCallIcon } from "@/utils/tool-call-icon"; +import { useTargetAgentTitle } from "@/tool-calls/use-target-agent-title"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentCapabilityFlags, ToolCallDetail } from "@getpaseo/protocol/agent-types"; @@ -2208,6 +2209,7 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa interface ToolCallProps { agentMessage?: AgentMessage; + serverId?: string; toolName: string; args?: unknown; result?: unknown; @@ -2228,6 +2230,7 @@ interface ToolCallProps { export const ToolCall = memo(function ToolCall({ agentMessage, + serverId, toolName, args, result, @@ -2265,6 +2268,7 @@ export const ToolCall = memo(function ToolCall({ return undefined; }, [detail, args, result]); + const targetAgentTitle = useTargetAgentTitle({ serverId, toolName, detail: effectiveDetail }); const presentation = useMemo( () => buildToolCallPresentation({ @@ -2275,9 +2279,10 @@ export const ToolCall = memo(function ToolCall({ detail: effectiveDetail, metadata, cwd, + targetAgentTitle, resolveIcon: resolveToolCallIcon, }), - [toolName, status, error, effectiveDetail, metadata, cwd, agentMessage], + [toolName, status, error, effectiveDetail, metadata, cwd, agentMessage, targetAgentTitle], ); const handleOpenFile = useMemo(() => { const openFilePath = presentation.openFilePath; @@ -2393,6 +2398,8 @@ export const ToolCall = memo(function ToolCall({ function areToolCallPropsEqual(previous: ToolCallProps, next: ToolCallProps) { if (previous.toolName !== next.toolName) return false; + if (previous.serverId !== next.serverId) return false; + if (previous.agentMessage !== next.agentMessage) return false; if (previous.args !== next.args) return false; if (previous.result !== next.result) return false; if (previous.error !== next.error) return false; diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 50e4052118..d67eb15f26 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -14,7 +14,7 @@ import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildPaseoToolDetailSections, type PaseoToolDetailSection, -} from "@getpaseo/protocol/paseo-tool-call-detail"; +} from "@getpaseo/protocol/paseo-tool-presentation/index"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; import { highlightDiffLines } from "@/utils/diff-highlight"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; diff --git a/packages/app/src/tool-calls/presentation.test.ts b/packages/app/src/tool-calls/presentation.test.ts index fde0ba020f..fc609ee8f6 100644 --- a/packages/app/src/tool-calls/presentation.test.ts +++ b/packages/app/src/tool-calls/presentation.test.ts @@ -1,9 +1,11 @@ +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { describe, expect, it } from "vitest"; import { buildToolCallPresentation, type ToolCallPresentationIcon } from "./presentation"; const fakeIcons = { + paseo: (() => null) as ToolCallPresentationIcon, brain: (() => null) as ToolCallPresentationIcon, eye: (() => null) as ToolCallPresentationIcon, wrench: (() => null) as ToolCallPresentationIcon, @@ -12,7 +14,11 @@ const fakeIcons = { function fakeResolveIcon( toolName: string, detail: ToolCallDetail | undefined, + agentMessage: AgentMessage | undefined, ): ToolCallPresentationIcon { + if (agentMessage) { + return fakeIcons.paseo; + } if (detail?.type === "plan") { return fakeIcons.brain; } @@ -94,32 +100,35 @@ describe("tool-call presentation", () => { }); it.each([ - ["message", "Message from QA messenger"], - ["finished", "QA messenger finished"], - ["errored", "QA messenger errored"], - ["permission-required", "QA messenger needs permission"], - ["closed", "QA messenger closed"], -] as const)("presents %s from structured provenance without a second label", (event, heading) => { - const presentation = buildToolCallPresentation({ - toolName: "internal_name_should_not_be_visible", - status: "completed", - error: null, - detail: { type: "plain_text", label: "legacy summary", text: "legacy body" }, - agentMessage: { - event, - sender: { id: "server::sender", title: "QA messenger" }, - text: "The delivered message.", - }, - resolveIcon: fakeResolveIcon, - }); - expect(presentation.displayName).toBe(heading); - expect(presentation.summary).toBeUndefined(); - expect(presentation.detail).toEqual({ - type: "plain_text", - icon: "bot", - text: "From agent: server::sender\n\nThe delivered message.", - }); -}); + ["message", "Message received"], + ["finished", "Agent finished"], + ["errored", "Agent errored"], + ["permission-required", "Agent needs permission"], + ["closed", "Agent closed"], +] as const)( + "presents %s with a fixed label, the sender as context, and the Paseo icon", + (event, heading) => { + const presentation = buildToolCallPresentation({ + toolName: "internal_name_should_not_be_visible", + status: "completed", + error: null, + detail: { type: "plain_text", label: "legacy summary", text: "legacy body", icon: "bot" }, + agentMessage: { + event, + sender: { id: "server::sender", title: "QA messenger" }, + text: "The delivered message.", + }, + resolveIcon: fakeResolveIcon, + }); + expect(presentation.displayName).toBe(heading); + expect(presentation.summary).toBe("QA messenger"); + expect(presentation.icon).toBe(fakeIcons.paseo); + expect(presentation.detail).toEqual({ + type: "plain_text", + text: "From agent: server::sender\n\nThe delivered message.", + }); + }, +); it("uses an opaque sender ID when no title is available", () => { const presentation = buildToolCallPresentation({ @@ -129,5 +138,19 @@ it("uses an opaque sender ID when no title is available", () => { agentMessage: { event: "message", sender: { id: "remote::worker" }, text: "Hello" }, resolveIcon: fakeResolveIcon, }); - expect(presentation.displayName).toBe("Message from remote::worker"); + expect(presentation.displayName).toBe("Message received"); + expect(presentation.summary).toBe("remote::worker"); +}); + +it("shows the target agent title as context for Paseo agent tools", () => { + const presentation = buildToolCallPresentation({ + toolName: "mcp__paseo__kill_agent", + status: "completed", + error: null, + detail: { type: "unknown", input: { agentId: "agt_1" }, output: null }, + targetAgentTitle: "Reviewer", + resolveIcon: fakeResolveIcon, + }); + expect(presentation.displayName).toBe("Kill agent"); + expect(presentation.summary).toBe("Reviewer"); }); diff --git a/packages/app/src/tool-calls/presentation.ts b/packages/app/src/tool-calls/presentation.ts index f25d752624..b0ffbb4d5d 100644 --- a/packages/app/src/tool-calls/presentation.ts +++ b/packages/app/src/tool-calls/presentation.ts @@ -1,5 +1,5 @@ import type { AgentMessage } from "@getpaseo/protocol/agent-message"; -import { buildAgentMessageDisplay } from "@getpaseo/protocol/agent-message-display"; +import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index"; import type { PlanOutcome } from "@/components/plan-card"; import type { ComponentType } from "react"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; @@ -22,6 +22,7 @@ interface BuildToolCallPresentationInput { detail?: ToolCallDetail; cwd?: string; metadata?: Record; + targetAgentTitle?: string; resolveIcon: ToolCallIconResolver; } @@ -42,6 +43,7 @@ export interface ToolCallPresentation { export type ToolCallIconResolver = ( toolName: string, detail: ToolCallDetail | undefined, + agentMessage: AgentMessage | undefined, ) => ToolCallPresentationIcon; function displayStatus(status: ToolCallStatus): ToolCallDisplayInput["status"] { @@ -67,6 +69,7 @@ export function buildToolCallPresentation( detail: detailForDisplay, metadata: input.metadata, cwd: input.cwd, + targetAgentTitle: input.targetAgentTitle, }); const isLoadingDetails = isPendingToolCallDetail({ detail, @@ -80,7 +83,7 @@ export function buildToolCallPresentation( displayName: displayModel.displayName, summary: displayModel.summary, errorText: displayModel.errorText, - icon: input.resolveIcon(input.toolName, detail), + icon: input.resolveIcon(input.toolName, detail, input.agentMessage), isLoadingDetails, hasDetails, canOpenDetails: hasDetails || isLoadingDetails, diff --git a/packages/app/src/tool-calls/use-target-agent-title.ts b/packages/app/src/tool-calls/use-target-agent-title.ts new file mode 100644 index 0000000000..877478d396 --- /dev/null +++ b/packages/app/src/tool-calls/use-target-agent-title.ts @@ -0,0 +1,19 @@ +import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; +import { getPaseoToolTargetAgentId } from "@getpaseo/protocol/paseo-tool-presentation/index"; +import { useSessionStore } from "@/stores/session-store"; + +/** Title of the agent a Paseo tool call acts on, when this host knows it. */ +export function useTargetAgentTitle(input: { + serverId: string | undefined; + toolName: string; + detail: ToolCallDetail | undefined; +}): string | undefined { + const agentId = getPaseoToolTargetAgentId(input.toolName, input.detail); + const { serverId } = input; + return useSessionStore((state) => { + if (!serverId || !agentId) return undefined; + const session = state.sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return agent?.title ?? undefined; + }); +} diff --git a/packages/app/src/utils/tool-call-icon-name.test.ts b/packages/app/src/utils/tool-call-icon-name.test.ts index 14da899c39..272422ecbd 100644 --- a/packages/app/src/utils/tool-call-icon-name.test.ts +++ b/packages/app/src/utils/tool-call-icon-name.test.ts @@ -53,4 +53,14 @@ describe("resolveToolCallIconName", () => { expect(icon).toBe("wrench"); }); + + it("uses the Paseo icon for agent messages despite the legacy bot detail icon", () => { + const icon = resolveToolCallIconName( + "agent_message", + { type: "plain_text", text: "Done.", icon: "bot" }, + { event: "finished", sender: { id: "agt_1" }, text: "Done." }, + ); + + expect(icon).toBe("paseo"); + }); }); diff --git a/packages/app/src/utils/tool-call-icon-name.ts b/packages/app/src/utils/tool-call-icon-name.ts index 28bfedb700..f5fb12559d 100644 --- a/packages/app/src/utils/tool-call-icon-name.ts +++ b/packages/app/src/utils/tool-call-icon-name.ts @@ -1,3 +1,4 @@ +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { ToolCallDetail, ToolCallIconName } from "@getpaseo/protocol/agent-types"; import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization"; @@ -17,7 +18,15 @@ const TOOL_DETAIL_ICON_NAMES: Record = { unknown: "wrench", }; -export function resolveToolCallIconName(toolName: string, detail?: ToolCallDetail): ToolCallIcon { +export function resolveToolCallIconName( + toolName: string, + detail?: ToolCallDetail, + agentMessage?: AgentMessage, +): ToolCallIcon { + // Agent messages are Paseo deliveries; the wire detail's legacy "bot" icon is for old clients. + if (agentMessage) { + return "paseo"; + } const lowerName = toolName.trim().toLowerCase(); if (detail?.type === "plain_text" && detail.icon) { diff --git a/packages/app/src/utils/tool-call-icon.ts b/packages/app/src/utils/tool-call-icon.ts index 804b68c4ca..4e1d83e040 100644 --- a/packages/app/src/utils/tool-call-icon.ts +++ b/packages/app/src/utils/tool-call-icon.ts @@ -10,6 +10,7 @@ import { SquareTerminal, Wrench, } from "lucide-react-native"; +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { PaseoLogo } from "@/components/icons/paseo-logo"; import { resolveToolCallIconName, type ToolCallIcon } from "./tool-call-icon-name"; @@ -36,6 +37,7 @@ export function componentForToolCallIcon(name: ToolCallIcon): ToolCallIconCompon export function resolveToolCallIcon( toolName: string, detail?: ToolCallDetail, + agentMessage?: AgentMessage, ): ToolCallIconComponent { - return componentForToolCallIcon(resolveToolCallIconName(toolName, detail)); + return componentForToolCallIcon(resolveToolCallIconName(toolName, detail, agentMessage)); } diff --git a/packages/protocol/src/agent-message-display.ts b/packages/protocol/src/agent-message-display.ts deleted file mode 100644 index c95f9e8206..0000000000 --- a/packages/protocol/src/agent-message-display.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { AgentMessage } from "./agent-message.js"; -import type { ToolCallDetail } from "./agent-types.js"; - -/** Presentation shared by clients; never used to encode provider prompts. */ -export function buildAgentMessageDisplay(message: AgentMessage): { - displayName: string; - detail: ToolCallDetail; -} { - const sender = message.sender.title ?? message.sender.id; - const headings: Record = { - message: `Message from ${sender}`, - finished: `${sender} finished`, - errored: `${sender} errored`, - "permission-required": `${sender} needs permission`, - closed: `${sender} closed`, - }; - return { - displayName: headings[message.event], - detail: { - type: "plain_text", - icon: "bot", - text: `From agent: ${message.sender.id}\n\n${message.text}`, - }, - }; -} diff --git a/packages/protocol/src/paseo-tool-presentation/agent-message.ts b/packages/protocol/src/paseo-tool-presentation/agent-message.ts new file mode 100644 index 0000000000..33eb44163b --- /dev/null +++ b/packages/protocol/src/paseo-tool-presentation/agent-message.ts @@ -0,0 +1,31 @@ +import type { AgentMessage } from "../agent-message.js"; +import type { ToolCallDetail } from "../agent-types.js"; +import { toSummaryText } from "./summary-text.js"; + +export interface AgentMessageDisplay { + displayName: string; + summary?: string; + detail: ToolCallDetail; +} + +/** Events read noun-first ("Agent finished"); the sender goes in the summary. */ +const EVENT_LABELS: Record = { + message: "Message received", + finished: "Agent finished", + errored: "Agent errored", + "permission-required": "Agent needs permission", + closed: "Agent closed", +}; + +/** Presentation shared by clients; never used to encode provider prompts. */ +export function buildAgentMessageDisplay(message: AgentMessage): AgentMessageDisplay { + const summary = toSummaryText(message.sender.title ?? message.sender.id); + return { + displayName: EVENT_LABELS[message.event], + ...(summary ? { summary } : {}), + detail: { + type: "plain_text", + text: `From agent: ${message.sender.id}\n\n${message.text}`, + }, + }; +} diff --git a/packages/protocol/src/paseo-tool-presentation/catalog.ts b/packages/protocol/src/paseo-tool-presentation/catalog.ts new file mode 100644 index 0000000000..1706134523 --- /dev/null +++ b/packages/protocol/src/paseo-tool-presentation/catalog.ts @@ -0,0 +1,349 @@ +/** + * One entry per Paseo MCP tool: the timeline label, the context shown after it, + * and how the expanded details lay out the call. + * + * Labels are fixed text so truncation never hides them. Actions read verb-first + * ("Send message"); the context goes in the summary. + */ + +type ToolInput = Readonly>; + +/** `targetAgent` is the target agent's title when known, else its ID. */ +export type PaseoToolContext = (input: ToolInput, targetAgent: string | undefined) => unknown; + +export interface PaseoToolDetailSpec { + promptField?: string; + inputOrder?: readonly string[]; + outputFields?: readonly string[]; +} + +export interface PaseoToolEntry { + label: string; + context?: PaseoToolContext; + detail?: PaseoToolDetailSpec; +} + +const WORKSPACE_FIELDS = [ + "title", + "workspaceId", + "projectId", + "isolation", + "path", + "mode", + "worktreeSlug", + "branchName", + "baseBranch", + "branch", + "prNumber", + "forge", +] as const; +const AGENT_FIELDS = [ + "title", + "agentId", + "provider", + "workspaceId", + "cwd", + "sessionMode", + "modeId", + "background", + "notifyOnFinish", + "internal", + "settings", + "labels", +] as const; +const AUTOMATION_FIELDS = [ + "name", + "id", + "cron", + "timezone", + "provider", + "cwd", + "isolation", + "maxRuns", + "expiresIn", + "clearExpires", +] as const; +const BROWSER_FIELDS = [ + "browserId", + "url", + "ref", + "sourceRef", + "targetRef", + "value", + "text", + "key", + "button", + "doubleClick", + "modifiers", + "filePaths", + "fullPage", + "maxEntries", + "timeoutMs", + "deltaX", + "deltaY", + "width", + "height", + "function", +] as const; + +const AUTOMATION_OUTPUT = ["id", "status", "nextRunAt", "expiresAt"] as const; + +function field(...keys: string[]): PaseoToolContext { + return (input) => keys.map((key) => input[key]).find(isPresent); +} + +function targetAgent(): PaseoToolContext { + return (_input, agent) => agent; +} + +function fieldOrTargetAgent(key: string): PaseoToolContext { + return (input, agent) => (isPresent(input[key]) ? input[key] : agent); +} + +function workspaceContext(): PaseoToolContext { + return (input) => + [input.title, input.branchName, input.branch, baseName(input.path)].find(isPresent); +} + +function viewportContext(): PaseoToolContext { + return (input) => + typeof input.width === "number" && typeof input.height === "number" + ? `${input.width}×${input.height}` + : undefined; +} + +function isPresent(value: unknown): boolean { + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + return typeof value === "number"; +} + +function baseName(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return value.split(/[\\/]/).findLast((segment) => segment.length > 0); +} + +function browser(label: string, context?: PaseoToolContext): PaseoToolEntry { + return { label, ...(context ? { context } : {}), detail: { inputOrder: BROWSER_FIELDS } }; +} + +export const PASEO_TOOL_CATALOG = { + // Agents + create_agent: { + label: "Create agent", + context: field("title", "initialPrompt"), + detail: { + promptField: "initialPrompt", + inputOrder: AGENT_FIELDS, + outputFields: ["agentId", "status", "currentModeId", "cwd"], + }, + }, + send_agent_prompt: { + label: "Send message", + context: field("prompt"), + detail: { + promptField: "prompt", + inputOrder: AGENT_FIELDS, + outputFields: ["status", "lastMessage", "permission"], + }, + }, + get_agent_status: { + label: "Check agent", + context: targetAgent(), + detail: { inputOrder: ["agentId"] }, + }, + get_agent_activity: { + label: "Read agent activity", + context: targetAgent(), + detail: { inputOrder: ["agentId", "limit"] }, + }, + list_agents: { + label: "List agents", + detail: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] }, + }, + update_agent: { + label: "Update agent", + context: fieldOrTargetAgent("name"), + detail: { inputOrder: AGENT_FIELDS }, + }, + set_agent_mode: { + label: "Set agent mode", + context: field("modeId"), + detail: { inputOrder: ["agentId", "modeId"] }, + }, + cancel_agent: { + label: "Cancel agent", + context: targetAgent(), + detail: { inputOrder: ["agentId"] }, + }, + archive_agent: { + label: "Archive agent", + context: targetAgent(), + detail: { inputOrder: ["agentId"] }, + }, + kill_agent: { label: "Kill agent", context: targetAgent(), detail: { inputOrder: ["agentId"] } }, + list_pending_permissions: { label: "List permissions" }, + respond_to_permission: { + label: "Respond to permission", + context: targetAgent(), + detail: { inputOrder: ["agentId", "requestId", "response"] }, + }, + + // Workspaces and scripts + create_workspace: { + label: "Create workspace", + context: workspaceContext(), + detail: { inputOrder: WORKSPACE_FIELDS, outputFields: ["workspaceId", "projectId"] }, + }, + list_workspaces: { label: "List workspaces" }, + rename_workspace: { + label: "Rename workspace", + context: field("title"), + detail: { inputOrder: ["title", "workspaceId"] }, + }, + archive_workspace: { + label: "Archive workspace", + context: field("workspaceId"), + detail: { + inputOrder: ["workspaceId"], + outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"], + }, + }, + list_workspace_scripts: { label: "List scripts", detail: { inputOrder: ["workspaceId"] } }, + start_workspace_script: { + label: "Start script", + context: field("scriptName"), + detail: { inputOrder: ["workspaceId", "scriptName"] }, + }, + stop_workspace_script: { + label: "Stop script", + context: field("scriptName"), + detail: { inputOrder: ["workspaceId", "scriptName"] }, + }, + + // Terminals + create_terminal: { + label: "Create terminal", + context: field("name", "command"), + detail: { inputOrder: ["cwd", "workspaceId", "name", "command"] }, + }, + list_terminals: { label: "List terminals", detail: { inputOrder: ["cwd", "all"] } }, + capture_terminal: { + label: "Read terminal", + context: field("terminalId"), + detail: { inputOrder: ["terminalId", "lines"] }, + }, + send_terminal_keys: { + label: "Send keys", + context: field("keys"), + detail: { inputOrder: ["terminalId", "keys", "literal"] }, + }, + kill_terminal: { + label: "Kill terminal", + context: field("terminalId"), + detail: { inputOrder: ["terminalId"] }, + }, + + // Schedules and heartbeats + create_schedule: { + label: "Create schedule", + context: field("name", "cron"), + detail: { + promptField: "prompt", + inputOrder: AUTOMATION_FIELDS, + outputFields: AUTOMATION_OUTPUT, + }, + }, + create_heartbeat: { + label: "Create heartbeat", + context: field("name", "cron"), + detail: { + promptField: "prompt", + inputOrder: AUTOMATION_FIELDS, + outputFields: AUTOMATION_OUTPUT, + }, + }, + update_schedule: { + label: "Update schedule", + context: field("name", "id"), + detail: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS }, + }, + list_schedules: { label: "List schedules" }, + inspect_schedule: { + label: "Inspect schedule", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + schedule_logs: { + label: "Read schedule logs", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + run_schedule_once: { + label: "Run schedule", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + pause_schedule: { label: "Pause schedule", context: field("id"), detail: { inputOrder: ["id"] } }, + resume_schedule: { + label: "Resume schedule", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + delete_schedule: { + label: "Delete schedule", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + delete_heartbeat: { + label: "Delete heartbeat", + context: field("id"), + detail: { inputOrder: ["id"] }, + }, + + // Providers + list_providers: { label: "List providers" }, + list_models: { + label: "List models", + context: field("provider"), + detail: { inputOrder: ["provider"] }, + }, + list_profiles: { label: "List profiles" }, + inspect_provider: { + label: "Inspect provider", + context: field("provider"), + detail: { inputOrder: ["provider", "cwd", "settings"] }, + }, + + // Browser + browser_list_tabs: browser("List browser tabs"), + browser_new_tab: browser("Open browser tab", field("url")), + browser_close_tab: browser("Close browser tab", field("browserId")), + browser_navigate: browser("Navigate", field("url")), + browser_back: browser("Go back"), + browser_forward: browser("Go forward"), + browser_reload: browser("Reload page"), + browser_snapshot: browser("Snapshot page"), + browser_screenshot: browser("Take screenshot"), + browser_click: browser("Click element", field("ref")), + browser_hover: browser("Hover element", field("ref")), + browser_drag: browser("Drag element", field("sourceRef")), + browser_fill: browser("Fill field", field("ref")), + browser_select: browser("Select option", field("value", "ref")), + browser_type: browser("Type text", field("text")), + browser_keypress: browser("Press key", field("key")), + browser_upload: browser("Upload files", field("filePaths")), + browser_wait: browser("Wait for page", field("text", "url")), + browser_scroll: browser("Scroll page"), + browser_resize: browser("Resize browser", viewportContext()), + browser_logs: browser("Read browser logs"), + browser_evaluate: browser("Run JavaScript", field("function")), +} satisfies Record; + +export type PaseoToolName = keyof typeof PASEO_TOOL_CATALOG; + +export function getPaseoToolEntry(leafName: string): PaseoToolEntry | undefined { + return Object.prototype.hasOwnProperty.call(PASEO_TOOL_CATALOG, leafName) + ? PASEO_TOOL_CATALOG[leafName as PaseoToolName] + : undefined; +} diff --git a/packages/protocol/src/paseo-tool-call-detail.test.ts b/packages/protocol/src/paseo-tool-presentation/detail-sections.test.ts similarity index 98% rename from packages/protocol/src/paseo-tool-call-detail.test.ts rename to packages/protocol/src/paseo-tool-presentation/detail-sections.test.ts index 25a637e45c..ab3c123e17 100644 --- a/packages/protocol/src/paseo-tool-call-detail.test.ts +++ b/packages/protocol/src/paseo-tool-presentation/detail-sections.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildPaseoToolDetailSections } from "./paseo-tool-call-detail.js"; +import { buildPaseoToolDetailSections } from "./index.js"; describe("Paseo tool-call detail presentation", () => { it.each(["mcp__paseo__create_agent", "paseo.create_agent", "paseo_remote.create_agent"])( diff --git a/packages/protocol/src/paseo-tool-call-detail.ts b/packages/protocol/src/paseo-tool-presentation/detail-sections.ts similarity index 54% rename from packages/protocol/src/paseo-tool-call-detail.ts rename to packages/protocol/src/paseo-tool-presentation/detail-sections.ts index 4b06d49923..e2fbcbe9b0 100644 --- a/packages/protocol/src/paseo-tool-call-detail.ts +++ b/packages/protocol/src/paseo-tool-presentation/detail-sections.ts @@ -1,4 +1,5 @@ -import { getPaseoToolLeafName } from "./tool-name-normalization.js"; +import { getPaseoToolLeafName } from "../tool-name-normalization.js"; +import { getPaseoToolEntry, type PaseoToolDetailSpec } from "./catalog.js"; export interface PaseoToolDetailField { label: string; @@ -17,161 +18,6 @@ export type PaseoToolDetailSection = fields: PaseoToolDetailField[]; }; -interface ToolDetailSpec { - promptField?: string; - inputOrder?: readonly string[]; - outputFields?: readonly string[]; -} - -const WORKSPACE_FIELDS = [ - "title", - "workspaceId", - "projectId", - "isolation", - "path", - "mode", - "worktreeSlug", - "branchName", - "baseBranch", - "branch", - "prNumber", - "forge", -] as const; -const AGENT_FIELDS = [ - "title", - "agentId", - "provider", - "workspaceId", - "cwd", - "sessionMode", - "modeId", - "background", - "notifyOnFinish", - "internal", - "settings", - "labels", -] as const; -const AUTOMATION_FIELDS = [ - "name", - "id", - "cron", - "timezone", - "provider", - "cwd", - "isolation", - "maxRuns", - "expiresIn", - "clearExpires", -] as const; -const BROWSER_FIELDS = [ - "browserId", - "url", - "ref", - "sourceRef", - "targetRef", - "value", - "text", - "key", - "button", - "doubleClick", - "modifiers", - "filePaths", - "fullPage", - "maxEntries", - "timeoutMs", - "deltaX", - "deltaY", - "width", - "height", - "function", -] as const; - -const TOOL_SPECS: Readonly> = { - create_workspace: { - inputOrder: WORKSPACE_FIELDS, - outputFields: ["workspaceId", "projectId"], - }, - list_workspaces: {}, - archive_workspace: { - inputOrder: ["workspaceId"], - outputFields: ["workspaceId", "archivedAgentIds", "removedDirectory"], - }, - rename_workspace: { inputOrder: ["title", "workspaceId"] }, - create_agent: { - promptField: "initialPrompt", - inputOrder: AGENT_FIELDS, - outputFields: ["agentId", "status", "currentModeId", "cwd"], - }, - send_agent_prompt: { - promptField: "prompt", - inputOrder: AGENT_FIELDS, - outputFields: ["status", "lastMessage", "permission"], - }, - get_agent_status: { inputOrder: ["agentId"] }, - list_agents: { inputOrder: ["cwd", "statuses", "sinceHours", "limit", "includeArchived"] }, - cancel_agent: { inputOrder: ["agentId"] }, - archive_agent: { inputOrder: ["agentId"] }, - kill_agent: { inputOrder: ["agentId"] }, - update_agent: { inputOrder: AGENT_FIELDS }, - get_agent_activity: { inputOrder: ["agentId", "limit"] }, - set_agent_mode: { inputOrder: ["agentId", "modeId"] }, - list_workspace_scripts: { inputOrder: ["workspaceId"] }, - start_workspace_script: { inputOrder: ["workspaceId", "scriptName"] }, - stop_workspace_script: { inputOrder: ["workspaceId", "scriptName"] }, - list_terminals: { inputOrder: ["cwd", "all"] }, - create_terminal: { inputOrder: ["cwd", "workspaceId", "name", "command"] }, - kill_terminal: { inputOrder: ["terminalId"] }, - capture_terminal: { inputOrder: ["terminalId", "lines"] }, - send_terminal_keys: { inputOrder: ["terminalId", "keys", "literal"] }, - create_schedule: { - promptField: "prompt", - inputOrder: AUTOMATION_FIELDS, - outputFields: ["id", "status", "nextRunAt", "expiresAt"], - }, - create_heartbeat: { - promptField: "prompt", - inputOrder: AUTOMATION_FIELDS, - outputFields: ["id", "status", "nextRunAt", "expiresAt"], - }, - delete_heartbeat: { inputOrder: ["id"] }, - list_schedules: {}, - inspect_schedule: { inputOrder: ["id"] }, - pause_schedule: { inputOrder: ["id"] }, - resume_schedule: { inputOrder: ["id"] }, - delete_schedule: { inputOrder: ["id"] }, - update_schedule: { promptField: "prompt", inputOrder: AUTOMATION_FIELDS }, - schedule_logs: { inputOrder: ["id"] }, - run_schedule_once: { inputOrder: ["id"] }, - list_providers: {}, - list_models: { inputOrder: ["provider"] }, - list_profiles: {}, - inspect_provider: { inputOrder: ["provider", "cwd", "settings"] }, - list_pending_permissions: {}, - respond_to_permission: { inputOrder: ["agentId", "requestId", "response"] }, - browser_list_tabs: {}, - browser_new_tab: { inputOrder: BROWSER_FIELDS }, - browser_snapshot: { inputOrder: BROWSER_FIELDS }, - browser_click: { inputOrder: BROWSER_FIELDS }, - browser_fill: { inputOrder: BROWSER_FIELDS }, - browser_wait: { inputOrder: BROWSER_FIELDS }, - browser_type: { inputOrder: BROWSER_FIELDS }, - browser_keypress: { inputOrder: BROWSER_FIELDS }, - browser_navigate: { inputOrder: BROWSER_FIELDS }, - browser_back: { inputOrder: BROWSER_FIELDS }, - browser_forward: { inputOrder: BROWSER_FIELDS }, - browser_reload: { inputOrder: BROWSER_FIELDS }, - browser_screenshot: { inputOrder: BROWSER_FIELDS }, - browser_upload: { inputOrder: BROWSER_FIELDS }, - browser_hover: { inputOrder: BROWSER_FIELDS }, - browser_select: { inputOrder: BROWSER_FIELDS }, - browser_drag: { inputOrder: BROWSER_FIELDS }, - browser_logs: { inputOrder: BROWSER_FIELDS }, - browser_evaluate: { inputOrder: BROWSER_FIELDS }, - browser_scroll: { inputOrder: BROWSER_FIELDS }, - browser_resize: { inputOrder: BROWSER_FIELDS }, - browser_close_tab: { inputOrder: BROWSER_FIELDS }, -}; - const FIELD_LABELS: Readonly> = { agentId: "Agent", archivedAgentIds: "Archived agents", @@ -330,7 +176,7 @@ export function buildPaseoToolDetailSections( const leafName = getPaseoToolLeafName(toolName); if (!leafName) return null; - const spec = TOOL_SPECS[leafName] ?? {}; + const spec: PaseoToolDetailSpec = getPaseoToolEntry(leafName)?.detail ?? {}; const sections: PaseoToolDetailSection[] = []; if (spec.promptField && isRecord(input)) { const prompt = input[spec.promptField]; diff --git a/packages/protocol/src/paseo-tool-presentation/index.test.ts b/packages/protocol/src/paseo-tool-presentation/index.test.ts new file mode 100644 index 0000000000..bea9acd400 --- /dev/null +++ b/packages/protocol/src/paseo-tool-presentation/index.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { + buildAgentMessageDisplay, + buildPaseoToolDisplay, + getPaseoToolTargetAgentId, + MAX_SUMMARY_CHARS, + PASEO_TOOL_NAMES, +} from "./index.js"; + +function display(toolName: string, input: unknown, targetAgentTitle?: string) { + return buildPaseoToolDisplay({ + toolName, + detail: { type: "unknown", input, output: null }, + ...(targetAgentTitle ? { targetAgentTitle } : {}), + }); +} + +describe("Paseo tool display", () => { + it.each([ + [ + "send_agent_prompt", + { agentId: "agt_1", prompt: "Fix the failing test" }, + "Send message", + "Fix the failing test", + ], + ["create_agent", { title: "Reviewer", initialPrompt: "Review it" }, "Create agent", "Reviewer"], + ["create_agent", { initialPrompt: "Review it" }, "Create agent", "Review it"], + ["get_agent_status", { agentId: "agt_1" }, "Check agent", "agt_1"], + ["update_agent", { agentId: "agt_1", name: "Renamed" }, "Update agent", "Renamed"], + ["create_workspace", { path: "/tmp/repo/feature-x" }, "Create workspace", "feature-x"], + ["create_workspace", { title: "Docs", branchName: "docs" }, "Create workspace", "Docs"], + ["start_workspace_script", { scriptName: "web" }, "Start script", "web"], + ["send_terminal_keys", { terminalId: "t1", keys: "npm test" }, "Send keys", "npm test"], + ["create_schedule", { prompt: "Check CI", cron: "0 9 * * 1" }, "Create schedule", "0 9 * * 1"], + ["browser_navigate", { url: "https://paseo.sh" }, "Navigate", "https://paseo.sh"], + [ + "browser_upload", + { ref: "@e1", filePaths: ["/a.png", "/b.png"] }, + "Upload files", + "/a.png +1", + ], + ["browser_resize", { width: 800, height: 600 }, "Resize browser", "800×600"], + ])("presents %s with a fixed label and its context", (tool, input, label, summary) => { + expect(display(`mcp__paseo__${tool}`, input)).toEqual({ displayName: label, summary }); + }); + + it("omits the summary when a call has no context", () => { + expect(display("mcp__paseo__list_agents", {})).toEqual({ displayName: "List agents" }); + }); + + it("recognizes every Paseo namespace format", () => { + for (const name of ["mcp__paseo__kill_agent", "paseo.kill_agent", "paseo_remote.kill_agent"]) { + expect(display(name, { agentId: "agt_1" })?.displayName).toBe("Kill agent"); + } + }); + + it("returns null outside the Paseo namespace, including speak", () => { + expect(display("Bash", {})).toBeNull(); + expect(display("mcp__paseo__speak", { text: "hi" })).toBeNull(); + }); + + it("humanizes a Paseo tool that has no catalog entry", () => { + expect(display("mcp__paseo__brand_new_tool", {})).toEqual({ displayName: "Brand new tool" }); + }); + + it("prefers the known agent title over the agent ID", () => { + expect(display("mcp__paseo__cancel_agent", { agentId: "agt_1" }, "Reviewer")).toEqual({ + displayName: "Cancel agent", + summary: "Reviewer", + }); + expect( + getPaseoToolTargetAgentId("mcp__paseo__cancel_agent", { + type: "unknown", + input: { agentId: "agt_1" }, + output: null, + }), + ).toBe("agt_1"); + expect( + getPaseoToolTargetAgentId("Bash", { type: "unknown", input: { agentId: "x" }, output: null }), + ).toBeNull(); + }); + + it("truncates a massive single-line prompt and collapses whitespace", () => { + const prompt = `Line one\n\n line two ${"x".repeat(1_000_000)}`; + const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary; + expect(summary).toHaveLength(MAX_SUMMARY_CHARS); + expect(summary?.startsWith("Line one line two xxx")).toBe(true); + expect(summary?.endsWith("…")).toBe(true); + }); + + it("bounds the scan even when the prompt opens with a wall of whitespace", () => { + const prompt = `${" ".repeat(10_000)}hidden`; + const summary = display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt })?.summary; + expect(summary).toBeUndefined(); + }); + + it("keeps a short prompt intact", () => { + expect( + display("mcp__paseo__send_agent_prompt", { agentId: "a", prompt: " hi\nthere " })?.summary, + ).toBe("hi there"); + }); + + it("catalogs tool names as lowercase leaf names", () => { + for (const name of PASEO_TOOL_NAMES) { + expect(name).toMatch(/^[a-z_]+$/); + } + }); +}); + +describe("agent message display", () => { + it.each([ + ["message", "Message received"], + ["finished", "Agent finished"], + ["errored", "Agent errored"], + ["permission-required", "Agent needs permission"], + ["closed", "Agent closed"], + ] as const)("labels %s noun-first with the sender as context", (event, label) => { + const result = buildAgentMessageDisplay({ + event, + sender: { id: "srv::agt_1", title: "QA messenger" }, + text: "Done.", + }); + expect(result.displayName).toBe(label); + expect(result.summary).toBe("QA messenger"); + expect(result.detail).toEqual({ type: "plain_text", text: "From agent: srv::agt_1\n\nDone." }); + }); + + it("falls back to the sender ID and truncates a long title", () => { + expect( + buildAgentMessageDisplay({ event: "finished", sender: { id: "srv::agt_1" }, text: "" }) + .summary, + ).toBe("srv::agt_1"); + const summary = buildAgentMessageDisplay({ + event: "finished", + sender: { id: "a", title: "t".repeat(500) }, + text: "", + }).summary; + expect(summary).toHaveLength(MAX_SUMMARY_CHARS); + }); +}); diff --git a/packages/protocol/src/paseo-tool-presentation/index.ts b/packages/protocol/src/paseo-tool-presentation/index.ts new file mode 100644 index 0000000000..c04606a231 --- /dev/null +++ b/packages/protocol/src/paseo-tool-presentation/index.ts @@ -0,0 +1,85 @@ +import type { ToolCallDetail } from "../agent-types.js"; +import { getPaseoToolLeafName, isPaseoToolName } from "../tool-name-normalization.js"; +import { getPaseoToolEntry, PASEO_TOOL_CATALOG, type PaseoToolName } from "./catalog.js"; +import { toSummaryText } from "./summary-text.js"; + +export { buildAgentMessageDisplay, type AgentMessageDisplay } from "./agent-message.js"; +export { + buildPaseoToolDetailSections, + type PaseoToolDetailField, + type PaseoToolDetailSection, +} from "./detail-sections.js"; +export { MAX_SUMMARY_CHARS } from "./summary-text.js"; +export type { PaseoToolName }; + +export interface PaseoToolDisplayInput { + toolName: string; + detail: ToolCallDetail; + /** Title of the agent named by the call's `agentId`, when the caller knows it. */ + targetAgentTitle?: string; +} + +export interface PaseoToolDisplay { + displayName: string; + summary?: string; +} + +export const PASEO_TOOL_NAMES = Object.keys(PASEO_TOOL_CATALOG) as readonly PaseoToolName[]; + +/** Returns null for tools outside the Paseo namespace. */ +export function buildPaseoToolDisplay(input: PaseoToolDisplayInput): PaseoToolDisplay | null { + const leafName = paseoLeafName(input.toolName); + if (!leafName) return null; + const entry = getPaseoToolEntry(leafName); + if (!entry) return { displayName: humanizeLeafName(leafName) }; + + const toolInput = readToolInput(input.detail); + const targetAgent = input.targetAgentTitle ?? readString(toolInput.agentId); + const summary = formatContext(entry.context?.(toolInput, targetAgent)); + return { displayName: entry.label, ...(summary ? { summary } : {}) }; +} + +/** The agent a Paseo call acts on, so callers can resolve its title. */ +export function getPaseoToolTargetAgentId( + toolName: string, + detail: ToolCallDetail | undefined, +): string | null { + if (!detail || !paseoLeafName(toolName)) return null; + return readString(readToolInput(detail).agentId) ?? null; +} + +function paseoLeafName(toolName: string): string | null { + return isPaseoToolName(toolName) ? getPaseoToolLeafName(toolName) : null; +} + +function readToolInput(detail: ToolCallDetail): Readonly> { + if (detail.type !== "unknown") return {}; + const value = detail.input; + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function formatContext(value: unknown): string | undefined { + if (typeof value === "string") return toSummaryText(value); + if (typeof value === "number") return String(value); + if (Array.isArray(value)) { + const [first, ...rest] = value; + const head = typeof first === "string" ? toSummaryText(first) : undefined; + if (!head) return undefined; + return rest.length > 0 ? `${head} +${rest.length}` : head; + } + return undefined; +} + +function humanizeLeafName(leafName: string): string { + const sentence = leafName + .replace(/[._-]+/g, " ") + .trim() + .toLowerCase(); + return `${sentence[0]?.toUpperCase() ?? ""}${sentence.slice(1)}`; +} diff --git a/packages/protocol/src/paseo-tool-presentation/summary-text.ts b/packages/protocol/src/paseo-tool-presentation/summary-text.ts new file mode 100644 index 0000000000..57e7405771 --- /dev/null +++ b/packages/protocol/src/paseo-tool-presentation/summary-text.ts @@ -0,0 +1,21 @@ +/** + * Prompts and results can be one enormous line. Summaries are capped here so no + * consumer relies on its renderer to clip them. + */ +export const MAX_SUMMARY_CHARS = 120; +// Bounds the whitespace scan: inputs can be megabytes. +const SCAN_WINDOW_CHARS = MAX_SUMMARY_CHARS * 4; + +export function toSummaryText(value: string): string | undefined { + const isPartial = value.length > SCAN_WINDOW_CHARS; + const collapsed = value.slice(0, SCAN_WINDOW_CHARS).replace(/\s+/g, " ").trim(); + if (!collapsed) return undefined; + if (collapsed.length <= MAX_SUMMARY_CHARS && !isPartial) return collapsed; + const kept = dropTrailingHighSurrogate(collapsed.slice(0, MAX_SUMMARY_CHARS - 1)).trimEnd(); + return `${kept}…`; +} + +function dropTrailingHighSurrogate(text: string): string { + const last = text.charCodeAt(text.length - 1); + return last >= 0xd800 && last <= 0xdbff ? text.slice(0, -1) : text; +} diff --git a/packages/protocol/src/tool-call-display.ts b/packages/protocol/src/tool-call-display.ts index ab597b5acd..db0aa6db20 100644 --- a/packages/protocol/src/tool-call-display.ts +++ b/packages/protocol/src/tool-call-display.ts @@ -1,6 +1,8 @@ -import { buildAgentMessageDisplay } from "./agent-message-display.js"; import type { ToolCallTimelineItem } from "./agent-types.js"; -import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js"; +import { + buildAgentMessageDisplay, + buildPaseoToolDisplay, +} from "./paseo-tool-presentation/index.js"; import { stripCwdPrefix } from "./path-utils.js"; export type ToolCallDisplayInput = Pick< @@ -8,6 +10,8 @@ export type ToolCallDisplayInput = Pick< "name" | "status" | "error" | "metadata" | "detail" | "agentMessage" > & { cwd?: string; + /** Title of the agent a Paseo call targets, when the caller knows it. */ + targetAgentTitle?: string; }; export interface ToolCallDisplayModel { @@ -34,12 +38,6 @@ function humanizeToolName(name: string): string { if (!trimmed) { return name; } - if (isPaseoToolName(trimmed)) { - const leaf = getPaseoToolLeafName(trimmed); - if (leaf) { - return humanizeToolName(leaf); - } - } if (/[:./]/.test(trimmed) || trimmed.includes("__")) { return trimmed; } @@ -152,8 +150,20 @@ function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay } export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel { - if (input.agentMessage) { - return { displayName: buildAgentMessageDisplay(input.agentMessage).displayName }; + const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined; + const paseoDisplay = input.agentMessage + ? buildAgentMessageDisplay(input.agentMessage) + : buildPaseoToolDisplay({ + toolName: input.name, + detail: input.detail, + targetAgentTitle: input.targetAgentTitle, + }); + if (paseoDisplay) { + return { + displayName: paseoDisplay.displayName, + ...(paseoDisplay.summary ? { summary: paseoDisplay.summary } : {}), + ...(errorText ? { errorText } : {}), + }; } const canonicalDisplay = buildCanonicalDetailDisplay(input); const unknownDetailOverride = buildUnknownDetailOverride(input); @@ -162,7 +172,6 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall canonicalDisplay.displayName ?? humanizeToolName(input.name); const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary; - const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined; return { displayName, diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 58a8a8b4f1..77814f1779 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js"; import type { AgentTimelineItem } from "./agent-sdk-types.js"; import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; +import { formatAgentMessage, projectAgentMessage } from "./agent-messages/index.js"; function toolCallItem(params: { callId: string; @@ -78,6 +79,28 @@ describe("curateAgentActivity", () => { ); }); + it("renders delivered agent messages with their event, sender, and body", () => { + const delivered = (source: Parameters[0]["source"], text: string) => + projectAgentMessage({ + type: "user_message", + text: formatAgentMessage({ id: `msg-${text.length}`, source, text }), + })!; + const timeline: AgentTimelineItem[] = [ + delivered( + { kind: "agent-message", agentId: "agt_lead", title: "CI lead" }, + "Take ownership.\nRead the handoff.", + ), + delivered( + { kind: "agent-notification", agentId: "agt_child", event: "finished" }, + "Child is done.", + ), + ]; + + expect(curateAgentActivity(timeline)).toBe( + "[Message received] CI lead\nTake ownership.\nRead the handoff.\n[Agent finished] agt_child\nChild is done.", + ); + }); + it("renders user/assistant/reasoning entries", () => { const timeline: AgentTimelineItem[] = [ { type: "user_message", text: "Hello" }, diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index efd0a8e48d..ec5bceb1cb 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -1,7 +1,9 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js"; +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { AgentTimelineRow } from "./agent-timeline-store-types.js"; import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization"; +import { buildAgentMessageDisplay } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { projectTimelineRows } from "./timeline-projection.js"; @@ -119,6 +121,17 @@ function formatToolCallEntry( return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`); } +function appendAgentMessageEntries(entries: ActivityEntry[], message: AgentMessage) { + const display = buildAgentMessageDisplay(message); + entries.push( + activityEntry( + display.summary ? `[${display.displayName}] ${display.summary}` : `[${display.displayName}]`, + ), + ); + const body = message.text.trim(); + if (body) entries.push(activityEntry(body)); +} + function curateProjectedActivityEntries( items: readonly AgentTimelineItem[], options?: ActivityCuratorOptions, @@ -146,6 +159,10 @@ function curateProjectedActivityEntries( appendTextEntry(entries, item, options); break; case "tool_call": { + if (item.agentMessage) { + appendAgentMessageEntries(entries, item.agentMessage); + break; + } entries.push(formatToolCallEntry(item, options)); if (item.detail.type === "sub_agent" && item.detail.log.trim()) { entries.push(activityEntry(item.detail.log.trim())); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 34d36c9e16..59fb6022e9 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -8,6 +8,7 @@ import type { ProviderOptions, ToolPolicy, } from "@getpaseo/protocol/agent-types"; +import type { AgentMessage } from "@getpaseo/protocol/agent-message"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; @@ -342,6 +343,8 @@ interface ToolCallBase { name: string; detail: ToolCallDetail; metadata?: Record; + /** Set when the row is a delivered agent message rather than a provider tool call. */ + agentMessage?: AgentMessage; } type ToolCallRunningTimelineItem = ToolCallBase & { diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 9246f463d0..cf2e6ab217 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -66,6 +66,7 @@ import type { ForgeService } from "../../services/forge-service.js"; import { areEquivalentPaths } from "../../utils/path.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { PASEO_TOOL_NAMES } from "@getpaseo/protocol/paseo-tool-presentation/index"; import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages"; import type { DaemonConfigStore } from "../daemon-config-store.js"; import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js"; @@ -888,6 +889,40 @@ describe("bundled Paseo skill", () => { }); }); +describe("Paseo tool timeline presentation", () => { + it("catalogs a timeline label for every advertised tool", async () => { + const server = await createAgentMcpServer({ + agentManager: new BoundaryAgentManagerFake() as AgentManager, + agentStorage: new BoundaryAgentStorageFake() as AgentStorage, + providerSnapshotManager: + new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager, + browserToolsEnabled: true, + browserToolsBroker: new FakeBrowserToolsBroker({ + requestId: "req-catalog", + ok: true, + result: { command: "list_tabs", tabs: [] }, + }) as BrowserToolsBroker, + callerAgentId: "agent-1", + logger: createTestLogger(), + }); + const client = await connectInMemoryMcpClient(server); + + try { + const { tools } = await client.listTools(); + const cataloged = new Set(PASEO_TOOL_NAMES); + // speak renders as a voice message, not a tool call row. + const uncataloged = tools + .map((tool) => tool.name) + .filter((name) => name !== "speak" && !cataloged.has(name)); + expect(tools.length).toBeGreaterThan(40); + expect(uncataloged).toEqual([]); + } finally { + await client.close(); + await server.close(); + } + }); +}); + describe("browser MCP tools", () => { const logger = createTestLogger();