From 5384abdbaaf69db588ff0d924929bda31bd19d94 Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Sat, 17 Oct 2026 14:49:56 +0200
Subject: [PATCH 001/354] fix(app): open Markdown preview folder links in Files
(#7229)
Preview links bypassed the directory-aware opener and opened folder destinations as file tabs. Use the existing opener and explorer reveal actions, retaining preferred file placement and canceling pending checks when leaving the source tab.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
---
packages/app/e2e/browser/folder-links.spec.ts | 57 +++++++++++++++++++
.../screens/workspace/workspace-screen.tsx | 47 +++++++++++++--
2 files changed, 100 insertions(+), 4 deletions(-)
diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts
index 8c1339a8c6..a893fbcb68 100644
--- a/packages/app/e2e/browser/folder-links.spec.ts
+++ b/packages/app/e2e/browser/folder-links.spec.ts
@@ -2,6 +2,63 @@ import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
+import {
+ expectFileTabOpen,
+ openFileExplorer,
+ openFileFromExplorer,
+} from "../support/helpers/file-explorer";
+import { ensureWorkspaceAgentPaneVisible } from "../support/helpers/workspace-tabs";
+
+async function openMarkdownPreview(page: Page) {
+ await openFileExplorer(page);
+ await openFileFromExplorer(page, "README.md");
+ await expectFileTabOpen(page, "README.md");
+ await page
+ .getByTestId("file-panel-bar")
+ .getByRole("button", { name: "Preview", exact: true })
+ .click();
+ await ensureWorkspaceAgentPaneVisible(page);
+}
+
+async function followPreviewLink(page: Page, name: string) {
+ await page
+ .getByTestId("workspace-file-pane")
+ .filter({ visible: true })
+ .getByRole("link", { name, exact: true })
+ .click();
+}
+
+test("Markdown preview folder links reveal Files and file links still open a file", async ({
+ page,
+ withWorkspace,
+}) => {
+ const workspace = await withWorkspace({ prefix: "preview-folder-links-" });
+ await mkdir(path.join(workspace.repoPath, "docs/guides"), { recursive: true });
+ await writeFile(
+ path.join(workspace.repoPath, "docs/guides/guide.md"),
+ "# Folder link destination\n",
+ );
+ await writeFile(
+ path.join(workspace.repoPath, "README.md"),
+ "[Dot](./docs/guides)\n\n[Bare](docs/guides)\n\n[Guide](docs/guides/guide.md)\n",
+ );
+ await workspace.navigateTo();
+
+ await openMarkdownPreview(page);
+ await followPreviewLink(page, "Guide");
+ await expect(page.getByText("Folder link destination", { exact: true })).toBeVisible();
+
+ await openMarkdownPreview(page);
+ await followPreviewLink(page, "Dot");
+ await expectFolderContents(page);
+ await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
+
+ await openMarkdownPreview(page);
+ await followPreviewLink(page, "Bare");
+ await expectFolderContents(page);
+ await expect(page.getByTestId("workspace-tab-file_docs/guides")).toHaveCount(0);
+ await page.screenshot({ path: test.info().outputPath("preview-folder-links.png") });
+});
async function openReportedFolderLink(page: Page, name: string) {
const link = page.getByTestId("assistant-message").getByRole("link", { name, exact: true });
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index 0872c0dc92..48e123c26a 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -1,4 +1,9 @@
-import { AssistantFileLinkResolverProvider, type InlinePathTarget } from "@/assistant-file-links";
+import {
+ AssistantFileLinkResolverProvider,
+ createInlinePathOpener,
+ type InlinePathTarget,
+} from "@/assistant-file-links";
+import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import {
WorkspaceComposerEditingSessionsProvider,
useComposerTabFullscreen,
@@ -203,6 +208,7 @@ import { PluginHeaderButtons } from "@/plugins";
import {
createWorkspaceFileTabTarget,
normalizeWorkspaceFileLocation,
+ type OpenFileDisposition,
type WorkspaceFileLocation,
type WorkspaceFileOpenRequest,
} from "@/workspace/file-open";
@@ -3658,9 +3664,42 @@ function WorkspaceScreenContent({
);
- const openMarkdownFile = useStableEvent((target: InlinePathTarget) => {
- handleOpenPreferredAssistantFile({ location: target, parentTabId: activeTabId });
+ const { revealDirectory } = useFileExplorerActions({
+ serverId: normalizedServerId,
+ workspaceId: normalizedWorkspaceId,
+ workspaceRoot: workspaceDirectory,
});
+ const openMarkdownFile = useStableEvent((location: WorkspaceFileLocation) => {
+ handleOpenPreferredAssistantFile({ location, parentTabId: activeTabId });
+ });
+ const openMarkdownDirectory = useStableEvent((directoryPath: string) => {
+ void revealDirectory(directoryPath);
+ openExplorerSidebarView({
+ isCompact: isMobile,
+ workspaceKey: persistenceKey,
+ checkout: activeExplorerCheckout,
+ view: "files",
+ });
+ });
+ const markdownPathOpener = useMemo(
+ () =>
+ createInlinePathOpener({
+ workspaceRoot: workspaceDirectory ?? "",
+ client,
+ openFile: openMarkdownFile,
+ openDirectory: openMarkdownDirectory,
+ }),
+ [workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
+ );
+ useEffect(() => {
+ if (!isRouteFocused) markdownPathOpener.cancel();
+ return () => markdownPathOpener.cancel();
+ }, [markdownPathOpener, isRouteFocused, activeTabId]);
+ const openMarkdownPath = useStableEvent(
+ (target: InlinePathTarget, disposition: OpenFileDisposition) => {
+ if (isRouteFocused) void markdownPathOpener.open(target, disposition);
+ },
+ );
const renderedWorkspaceScreen = (
@@ -3705,7 +3744,7 @@ function WorkspaceScreenContent({
client={client}
serverId={normalizedServerId}
workspaceRoot={workspaceDirectory ?? undefined}
- onOpenWorkspaceFile={openMarkdownFile}
+ onOpenWorkspaceFile={openMarkdownPath}
toast={toast}
>
{renderedWorkspaceScreen}
From a0aeca967c9cf0af433c76b1f0ba0c50daa419c4 Mon Sep 17 00:00:00 2001
From: Mohamed Boudra
Date: Sat, 17 Oct 2026 19:59:49 +0700
Subject: [PATCH 002/354] fix(app): open explicit file links regardless of
extension (#7233)
Authored Markdown destinations were filtered by the filename heuristics intended for inferred text, silently ignoring screenshot links. Resolve explicit destinations directly through the existing workspace opener so reads and failures reach the file pane.
---
packages/app/e2e/browser/file-editing.spec.ts | 31 +++++++++++++++++++
.../app/src/assistant-file-links/parse.ts | 24 ++++++--------
.../src/assistant-file-links/resolver.test.ts | 17 +++++++++-
.../app/src/assistant-file-links/resolver.ts | 2 +-
4 files changed, 57 insertions(+), 17 deletions(-)
diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts
index da729381d2..0d63336d59 100644
--- a/packages/app/e2e/browser/file-editing.spec.ts
+++ b/packages/app/e2e/browser/file-editing.spec.ts
@@ -105,6 +105,37 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
+ test("opens explicit screenshot links and explains missing files", async ({ page }) => {
+ const session = await seedMockAgentWorkspace({
+ repoPrefix: "screenshot-markdown-link-",
+ title: "Screenshot file links",
+ initialPrompt: "Show screenshots",
+ featureValues: {
+ mockAssistantResponse:
+ "[Capture](.dev/tablet-review/capture.png) [Missing capture](.dev/tablet-review/missing.png)",
+ },
+ });
+ try {
+ await mkdir(path.join(session.cwd, ".dev/tablet-review"), { recursive: true });
+ await writeFile(path.join(session.cwd, ".dev/tablet-review/capture.png"), RED_PIXEL);
+ await openAgentRoute(page, session);
+ await test.step("Open the linked screenshot", async () => {
+ await openAssistantMarkdownLink(page, "Capture");
+ await expectFileTabOpen(page, ".dev/tablet-review/capture.png");
+ await expect(page.getByTestId("image-file-preview-canvas")).toBeVisible();
+ });
+ await test.step("Explain why the missing screenshot cannot open", async () => {
+ await returnToAssistant(page, session.agentId);
+ await openAssistantMarkdownLink(page, "Missing capture");
+ await expectFileTabOpen(page, ".dev/tablet-review/missing.png");
+ await expect(page.getByText(/ENOENT/)).toBeVisible();
+ await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
+ });
+ } finally {
+ await session.cleanup();
+ }
+ });
+
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts
index c195a277cf..4fb41a72ca 100644
--- a/packages/app/src/assistant-file-links/parse.ts
+++ b/packages/app/src/assistant-file-links/parse.ts
@@ -23,6 +23,8 @@ const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0
const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i;
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
+ /** Authored links identify a file; only inferred text needs filename heuristics. */
+ explicitLink?: boolean;
/**
* True only when `value` is a markdown href — the renderer percent-encodes
* non-ASCII and space characters, so the path must be decoded. Raw path
@@ -30,8 +32,6 @@ export interface AssistantHrefParseOptions {
* send a real file named `my%20file.md` to `my file.md`.
*/
decodeHref?: boolean;
- /** Explicit Markdown links can name nested folders without a filename extension. */
- allowExtensionlessNestedPaths?: boolean;
}
export type AssistantFileLinkClassification =
@@ -236,7 +236,7 @@ export function classifyAssistantFileLink(
return null;
}
- if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
+ if (!options.explicitLink && isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -280,7 +280,7 @@ export function parseAssistantFileLink(
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
- allowExtensionlessNestedPaths: options.allowExtensionlessNestedPaths,
+ explicitLink: options.explicitLink,
});
if (relativeTarget) {
return relativeTarget;
@@ -414,7 +414,7 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
- options: Pick = {},
+ options: Pick = {},
): {
path: string;
lines: Pick;
@@ -440,7 +440,7 @@ function parseLocalPathParts(
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
- if (!isPlausibleAssistantLocalPath(pathPart, options)) {
+ if (!options.explicitLink && !isPlausibleAssistantLocalPath(pathPart)) {
return null;
}
return {
@@ -461,7 +461,7 @@ function parseLocalPathParts(
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
- if (!isPlausibleAssistantLocalPath(plausiblePath, options)) {
+ if (!options.explicitLink && !isPlausibleAssistantLocalPath(plausiblePath)) {
return null;
}
@@ -586,10 +586,7 @@ function getHeuristicLocalPath(value: string): string | null {
return beforeHash;
}
-function isPlausibleAssistantLocalPath(
- pathValue: string,
- options: Pick = {},
-): boolean {
+function isPlausibleAssistantLocalPath(pathValue: string): boolean {
const normalized = normalizePathToken(pathValue);
if (!normalized) {
return false;
@@ -613,10 +610,7 @@ function isPlausibleAssistantLocalPath(
if (segments.length > 1) {
const lastSegment = segments[segments.length - 1];
- return (
- !isDomainLikePathSegment(firstSegment) &&
- (options.allowExtensionlessNestedPaths === true || isPlausibleAssistantFileName(lastSegment))
- );
+ return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment);
}
return isPlausibleAssistantFileName(firstSegment);
diff --git a/packages/app/src/assistant-file-links/resolver.test.ts b/packages/app/src/assistant-file-links/resolver.test.ts
index 854c358627..96884c21c7 100644
--- a/packages/app/src/assistant-file-links/resolver.test.ts
+++ b/packages/app/src/assistant-file-links/resolver.test.ts
@@ -50,7 +50,7 @@ it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])(
},
);
-it.each(["example.com/guides", "docs/guides?view=all"])(
+it.each(["docs/guides?view=all"])(
"does not turn URL-like hrefs into workspace paths: %s",
(href) => {
expect(classifyForResolution({ href }, CONTEXT)).toEqual({
@@ -60,6 +60,21 @@ it.each(["example.com/guides", "docs/guides?view=all"])(
},
);
+it.each([
+ ".dev/tablet-review/tablet-agent-draft.png",
+ "images/capture.jpg",
+ "downloads/archive.custom",
+ "assets/LICENSE",
+ "example.com/guides",
+ "capture.png",
+ "missing.png",
+])("opens explicit file link %s without filename guessing", (href) => {
+ expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({
+ kind: "resolved",
+ value: { kind: "file", target: { path: `${CONTEXT.workspaceRoot}/${href}` } },
+ });
+});
+
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
try {
diff --git a/packages/app/src/assistant-file-links/resolver.ts b/packages/app/src/assistant-file-links/resolver.ts
index 59057ba0ca..be012077a3 100644
--- a/packages/app/src/assistant-file-links/resolver.ts
+++ b/packages/app/src/assistant-file-links/resolver.ts
@@ -117,7 +117,7 @@ export function classifyForResolution(
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
- allowExtensionlessNestedPaths: tokenInfo.fromHref && !isLinkifiedSource(source),
+ explicitLink: source.sourceType !== "inline-code" && !isLinkifiedSource(source),
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
From f22775a8e14908052e4b933c7e8007663449c7c4 Mon Sep 17 00:00:00 2001
From: Derick Wells
Date: Sat, 17 Oct 2026 21:39:05 +0800
Subject: [PATCH 003/354] 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 004/354] 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\nAfter it.").render(),
+ ).toBe("Before the shot:\n\n\n\nAfter it.");
+ expect(prepareMarkdownSource("\n").render()).toBe(
+ "\n\n",
+ );
+ expect(
+ prepareMarkdownSource(
+ "Line one\\\n[](https://x)\nLine three",
+ ).render(),
+ ).toBe("Line one\n\n[](https://x)\n\nLine three");
+ });
+ it("keeps images that share a line with text inline", () => {
+ const source = "Icon  in text\n 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> \n> More").render()).toBe(
+ "> Quote text\n>\n> \n>\n> More",
+ );
+ expect(
+ prepareMarkdownSource("- Item text\n \n More\n- Next").render(),
+ ).toBe("- Item text\n\n \n\n More\n- Next");
+ expect(prepareMarkdownSource("- \n Caption line").render()).toBe(
+ "- \n\n Caption line",
+ );
+ });
+ it("renders own-line images as blocks through the library parser", async () => {
+ const source =
+ "Before:\n\nIcon  in text\n> Quoted\n> ";
+ 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 005/354] 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 006/354] 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 007/354] 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 (
-
+
{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
);
-}
+});
function collectPanesById(node: SplitNode): Map {
const next = new Map();
From 3882b957a63439f9d5c6e8513fc7cb420167d818 Mon Sep 17 00:00:00 2001
From: Mohamed Boudra
Date: Sun, 18 Oct 2026 14:56:39 +0700
Subject: [PATCH 030/354] ci: rebalance Playwright shard weights from measured
step minutes (#7297)
Weights derived from the Playwright step minutes of five eight-shard runs; shard 8 drops from about 16 minutes to 10 and the shards sit within about 4 minutes of each other once normalized for runner CPU.
---
.github/workflows/ci.yml | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a2cbb0c29a..35432703cd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -452,7 +452,9 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
E2E_WORKERS: "2"
# File groups are weighted by observed reporter durations.
- PWTEST_SHARD_WEIGHTS: "93:100:101:104:91:94:122:110"
+ # Share of the test list per shard, rebalanced from the Playwright step
+ # minutes of five eight-shard runs so the slowest and fastest shards meet.
+ PWTEST_SHARD_WEIGHTS: "85:94:117:128:91:103:127:85"
- name: Upload test artifacts
uses: actions/upload-artifact@v4
From 233997ed5c932a450e399ec241f153894befd812 Mon Sep 17 00:00:00 2001
From: Mohamed Boudra
Date: Sun, 18 Oct 2026 15:12:28 +0700
Subject: [PATCH 031/354] Give Markdown blocks one consistent gap, roomier on
desktop, and a zoom-in cursor on images (#7298)
* fix(scripts): recover installs when a dependency patch changed
patch-package cannot apply a patch over a package patched with an earlier
version of it, and without --error-on-fail it exits 0 locally. Checkouts kept
the stale patch silently: the Markdown code block spacing fix never reached
them. The script now fails the install and removes that package so the next
npm install reinstalls it clean with the current patch. A patch-package that
cannot start fails the install without removing anything.
* fix(app): give Markdown blocks one gap and roomier desktop spacing
Every Markdown block is followed by one gap, density.blockGap: 12 on compact
layouts and 16 wider. The library's element styles and the views stacked
outside it (Mermaid segments on native, streaming head and tail) take the same
token, so no block type carries its own margins. Desktop was too tight at 12.
Markdown images show a zoom-in cursor on web, since tapping opens the lightbox.
---
.github/workflows/ci.yml | 3 +
docs/development.md | 4 +-
docs/markdown-renderer.md | 4 +
.../e2e/browser/plan-card-markdown.spec.ts | 41 ++------
.../src/components/markdown/block-stack.tsx | 28 ++++++
.../markdown/fence/mermaid/host.web.tsx | 17 +---
.../markdown/fence/mermaid/presentation.ts | 3 -
.../app/src/components/markdown/frame.tsx | 18 ----
.../src/components/markdown/renderer.test.ts | 8 +-
.../app/src/components/markdown/renderer.tsx | 73 +++++++-------
.../app/src/components/markdown/styles.ts | 12 +--
packages/app/src/styles/density.ts | 9 +-
...own+1.2.0-nightly-20261014-766fc0e67.patch | 12 +++
scripts/postinstall-patches.mjs | 50 +++++-----
scripts/postinstall-patches.test.mjs | 99 +++++++++++++++++++
15 files changed, 245 insertions(+), 136 deletions(-)
create mode 100644 packages/app/src/components/markdown/block-stack.tsx
delete mode 100644 packages/app/src/components/markdown/frame.tsx
create mode 100644 scripts/postinstall-patches.test.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 35432703cd..5d2d489f09 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -145,6 +145,9 @@ jobs:
- name: Install dependencies
run: node scripts/npm-retry.mjs ci
+ - name: Verify dependency patch recovery
+ run: node --test scripts/postinstall-patches.test.mjs
+
- name: Build server stack
run: npm run build:server
diff --git a/docs/development.md b/docs/development.md
index 437b635fec..7eb92375ab 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -535,7 +535,9 @@ For tighter loops, you can rebuild a single workspace:
`patches/*.patch` are applied by `scripts/postinstall-patches.mjs` on every install. A patch only
runs when its package is actually present, so add the package to that script's `patchedPackages`
list when you introduce a new patch — otherwise the file sits in `patches/` and never applies.
-Regenerate a patch with `npx patch-package ` after editing `node_modules/`, and
+patch-package cannot apply a patch that changed after an earlier version was applied, so the
+script removes that package and fails the install; the next `npm install` reinstalls it clean
+with the current patch. Regenerate a patch with `npx patch-package ` after editing `node_modules/`, and
patch every build the consumers use: Metro resolves the `react-native` field of a package
(`src/*.ts` for `react-native-svg`), while Node and Vitest resolve `main`/`module`
(`lib/commonjs`, `lib/module`). Patching only `lib/` leaves the app bundle unfixed.
diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md
index 0afbcc1cb3..d2508f0004 100644
--- a/docs/markdown-renderer.md
+++ b/docs/markdown-renderer.md
@@ -4,6 +4,10 @@ Use `MarkdownRenderer` for chat replies, file previews, plans, pull requests and
Native selection belongs to the library's text view. Keep an assistant message in one row so selection can cross paragraphs, headings and lists. Code blocks and tables use the library's native views and their selection behavior. Web uses browser selection over semantic HTML. Once its WASM parser is initialized, the library parses synchronously for the first commit of each mounted row; an empty interim row would corrupt virtualized measurements and move the reader. Chat owns paced reveal, the web render cap, message identity and list measurement. Native renders completed messages together. While streaming, it keeps closed blocks in one head view and updates a separate tail, then reunites the text on completion. See [native parsing cost](agent-stream-performance.md#native-parsing-cost) for the boundary and pacing constraints.
+## Block spacing
+
+Every block is separated by one gap, `density.blockGap`: 12 on compact layouts, 16 wider. Inside the library's text, every element style ends with that gap and the library drops it after the last block. Views stacked outside the library (Mermaid segments, streaming head and tail) use the same gap through `MarkdownBlockStack`. Do not give a block type its own margins; change the gap.
+
## Links and images
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.
diff --git a/packages/app/e2e/browser/plan-card-markdown.spec.ts b/packages/app/e2e/browser/plan-card-markdown.spec.ts
index 90bdb8b6db..7faae09dca 100644
--- a/packages/app/e2e/browser/plan-card-markdown.spec.ts
+++ b/packages/app/e2e/browser/plan-card-markdown.spec.ts
@@ -287,43 +287,22 @@ test("markdown links underline on hover", async ({ page }) => {
}
});
-// 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 PIXEL_PNG =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
+
+test("Markdown images show a zoom-in cursor", 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",
+ repoPrefix: "markdown-image-cursor-",
+ title: "Markdown image cursor",
+ featureValues: { mockAssistantResponse: `Before.\n\n\n\nAfter.` },
+ initialPrompt: "Show image",
});
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);
+ const image = page.getByTestId("assistant-message").getByRole("button", { name: "pixel" });
+ await expect(image).toHaveCSS("cursor", "zoom-in");
} 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/block-stack.tsx b/packages/app/src/components/markdown/block-stack.tsx
new file mode 100644
index 0000000000..e17421a84c
--- /dev/null
+++ b/packages/app/src/components/markdown/block-stack.tsx
@@ -0,0 +1,28 @@
+import type { ReactNode } from "react";
+import { View } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
+import { markdownCopyImageSourcesDataSet } from "@/assistant-selection-copy/markup";
+import { isWeb } from "@/constants/platform";
+import { density } from "@/styles/density";
+
+/** Stacks Markdown blocks rendered as separate views with the same gap the text puts between its blocks. */
+export function MarkdownBlockStack({
+ imageSources,
+ children,
+}: {
+ imageSources?: Record;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ stack: { gap: density.blockGap },
+});
diff --git a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx
index fed9afe3a3..9370bc90c0 100644
--- a/packages/app/src/components/markdown/fence/mermaid/host.web.tsx
+++ b/packages/app/src/components/markdown/fence/mermaid/host.web.tsx
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from "react";
-import { Pressable, View, type TextStyle, type ViewStyle } from "react-native";
+import { Pressable, View, type ViewStyle } from "react-native";
import { Code, Maximize2, Workflow } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
@@ -68,15 +68,6 @@ function MermaidFenceHostImpl({
],
[openFullscreen, showSourcePress, t],
);
- const sourceView = useMemo(() => {
- const { marginTop, marginBottom, marginVertical, ...sourceTextStyle } = textStyle;
- const margins: ViewStyle = {
- marginTop: marginTop ?? marginVertical,
- marginBottom: marginBottom ?? marginVertical,
- };
- const text: TextStyle = sourceTextStyle;
- return { container: [margins, sourceContainerStyle], text };
- }, [textStyle]);
const diagramStyle = useMemo(
() => [
getDiagramBoxStyle(textStyle),
@@ -90,19 +81,17 @@ function MermaidFenceHostImpl({
[visible],
);
const sourceVisible = !diagramVisible;
- const sourceContainer = showSource ? sourceView.container : sourceContainerStyle;
- const sourceTextStyle = showSource ? sourceView.text : textStyle;
const viewportStyle = diagramVisible ? diagramStyle : measuringStyle;
return (
<>
{sourceVisible ? (
-
+
{showSource && canShowDiagram ? (
;
- children: ReactElement;
-}) {
- return (
-
- {children}
-
- );
-}
diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts
index c8a7375737..670dcf6c1e 100644
--- a/packages/app/src/components/markdown/renderer.test.ts
+++ b/packages/app/src/components/markdown/renderer.test.ts
@@ -307,7 +307,7 @@ describe("shared Markdown theme colours", () => {
["dark", darkTheme],
["light", lightTheme],
] as const)("uses %s theme colours for tables and task labels", (_name, theme) => {
- const { markdownStyle: style } = createMarkdownStyles(theme);
+ const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
expect(style.table).toMatchObject({
color: theme.colors.foreground,
headerTextColor: theme.colors.foreground,
@@ -337,7 +337,7 @@ it.each([
] as const)(
"uses %s theme colours for quotes, admonitions, code, links and dividers",
(_name, theme) => {
- const { markdownStyle: style } = createMarkdownStyles(theme);
+ const { markdownStyle: style } = createMarkdownStyles(theme, { blockGap: 16 });
expect(style.blockquote).toMatchObject({
color: `${theme.colors.foreground}cc`,
backgroundColor: theme.colors.surface1,
@@ -364,7 +364,7 @@ it.each([
);
it.each([darkTheme, lightTheme])("supplies the theme selection colour to the renderer", (theme) => {
- expect(createMarkdownStyles(theme)).toHaveProperty(
+ expect(createMarkdownStyles(theme, { blockGap: 16 })).toHaveProperty(
"selectionColor",
theme.colors.terminal.selectionBackground,
);
@@ -431,7 +431,7 @@ it("preserves backslashes and decodes entities inside HTML code", () => {
it.each([false, true])(
"caps inline images to the paragraph line height (compact: %s)",
(compact) => {
- const { markdownStyle } = createMarkdownStyles(darkTheme, compact);
+ const { markdownStyle } = createMarkdownStyles(darkTheme, { compact, blockGap: 16 });
expect(markdownStyle.inlineImage?.size).toBe(markdownStyle.paragraph?.lineHeight);
},
);
diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx
index c4a832342f..4d6f042c2f 100644
--- a/packages/app/src/components/markdown/renderer.tsx
+++ b/packages/app/src/components/markdown/renderer.tsx
@@ -9,7 +9,8 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
import type { Theme } from "@/styles/theme";
import { isWeb } from "@/constants/platform";
-import { MarkdownRendererFrame } from "./frame";
+import { density, resolveDensity } from "@/styles/density";
+import { MarkdownBlockStack } from "./block-stack";
import { WEB_RENDERERS } from "./renderers";
import { createMarkdownStyles } from "./styles";
import { PENDING_MARKDOWN_IMAGE } from "./source";
@@ -21,8 +22,11 @@ import { MermaidFence, MermaidStreamingContext, splitMermaidFences } from "./fen
const ThemedMarkdown = withUnistyles(EnrichedMarkdownText);
const MD4C_FLAGS = { latexMath: false, hardSoftBreaks: true, admonitions: true };
-const regularStyle = (theme: Theme) => createMarkdownStyles(theme);
-const compactStyle = (theme: Theme) => createMarkdownStyles(theme, true);
+// Reading the breakpoint here makes Unistyles restyle the text when the layout crosses it.
+const markdownStyle = (compact: boolean) => (theme: Theme, rt: { breakpoint?: string }) =>
+ createMarkdownStyles(theme, { compact, blockGap: resolveDensity(rt, density.blockGap) });
+const regularStyle = markdownStyle(false);
+const compactStyle = markdownStyle(true);
const WEB_CONTAINER = { userSelect: "text" as const };
export interface MarkdownRendererProps extends MarkdownImageContext {
@@ -43,21 +47,20 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
);
if (!streaming || boundary === 0) return ;
return (
- <>
-
+
+
- >
+
);
});
const MarkdownPart = memo(function MarkdownPart({
text,
- continues = false,
compact = false,
streaming = false,
onLinkPress,
...imageContext
-}: MarkdownRendererProps & { continues?: boolean }) {
+}: MarkdownRendererProps) {
const environment = useAssistantFileLinkEnvironment();
const actions = useAssistantFileLinkActions();
const client =
@@ -86,35 +89,35 @@ const MarkdownPart = memo(function MarkdownPart({
isWeb ? [{ kind: "markdown", id: "0", source: markdown }] : splitMermaidFences(markdown),
[markdown],
);
- const content = (
+ return (
- {segments.map((segment) =>
- segment.kind === "mermaid" ? (
-
- ) : (
-
- ),
- )}
+
+ {segments.map((segment) =>
+ segment.kind === "mermaid" ? (
+
+ ) : (
+
+ ),
+ )}
+
{image ? : null}
);
- return {content};
});
diff --git a/packages/app/src/components/markdown/styles.ts b/packages/app/src/components/markdown/styles.ts
index 03d6c69b23..57ee40ff12 100644
--- a/packages/app/src/components/markdown/styles.ts
+++ b/packages/app/src/components/markdown/styles.ts
@@ -4,23 +4,23 @@ import { isWeb } from "@/constants/platform";
export function createMarkdownStyles(
theme: Theme,
- compact = false,
+ { compact = false, blockGap }: { compact?: boolean; blockGap: number },
): { markdownStyle: MarkdownStyle; selectionColor: string } {
const fontSize = compact ? theme.fontSize.base : theme.fontSize.content;
+ // Every block is followed by the same gap; the library drops it after the last block.
+ const block = { marginTop: 0, marginBottom: blockGap };
const paragraph = {
fontSize,
fontFamily: theme.fontFamily.ui,
lineHeight: Math.round(fontSize * 1.4),
color: theme.colors.foreground,
- marginTop: 0,
- marginBottom: theme.spacing[3],
+ ...block,
};
const heading = (scale: number) => ({
...paragraph,
fontSize: Math.round(fontSize * scale),
lineHeight: Math.round(fontSize * scale * 1.3),
fontWeight: "bold",
- marginTop: theme.spacing[3],
});
return {
selectionColor: theme.colors.terminal.selectionBackground,
@@ -93,8 +93,8 @@ export function createMarkdownStyles(
borderColor: theme.colors.foregroundMuted,
},
inlineImage: { size: paragraph.lineHeight },
- image: { height: 320, resizeMode: "contain", borderRadius: 8 },
- thematicBreak: { color: theme.colors.border, height: 1, marginTop: 12, marginBottom: 12 },
+ image: { ...block, height: 320, resizeMode: "contain", borderRadius: 8 },
+ thematicBreak: { ...block, color: theme.colors.border, height: 1 },
},
};
}
diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts
index 2383685d24..d29547ee88 100644
--- a/packages/app/src/styles/density.ts
+++ b/packages/app/src/styles/density.ts
@@ -26,15 +26,22 @@ export const density = {
tightRowHeight: byLayout(36, 28),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
+ /** The space between two blocks of rich text, such as Markdown paragraphs, code and images. */
+ blockGap: byLayout(12, 16),
};
+/** Resolves a density value for a prop, which Unistyles cannot resolve on its own. */
+export function resolveDensity(rt: { breakpoint?: string }, value: { xs: T; md: T }): T {
+ return isCompactBreakpoint(rt.breakpoint) ? value.xs : value.md;
+}
+
/** The width or height of a slot that holds an icon of `size`. */
export function densityIconBox(size: DensityIconSize) {
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
}
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
- return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
+ return resolveDensity(rt, densityIconBox(size));
}
/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */
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 535f61b6ef..8226f81bc2 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
@@ -1333,6 +1333,18 @@ index dd70713..ca0385a 100644
const label = language ? `Code block: ${language}` : 'Code block';
if (callbacks.onCodeBlockPress == null) {
return /*#__PURE__*/_jsx("pre", {
+@@ -214,7 +230,10 @@ function ImageRenderer({
+ src: url,
+ alt: alt,
+ title: title,
+- style: imgStyle,
++ style: {
++ ...imgStyle,
++ cursor: 'zoom-in'
++ },
+ role: "button",
+ tabIndex: 0,
+ "aria-label": alt,
diff --git a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map b/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map
index dc917c4..cf59150 100644
--- a/node_modules/react-native-enriched-markdown/lib/module/web/renderers/BlockRenderers.js.map
diff --git a/scripts/postinstall-patches.mjs b/scripts/postinstall-patches.mjs
index a571614c5a..5a2e62c5c6 100644
--- a/scripts/postinstall-patches.mjs
+++ b/scripts/postinstall-patches.mjs
@@ -60,30 +60,37 @@ if (!existsSync("patches") || installedPackages.length === 0) {
const patchFiles = readdirSync("patches").filter((file) => file.endsWith(".patch"));
-// Group patch files by the directory patch-package must run from.
-const patchFilesByCwd = new Map();
-for (const { patchPrefix, cwd = "." } of installedPackages) {
+const isWindows = process.platform === "win32";
+const cmd = isWindows ? "patch-package.cmd" : "patch-package";
+
+let failed = false;
+for (const [index, { nodeModulesPath, patchPrefix, cwd = "." }] of installedPackages.entries()) {
const files = patchFiles.filter((file) => file.startsWith(patchPrefix));
if (files.length === 0) {
continue;
}
- const group = patchFilesByCwd.get(cwd) ?? [];
- group.push(...files);
- patchFilesByCwd.set(cwd, group);
+ const outcome = applyPatches(cwd, files, index);
+ if (outcome === "applied") {
+ continue;
+ }
+ failed = true;
+ if (outcome === "unavailable") {
+ continue;
+ }
+ // patch-package cannot apply a patch that changed after an earlier version of it was
+ // applied. Without the package, the next install fetches it clean and applies the patch.
+ rmSync(nodeModulesPath, { recursive: true, force: true });
+ console.error(
+ `postinstall-patches: removed ${nodeModulesPath} because its patch did not apply. ` +
+ "Run npm install again to reinstall it with the current patch. " +
+ "If it fails again on the clean package, the patch is out of date and must be regenerated.",
+ );
}
-if (patchFilesByCwd.size === 0) {
- process.exit(0);
-}
-
-const isWindows = process.platform === "win32";
-const cmd = isWindows ? "patch-package.cmd" : "patch-package";
-
-let groupIndex = 0;
-for (const [cwd, files] of patchFilesByCwd) {
- groupIndex += 1;
- const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}-${groupIndex}`);
+process.exit(failed ? 1 : 0);
+function applyPatches(cwd, files, index) {
+ const tempPatchDir = join(".tmp", `postinstall-patches-${process.pid}-${index}`);
mkdirSync(tempPatchDir, { recursive: true });
for (const patchFile of files) {
copyFileSync(join("patches", patchFile), join(tempPatchDir, patchFile));
@@ -91,7 +98,7 @@ for (const [cwd, files] of patchFilesByCwd) {
let result;
try {
- result = spawnSync(cmd, ["--patch-dir", relative(cwd, tempPatchDir)], {
+ result = spawnSync(cmd, ["--patch-dir", relative(cwd, tempPatchDir), "--error-on-fail"], {
cwd,
shell: isWindows,
stdio: "inherit",
@@ -103,10 +110,7 @@ for (const [cwd, files] of patchFilesByCwd) {
if (result.error) {
console.error("postinstall-patches: patch-package failed to spawn:", result.error.message);
+ return "unavailable";
}
- if (result.status !== 0) {
- process.exit(result.status ?? 1);
- }
+ return result.status === 0 ? "applied" : "rejected";
}
-
-process.exit(0);
diff --git a/scripts/postinstall-patches.test.mjs b/scripts/postinstall-patches.test.mjs
new file mode 100644
index 0000000000..9e4d4c0722
--- /dev/null
+++ b/scripts/postinstall-patches.test.mjs
@@ -0,0 +1,99 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { test } from "node:test";
+
+const script = path.resolve("scripts", "postinstall-patches.mjs");
+const bin = path.resolve("node_modules", ".bin");
+const packageName = "react-native-draggable-flatlist";
+const patchFile = `${packageName}+4.0.3.patch`;
+
+function createCheckout() {
+ const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-"));
+ writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" }));
+ const packageDir = path.join(root, "node_modules", packageName);
+ mkdirSync(packageDir, { recursive: true });
+ writeFileSync(
+ path.join(packageDir, "package.json"),
+ JSON.stringify({ name: packageName, version: "4.0.3" }),
+ );
+ writeFileSync(path.join(packageDir, "index.js"), "export const value = 1;\n");
+ mkdirSync(path.join(root, "patches"));
+ return { root, packageDir };
+}
+
+function writePatch(root, from, to) {
+ const file = `node_modules/${packageName}/index.js`;
+ writeFileSync(
+ path.join(root, "patches", patchFile),
+ [
+ `diff --git a/${file} b/${file}`,
+ `--- a/${file}`,
+ `+++ b/${file}`,
+ "@@ -1 +1 @@",
+ `-export const value = ${from};`,
+ `+export const value = ${to};`,
+ "",
+ ].join("\n"),
+ );
+}
+
+function install(root, PATH = `${bin}${path.delimiter}${process.env.PATH}`) {
+ return spawnSync(process.execPath, [script], {
+ cwd: root,
+ encoding: "utf8",
+ env: { ...process.env, PATH },
+ });
+}
+
+test("a patch that changed since it was applied removes the package for a clean reinstall", () => {
+ const { root, packageDir } = createCheckout();
+ try {
+ writePatch(root, 1, 2);
+ assert.equal(install(root).status, 0);
+ assert.equal(
+ readFileSync(path.join(packageDir, "index.js"), "utf8"),
+ "export const value = 2;\n",
+ );
+
+ writePatch(root, 1, 3);
+ const result = install(root);
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /Run npm install again/);
+ assert.equal(existsSync(packageDir), false);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("an already applied patch leaves the package in place", () => {
+ const { root, packageDir } = createCheckout();
+ try {
+ writePatch(root, 1, 2);
+ assert.equal(install(root).status, 0);
+ assert.equal(install(root).status, 0);
+ assert.equal(
+ readFileSync(path.join(packageDir, "index.js"), "utf8"),
+ "export const value = 2;\n",
+ );
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("a missing patch-package fails the install and leaves the package in place", () => {
+ const { root, packageDir } = createCheckout();
+ try {
+ writePatch(root, 1, 2);
+ const result = install(root, root);
+
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /failed to spawn/);
+ assert.equal(existsSync(packageDir), true);
+ } finally {
+ rmSync(root, { recursive: true, force: true });
+ }
+});
From 60fa12c4de17be6ec87be6ed3bce980077b697f2 Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 11:57:41 +0200
Subject: [PATCH 032/354] Report disconnected hosts in new agent drafts (#7303)
* fix(app): report offline hosts in new agent drafts
A disconnected client still exists, and pending form resolution and retained loading catalogs outlive the connection. Use host connection state for draft readiness and model loading while keeping known models available for reconnect.
Co-authored-by: Ivan Malison
* test(app): confirm draft recovery on a fresh host connection
---------
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison
---
.../app/e2e/browser/offline-draft.spec.ts | 32 +++++++++++++++++++
.../src/composer/draft/workspace-tab-core.ts | 6 ++--
.../src/composer/draft/workspace-tab.test.ts | 16 +++++++++-
.../app/src/composer/draft/workspace-tab.tsx | 5 +--
.../app/src/hooks/use-agent-form-state.ts | 10 ++++--
.../provider-selection.test.ts | 21 ++++++++++--
.../provider-selection/provider-selection.ts | 16 ++++++----
7 files changed, 88 insertions(+), 18 deletions(-)
create mode 100644 packages/app/e2e/browser/offline-draft.spec.ts
diff --git a/packages/app/e2e/browser/offline-draft.spec.ts b/packages/app/e2e/browser/offline-draft.spec.ts
new file mode 100644
index 0000000000..36ae726457
--- /dev/null
+++ b/packages/app/e2e/browser/offline-draft.spec.ts
@@ -0,0 +1,32 @@
+import { expect, test } from "../support/fixtures";
+import { expectComposerModel } from "../support/helpers/agent-profiles";
+import { installDaemonWebSocketGate } from "../support/helpers/daemon-websocket-gate";
+import { createAgentTabFromMenu } from "../support/helpers/workspace-tabs";
+import { submitMessage } from "../support/helpers/composer";
+
+async function restoreHostConnection(gate: Awaited>) {
+ gate.holdNextServerMessage("status");
+ gate.restore();
+ await gate.waitForHeldServerMessage("status");
+ gate.releaseHeldServerMessage("status");
+}
+
+test("an offline workspace draft reports the disconnected host instead of loading models", async ({
+ page,
+ withWorkspace,
+}) => {
+ const gate = await installDaemonWebSocketGate(page);
+ const workspace = await withWorkspace();
+ await workspace.navigateTo();
+ await gate.drop();
+ await gate.waitForBlockedConnection();
+ await createAgentTabFromMenu(page);
+ await submitMessage(page, "hello");
+ await expect(page.getByRole("alert").filter({ hasText: "Host is not connected" })).toHaveText(
+ "Host is not connected",
+ );
+ await expect(page.getByText("Loading...", { exact: true })).toHaveCount(0);
+ await restoreHostConnection(gate);
+ await expectComposerModel(page, "Ten second stream");
+ await expect(page.getByRole("textbox", { name: "Message agent..." })).toHaveValue("hello");
+});
diff --git a/packages/app/src/composer/draft/workspace-tab-core.ts b/packages/app/src/composer/draft/workspace-tab-core.ts
index 30411aeb00..26bf548064 100644
--- a/packages/app/src/composer/draft/workspace-tab-core.ts
+++ b/packages/app/src/composer/draft/workspace-tab-core.ts
@@ -24,7 +24,7 @@ export function validateDraftSubmission(input: {
};
autoSubmitConfig: WorkspaceDraftAutoSubmitConfig | null;
workspaceDirectory: string | null;
- hasClient: boolean;
+ isConnected: boolean;
}): string | null {
const {
text,
@@ -32,7 +32,7 @@ export function validateDraftSubmission(input: {
composerState,
autoSubmitConfig,
workspaceDirectory,
- hasClient,
+ isConnected,
} = input;
const readiness = resolveSubmissionReadiness({
text,
@@ -46,7 +46,7 @@ export function validateDraftSubmission(input: {
},
autoSubmitConfig,
workspaceDirectory,
- hasClient,
+ isConnected,
});
return readiness.ok ? null : (readiness.reason ?? null);
}
diff --git a/packages/app/src/composer/draft/workspace-tab.test.ts b/packages/app/src/composer/draft/workspace-tab.test.ts
index 5952083a62..1d7e2d4b2c 100644
--- a/packages/app/src/composer/draft/workspace-tab.test.ts
+++ b/packages/app/src/composer/draft/workspace-tab.test.ts
@@ -17,12 +17,26 @@ function validate(overrides = {}) {
composerState: baseComposerState,
autoSubmitConfig: null,
workspaceDirectory: "/tmp/project",
- hasClient: true,
+ isConnected: true,
...overrides,
});
}
describe("workspace draft agent model validation", () => {
+ test("reports an offline host before an absent catalog or pending model defaults", () => {
+ expect(
+ validate({
+ isConnected: false,
+ composerState: {
+ ...baseComposerState,
+ providerDefinitions: [],
+ selectedProvider: null,
+ isModelLoading: true,
+ },
+ }),
+ ).toBe("Host is not connected");
+ });
+
test("allows a ready provider with no models to submit without a selected model", () => {
expect(validate({})).toBeNull();
});
diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx
index b54b344a36..27ca8b8af8 100644
--- a/packages/app/src/composer/draft/workspace-tab.tsx
+++ b/packages/app/src/composer/draft/workspace-tab.tsx
@@ -16,7 +16,7 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { useDraftAgentCreateFlow, type DraftCreateAttempt } from "@/composer/draft/create-flow";
import { resolveTurnPresentation, TURN_LIVENESS_IDLE } from "@/timeline/turn-liveness";
-import { useHostRuntimeClient } from "@/runtime/host-runtime";
+import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -305,6 +305,7 @@ export function WorkspaceDraftAgentTab({
}: WorkspaceDraftAgentTabProps) {
const { t } = useTranslation();
const client = useHostRuntimeClient(serverId);
+ const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
workspaceDirectory: w.workspaceDirectory,
id: w.id,
@@ -445,7 +446,7 @@ export function WorkspaceDraftAgentTab({
composerState,
autoSubmitConfig,
workspaceDirectory: draftWorkingDirectory,
- hasClient: Boolean(client),
+ isConnected,
});
},
onBeforeSubmit: async () => {
diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts
index 26908bd6a0..1522ce082e 100644
--- a/packages/app/src/hooks/use-agent-form-state.ts
+++ b/packages/app/src/hooks/use-agent-form-state.ts
@@ -36,6 +36,7 @@ import {
type FormState,
type ProviderModelsByProvider,
} from "@/provider-selection/resolve-agent-form";
+import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import type { MaterializedAgentProfile } from "@/agent-profiles";
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
@@ -186,6 +187,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
refetchIfStale: refetchSnapshotIfStale,
} = useProvidersSnapshot(serverId, { cwd: workingDir });
+ const isConnected = useHostRuntimeIsConnected(serverId ?? "");
+
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(
() => buildProviderDefinitions(snapshotEntries),
@@ -220,8 +223,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
[snapshotEntries],
);
const snapshotModelSelectorProviders = useMemo(
- () => buildSelectableProviderSelectorProviders(snapshotEntries),
- [snapshotEntries],
+ () => buildSelectableProviderSelectorProviders(snapshotEntries, isConnected),
+ [snapshotEntries, isConnected],
);
const snapshotSelectedEntry = useMemo(
() =>
@@ -247,7 +250,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isModelSelectionLoading =
- resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading;
+ isConnected &&
+ (resolution.status === "pending" || snapshotIsLoading || selectedProviderIsLoading);
const isAllModelsLoading = isModelSelectionLoading;
useEffect(() => {
diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts
index 6c13ad3d83..1c04c0b154 100644
--- a/packages/app/src/provider-selection/provider-selection.test.ts
+++ b/packages/app/src/provider-selection/provider-selection.test.ts
@@ -15,6 +15,21 @@ import {
} from "./provider-selection";
describe("combined model selector data", () => {
+ it("shows a disconnected error for unfinished catalogs while keeping known models", () => {
+ const providers = buildSelectableProviderSelectorProviders(
+ [
+ snapshotEntry({ provider: "waiting", status: "loading", models: [] }),
+ snapshotEntry({ provider: "codex", status: "ready", models: [codexModel] }),
+ ],
+ false,
+ );
+ expect(providers[0].modelSelection).toEqual({
+ kind: "error",
+ message: "Host is not connected",
+ });
+ expect(providers[1].modelSelection.kind).toBe("models");
+ });
+
const codexModel: AgentModelDefinition = {
provider: "codex",
id: "gpt-5.4",
@@ -367,7 +382,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
- hasClient: true,
+ isConnected: true,
}),
).toEqual({
ok: false,
@@ -387,7 +402,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
- hasClient: true,
+ isConnected: true,
}),
).toEqual({ ok: true });
});
@@ -426,7 +441,7 @@ describe("combined model selector data", () => {
},
autoSubmitConfig: null,
workspaceDirectory: "/repo",
- hasClient: true,
+ isConnected: true,
}),
).toEqual({ ok: false, reason: "初始 prompt 必填" });
} finally {
diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts
index f66a1b1476..e0313c6623 100644
--- a/packages/app/src/provider-selection/provider-selection.ts
+++ b/packages/app/src/provider-selection/provider-selection.ts
@@ -103,6 +103,7 @@ function buildModelSelection(
function buildEntryModelSelection(
entry: ProviderSnapshotEntry,
label: string,
+ isConnected: boolean,
): ProviderModelSelection {
if ((entry.models?.length ?? 0) > 0) {
return buildModelSelection(entry.provider, label, entry.models ?? null);
@@ -111,7 +112,9 @@ function buildEntryModelSelection(
return buildModelSelection(entry.provider, label, entry.models ?? null);
}
if (entry.status === "loading") {
- return { kind: "loading" };
+ return isConnected
+ ? { kind: "loading" }
+ : { kind: "error", message: i18n.t("providerSelection.readiness.hostDisconnected") };
}
return {
kind: "error",
@@ -142,6 +145,7 @@ export function buildProviderSelectorProviders(input: {
export function buildSelectableProviderSelectorProviders(
entries: ProviderSnapshotEntry[] | undefined,
+ isConnected = true,
): ProviderSelectorProvider[] {
return (entries ?? [])
.filter((entry) => entry.enabled)
@@ -150,7 +154,7 @@ export function buildSelectableProviderSelectorProviders(
return {
id: entry.provider,
label,
- modelSelection: buildEntryModelSelection(entry, label),
+ modelSelection: buildEntryModelSelection(entry, label, isConnected),
};
});
}
@@ -345,11 +349,14 @@ export function resolveSubmissionReadiness(input: {
};
autoSubmitConfig: { provider: string; model: string | null } | null;
workspaceDirectory: string | null;
- hasClient: boolean;
+ isConnected: boolean;
}): ProviderSelectionReadiness {
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
return { ok: false, reason: i18n.t("providerSelection.readiness.initialPromptRequired") };
}
+ if (!input.isConnected) {
+ return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") };
+ }
if (input.providerCount === 0) {
return { ok: false, reason: i18n.t("providerSelection.readiness.noProviders") };
}
@@ -366,8 +373,5 @@ export function resolveSubmissionReadiness(input: {
if (!input.workspaceDirectory) {
return { ok: false, reason: i18n.t("providerSelection.readiness.workspaceDirectoryNotFound") };
}
- if (!input.hasClient) {
- return { ok: false, reason: i18n.t("providerSelection.readiness.hostDisconnected") };
- }
return { ok: true };
}
From 38a8e607806d46dcdf1f885e0ff439f667e74c7f Mon Sep 17 00:00:00 2001
From: Mohamed Boudra
Date: Sun, 18 Oct 2026 12:43:26 +0200
Subject: [PATCH 033/354] docs(changelog): 0.12.0-beta.3
---
CHANGELOG.md | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34d2871886..12b4568cb0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## 0.12.0-beta.2 - 2026-10-17
+## 0.12.0-beta.3 - 2026-10-18
Background workspaces, a new Markdown renderer with selection across paragraphs on phones, a reworked phone layout, and close to 100 fixes.
@@ -27,6 +27,9 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Changed phones to open files and subagents in a closable sheet, with Open as tab for subagents ([#6827](https://github.com/getpaseo/paseo/pull/6827), [#6885](https://github.com/getpaseo/paseo/pull/6885), [#6958](https://github.com/getpaseo/paseo/pull/6958))
- Changed diff comments on phones to open in a sheet instead of an editor inside the code ([#6455](https://github.com/getpaseo/paseo/pull/6455))
- Changed Settings on phones to large tiles grouped into App and Host ([#7129](https://github.com/getpaseo/paseo/pull/7129))
+- Changed Command Center to rank workspaces and agents by match and then recency, and to show timestamps, project icons, and highlighted matches ([#7277](https://github.com/getpaseo/paseo/pull/7277))
+- Added each Paseo subagent's model, thinking, mode, and context under its title in the subagents list ([#7290](https://github.com/getpaseo/paseo/pull/7290))
+- Changed Paseo tool calls in chats to lead with the action and its context, such as Send message · the first line of the prompt ([#7283](https://github.com/getpaseo/paseo/pull/7283))
- Changed the New workspace form on phones to full-width fields, with Import session in the header ([#7239](https://github.com/getpaseo/paseo/pull/7239))
- Reduced rendering work in long chats while a reply streams ([#7052](https://github.com/getpaseo/paseo/pull/7052) by [@colonelpanic8](https://github.com/colonelpanic8))
- Made reopening the sidebar about 45% faster ([#7157](https://github.com/getpaseo/paseo/pull/7157))
@@ -41,6 +44,8 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed the chat outline not jumping to prompts rendered by a timeline plugin ([#6690](https://github.com/getpaseo/paseo/pull/6690) by [@lukeRRD](https://github.com/lukeRRD))
- Fixed Escape stopping the running agent instead of first closing an open plugin preview ([#7110](https://github.com/getpaseo/paseo/pull/7110) by [@RRD-dev](https://github.com/RRD-dev), [@luoph](https://github.com/luoph))
- Fixed `paseo plugin reload` failing with a 60-second timeout while the reload was still running ([#6306](https://github.com/getpaseo/paseo/pull/6306) by [@Mtuchel](https://github.com/Mtuchel))
+- Fixed follow-ups sent while a plugin ACP agent runs failing with "Provider does not support prompt.steer" ([#7270](https://github.com/getpaseo/paseo/pull/7270) by [@simonepri](https://github.com/simonepri))
+- Fixed Find showing no matches for prompt text drawn by a timeline plugin ([#7253](https://github.com/getpaseo/paseo/pull/7253) by [@a-grasso](https://github.com/a-grasso))
### Fixed
@@ -54,7 +59,11 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed an incomplete font name in Appearance breaking the app's styling ([#6695](https://github.com/getpaseo/paseo/pull/6695))
- Fixed the Linux desktop window never appearing when the hidden window renders no frames ([#6726](https://github.com/getpaseo/paseo/pull/6726) by [@EhEo](https://github.com/EhEo))
- Fixed opening another agent or workspace stalling while a different agent on the same host is streaming ([#7074](https://github.com/getpaseo/paseo/pull/7074) by [@colonelpanic8](https://github.com/colonelpanic8))
+- Fixed the daemon running out of memory when several directory searches overlap on a large folder tree ([#7265](https://github.com/getpaseo/paseo/pull/7265))
+- Fixed dragging the Explorer edge stuttering with several panes open ([#7284](https://github.com/getpaseo/paseo/pull/7284) by [@colonelpanic8](https://github.com/colonelpanic8))
- Fixed agent creation showing an error and a retry draft when the provider rejected the first prompt ([#6379](https://github.com/getpaseo/paseo/pull/6379))
+- Fixed new agent drafts staying on "Loading…" when the host is disconnected; they now say "Host is not connected" and keep your draft ([#7303](https://github.com/getpaseo/paseo/pull/7303) by [@colonelpanic8](https://github.com/colonelpanic8))
+- Fixed reloading a chat during a streaming reply showing a frozen copy above the live one and dropping the ending ([#7256](https://github.com/getpaseo/paseo/pull/7256))
- Fixed question cards spinning forever when an answer fails to send; they now show the error and keep your answers ([#7098](https://github.com/getpaseo/paseo/pull/7098) by [@xzthemeofsss](https://github.com/xzthemeofsss))
- Fixed parent agents missing the finish notification when a child finished its first turn very quickly ([#6008](https://github.com/getpaseo/paseo/pull/6008) by [@ahmet-izgi](https://github.com/ahmet-izgi))
- Fixed parent agents being told a child finished when its task was stopped or reloaded ([#6660](https://github.com/getpaseo/paseo/pull/6660) by [@Mar-garet](https://github.com/Mar-garet))
@@ -68,7 +77,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed rewinding a Claude conversation failing with "Session not found" when the provider sets `CLAUDE_CONFIG_DIR` ([#6325](https://github.com/getpaseo/paseo/pull/6325))
- Fixed changing a running Claude agent's thinking level and then its mode killing its running command ([#7179](https://github.com/getpaseo/paseo/pull/7179))
- Fixed Claude models that need a recent Claude Code being hidden when the provider command is a wrapper script ([#7209](https://github.com/getpaseo/paseo/pull/7209) by [@SulthanZahran1](https://github.com/SulthanZahran1))
-- Fixed background Claude subagent cards staying empty while the subagent works ([#6242](https://github.com/getpaseo/paseo/pull/6242))
+- Fixed background Claude subagent cards staying empty while the subagent works, including after the parent finishes ([#6242](https://github.com/getpaseo/paseo/pull/6242), [#7266](https://github.com/getpaseo/paseo/pull/7266))
- Fixed Copilot staying in Plan mode and refusing edits after you select Allow All ([#7234](https://github.com/getpaseo/paseo/pull/7234))
- Fixed imported Claude Code sessions taking their first prompt as the name instead of their `/rename` title ([#6930](https://github.com/getpaseo/paseo/pull/6930) by [@platanus-kr](https://github.com/platanus-kr))
- Fixed a wrongly shaped answer to a Claude question reporting success and discarding the question ([#6936](https://github.com/getpaseo/paseo/pull/6936) by [@thomasvan](https://github.com/thomasvan))
@@ -91,6 +100,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed workspace service URLs returning 502 when the service listens only on IPv6 localhost ([#6753](https://github.com/getpaseo/paseo/pull/6753) by [@marcelocastrobarbosa](https://github.com/marcelocastrobarbosa))
- Fixed terminals that could not be closed when their command was missing or exited immediately ([#6792](https://github.com/getpaseo/paseo/pull/6792) by [@KiraPC](https://github.com/KiraPC))
- Fixed CJK terminal text gaining spaces between characters after returning to a workspace ([#6452](https://github.com/getpaseo/paseo/pull/6452) by [@jasonhnd](https://github.com/jasonhnd), [@liujin0506](https://github.com/liujin0506), [@seob717](https://github.com/seob717))
+- Fixed copying Korean text from the terminal on iOS and Android adding spaces between characters or dropping the first one ([#7254](https://github.com/getpaseo/paseo/pull/7254), [#7260](https://github.com/getpaseo/paseo/pull/7260) by [@helloimdevman](https://github.com/helloimdevman))
- Fixed the self-hosted web app failing to connect through a reverse proxy on port 80 or 443 ([#6638](https://github.com/getpaseo/paseo/pull/6638) by [@kiawin](https://github.com/kiawin))
- Fixed assistant file links with spaces or non-ASCII characters failing with ENOENT ([#6520](https://github.com/getpaseo/paseo/pull/6520) by [@apple-ouyang](https://github.com/apple-ouyang), [@sunshinehomeyu-blip](https://github.com/sunshinehomeyu-blip))
- Fixed local images in agent replies failing to load when their path contains spaces or non-ASCII characters ([#6877](https://github.com/getpaseo/paseo/pull/6877))
@@ -102,7 +112,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed sidebar rows, footer buttons, and Settings items showing no feedback when tapped on touch devices ([#7150](https://github.com/getpaseo/paseo/pull/7150))
- Fixed home screen actions being cut off on short phone screens ([#7167](https://github.com/getpaseo/paseo/pull/7167))
- Fixed Android dictation stopping with "Dictation was interrupted by another audio source" when another app plays audio ([#7200](https://github.com/getpaseo/paseo/pull/7200))
-- Fixed Page Up and Page Down not scrolling the chat after clicking a message ([#7033](https://github.com/getpaseo/paseo/pull/7033) by [@panta82](https://github.com/panta82))
+- Fixed Page Up and Page Down not scrolling the chat after clicking a message or closing Find ([#7033](https://github.com/getpaseo/paseo/pull/7033), [#7263](https://github.com/getpaseo/paseo/pull/7263) by [@panta82](https://github.com/panta82))
- Fixed Add Project keyboard selection moving to directories scrolled out of view ([#7081](https://github.com/getpaseo/paseo/pull/7081) by [@superheher](https://github.com/superheher))
- Fixed Enter replacing an email address in the composer with a file mention ([#6471](https://github.com/getpaseo/paseo/pull/6471) by [@jasonhnd](https://github.com/jasonhnd))
- Fixed letter shortcuts not running with non-Latin keyboard layouts such as Russian ([#6755](https://github.com/getpaseo/paseo/pull/6755))
@@ -147,6 +157,7 @@ Background workspaces, a new Markdown renderer with selection across paragraphs
- Fixed Google Play listing Android devices without a camera as incompatible ([#6339](https://github.com/getpaseo/paseo/pull/6339))
- Fixed `nix build .#desktop` running out of memory on Macs with less than 15 GB of RAM ([#6537](https://github.com/getpaseo/paseo/pull/6537) by [@truelecter](https://github.com/truelecter))
- Fixed usage providers changing places after Refresh all ([#6947](https://github.com/getpaseo/paseo/pull/6947) by [@sleroq](https://github.com/sleroq))
+- Fixed usage cards losing their last values and showing "Updated" when a refresh fails ([#7246](https://github.com/getpaseo/paseo/pull/7246))
- Fixed the OpenCode Go usage icon being nearly invisible on dark themes ([#6362](https://github.com/getpaseo/paseo/pull/6362) by [@az33zy](https://github.com/az33zy))
## 0.11.4 - 2026-10-15
From cc748dfe12590675eb9803d52a0ffddbe964a905 Mon Sep 17 00:00:00 2001
From: Mohamed Boudra
Date: Sun, 18 Oct 2026 12:47:46 +0200
Subject: [PATCH 034/354] chore(release): cut 0.12.0-beta.3
---
package-lock.json | 56 ++++++++++++------------
package.json | 2 +-
packages/app/package.json | 2 +-
packages/cli/package.json | 8 ++--
packages/client/package.json | 6 +--
packages/desktop/package.json | 2 +-
packages/expo-two-way-audio/package.json | 2 +-
packages/highlight/package.json | 2 +-
packages/plugin/package.json | 10 ++---
packages/protocol/package.json | 2 +-
packages/relay/package.json | 2 +-
packages/server/package.json | 12 ++---
packages/website/package.json | 2 +-
plugins/package.json | 2 +-
14 files changed, 55 insertions(+), 55 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 150a1553e8..aba978195d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "paseo",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -36349,7 +36349,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -37469,12 +37469,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/client": "0.12.0-beta.2",
- "@getpaseo/protocol": "0.12.0-beta.2",
- "@getpaseo/server": "0.12.0-beta.2",
+ "@getpaseo/client": "0.12.0-beta.3",
+ "@getpaseo/protocol": "0.12.0-beta.3",
+ "@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -37721,10 +37721,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
- "@getpaseo/protocol": "0.12.0-beta.2",
- "@getpaseo/relay": "0.12.0-beta.2",
+ "@getpaseo/protocol": "0.12.0-beta.3",
+ "@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -37735,7 +37735,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -37979,7 +37979,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -38875,7 +38875,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -38904,14 +38904,14 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
- "@getpaseo/client": "0.12.0-beta.2",
- "@getpaseo/protocol": "0.12.0-beta.2",
+ "@getpaseo/client": "0.12.0-beta.3",
+ "@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -38921,8 +38921,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
- "@getpaseo/client": "0.12.0-beta.2",
- "@getpaseo/protocol": "0.12.0-beta.2",
+ "@getpaseo/client": "0.12.0-beta.3",
+ "@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -38944,7 +38944,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -38971,7 +38971,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39189,16 +39189,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
- "@getpaseo/client": "0.12.0-beta.2",
- "@getpaseo/highlight": "0.12.0-beta.2",
- "@getpaseo/plugin": "0.12.0-beta.2",
- "@getpaseo/protocol": "0.12.0-beta.2",
- "@getpaseo/relay": "0.12.0-beta.2",
+ "@getpaseo/client": "0.12.0-beta.3",
+ "@getpaseo/highlight": "0.12.0-beta.3",
+ "@getpaseo/plugin": "0.12.0-beta.3",
+ "@getpaseo/protocol": "0.12.0-beta.3",
+ "@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -40119,7 +40119,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -41727,7 +41727,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
diff --git a/package.json b/package.json
index 23a2b676f5..a4be513632 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "paseo",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
diff --git a/packages/app/package.json b/packages/app/package.json
index 43192eedad..ac427837c3 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"private": true,
"main": "index.ts",
"scripts": {
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 51201254e9..8c7d796963 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
- "@getpaseo/client": "0.12.0-beta.2",
- "@getpaseo/protocol": "0.12.0-beta.2",
- "@getpaseo/server": "0.12.0-beta.2",
+ "@getpaseo/client": "0.12.0-beta.3",
+ "@getpaseo/protocol": "0.12.0-beta.3",
+ "@getpaseo/server": "0.12.0-beta.3",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
diff --git a/packages/client/package.json b/packages/client/package.json
index 00ca259e85..a042d0cd8c 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -39,8 +39,8 @@
"test": "vitest run"
},
"dependencies": {
- "@getpaseo/protocol": "0.12.0-beta.2",
- "@getpaseo/relay": "0.12.0-beta.2",
+ "@getpaseo/protocol": "0.12.0-beta.3",
+ "@getpaseo/relay": "0.12.0-beta.3",
"zod": "^4.4.3"
},
"devDependencies": {
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index edf8fb913b..60ea32fe5c 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
diff --git a/packages/expo-two-way-audio/package.json b/packages/expo-two-way-audio/package.json
index e71431723d..ca03114da2 100644
--- a/packages/expo-two-way-audio/package.json
+++ b/packages/expo-two-way-audio/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
diff --git a/packages/highlight/package.json b/packages/highlight/package.json
index bfbfab8cb5..9da2494d13 100644
--- a/packages/highlight/package.json
+++ b/packages/highlight/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"files": [
"dist",
"!dist/**/*.map",
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index b189218837..e04c383488 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -70,8 +70,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
- "@getpaseo/client": "0.12.0-beta.2",
- "@getpaseo/protocol": "0.12.0-beta.2",
+ "@getpaseo/client": "0.12.0-beta.3",
+ "@getpaseo/protocol": "0.12.0-beta.3",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.1.0",
@@ -81,8 +81,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
- "@getpaseo/client": "0.12.0-beta.2",
- "@getpaseo/protocol": "0.12.0-beta.2",
+ "@getpaseo/client": "0.12.0-beta.3",
+ "@getpaseo/protocol": "0.12.0-beta.3",
"react": "~19.1.0",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
diff --git a/packages/protocol/package.json b/packages/protocol/package.json
index 217f13eb74..89bb95b50a 100644
--- a/packages/protocol/package.json
+++ b/packages/protocol/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
diff --git a/packages/relay/package.json b/packages/relay/package.json
index 04372d00c1..43f328c5cd 100644
--- a/packages/relay/package.json
+++ b/packages/relay/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
diff --git a/packages/server/package.json b/packages/server/package.json
index 49549cdff3..41457e6564 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
- "@getpaseo/client": "0.12.0-beta.2",
- "@getpaseo/highlight": "0.12.0-beta.2",
- "@getpaseo/plugin": "0.12.0-beta.2",
- "@getpaseo/protocol": "0.12.0-beta.2",
- "@getpaseo/relay": "0.12.0-beta.2",
+ "@getpaseo/client": "0.12.0-beta.3",
+ "@getpaseo/highlight": "0.12.0-beta.3",
+ "@getpaseo/plugin": "0.12.0-beta.3",
+ "@getpaseo/protocol": "0.12.0-beta.3",
+ "@getpaseo/relay": "0.12.0-beta.3",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
diff --git a/packages/website/package.json b/packages/website/package.json
index 7df1a84aa7..c6035507c6 100644
--- a/packages/website/package.json
+++ b/packages/website/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"private": true,
"type": "module",
"scripts": {
diff --git a/plugins/package.json b/plugins/package.json
index 6bd8965d25..79166d816c 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
- "version": "0.12.0-beta.2",
+ "version": "0.12.0-beta.3",
"private": true,
"type": "module",
"scripts": {
From 9ed4f1069b2cde963f7c4424e5b85fad648f4d58 Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 12:57:54 +0200
Subject: [PATCH 035/354] fix(app): create project workspaces on the active
host (#7308)
Grouped sidebar projects chose the first eligible host even while a workspace on another host was active. Prefer that active host when online and eligible, keeping the existing fallback for other placements.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Michael Whitman
---
.../browser/sidebar-project-grouping.spec.ts | 27 +++++++++++++
.../e2e/support/helpers/project-grouping.ts | 16 ++++++++
.../src/components/sidebar-workspace-list.tsx | 13 ++++++-
.../utils/sidebar-project-row-model.test.ts | 39 +++++++++++++++++++
.../src/utils/sidebar-project-row-model.ts | 17 ++++++--
5 files changed, 107 insertions(+), 5 deletions(-)
diff --git a/packages/app/e2e/browser/sidebar-project-grouping.spec.ts b/packages/app/e2e/browser/sidebar-project-grouping.spec.ts
index bd9b934aad..6243d52fdf 100644
--- a/packages/app/e2e/browser/sidebar-project-grouping.spec.ts
+++ b/packages/app/e2e/browser/sidebar-project-grouping.spec.ts
@@ -9,6 +9,8 @@ import {
expectProjectWorkspaceCountForHost,
expectSeparateProjects,
openGroupedProjectSettings,
+ openGroupedProjectWorkspace,
+ expectWorkspaceHost,
openProjectsForSettingsHost,
openProjectDirectory,
openProjectDirectoryWithHosts,
@@ -418,4 +420,29 @@ test.describe("Sidebar project grouping", () => {
count: 2,
});
});
+ test("creates grouped project workspaces on the active workspace host", async ({
+ page,
+ crossHostProject,
+ }) => {
+ await openScenario(page, crossHostProject);
+ await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Secondary workspace");
+ await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
+ await expectWorkspaceHost(page, SECONDARY_HOST_LABEL);
+ await createWorkspaceWithoutAgent(page);
+ await expectProjectWorkspaceCountForHost(page, {
+ projectName: GROUPED_PROJECT_NAME,
+ hostName: SECONDARY_HOST_LABEL,
+ count: 2,
+ });
+
+ await openGroupedProjectWorkspace(page, GROUPED_PROJECT_NAME, "Primary workspace");
+ await beginWorkspaceFromProject(page, GROUPED_PROJECT_NAME);
+ await expectWorkspaceHost(page, PRIMARY_HOST_LABEL);
+ await createWorkspaceWithoutAgent(page);
+ await expectProjectWorkspaceCountForHost(page, {
+ projectName: GROUPED_PROJECT_NAME,
+ hostName: PRIMARY_HOST_LABEL,
+ count: 2,
+ });
+ });
});
diff --git a/packages/app/e2e/support/helpers/project-grouping.ts b/packages/app/e2e/support/helpers/project-grouping.ts
index fc56d4bf9a..49c2f1c9b4 100644
--- a/packages/app/e2e/support/helpers/project-grouping.ts
+++ b/packages/app/e2e/support/helpers/project-grouping.ts
@@ -134,3 +134,19 @@ export async function expectProjectSettingsName(page: Page, projectName: string)
timeout: PROJECT_VISIBILITY_TIMEOUT,
});
}
+
+export async function openGroupedProjectWorkspace(
+ page: Page,
+ projectName: string,
+ workspaceName: string,
+): Promise {
+ await projectGroup(page, projectName)
+ .getByRole("button")
+ .filter({ hasText: workspaceName })
+ .click();
+ await expect(page).toHaveURL(/\/workspace\//);
+}
+
+export async function expectWorkspaceHost(page: Page, hostName: string): Promise {
+ await expect(page.getByRole("button", { name: "Host", exact: true })).toContainText(hostName);
+}
diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx
index c750121368..9ce91b5a10 100644
--- a/packages/app/src/components/sidebar-workspace-list.tsx
+++ b/packages/app/src/components/sidebar-workspace-list.tsx
@@ -41,7 +41,11 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
-import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
+import {
+ getHostRuntimeStore,
+ useHosts,
+ useHostRuntimeConnectionStatus,
+} from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -1596,14 +1600,18 @@ function ProjectBlock({
canToggle: canToggleWorkspaces,
toggleExpanded: toggleWorkspacesExpanded,
} = useLimitedSidebarGroup(project.workspaces);
+ const activeServerId = activeWorkspaceSelection?.serverId ?? null;
+ const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
+ const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
const rowModel = useMemo(
() =>
buildSidebarProjectRowModel({
project,
collapsed,
supportsMultiplicityByServerId,
+ preferredServerId,
}),
- [collapsed, project, supportsMultiplicityByServerId],
+ [collapsed, preferredServerId, project, supportsMultiplicityByServerId],
);
// Collapsed rows hide their workspace rows, so the project row carries the most urgent
@@ -1848,6 +1856,7 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
+ previous.activeWorkspaceSelection?.serverId === next.activeWorkspaceSelection?.serverId &&
areProjectBlockSelectionsEqual(previous, next)
);
}
diff --git a/packages/app/src/utils/sidebar-project-row-model.test.ts b/packages/app/src/utils/sidebar-project-row-model.test.ts
index de23319c97..a2978a669c 100644
--- a/packages/app/src/utils/sidebar-project-row-model.test.ts
+++ b/packages/app/src/utils/sidebar-project-row-model.test.ts
@@ -65,6 +65,45 @@ function project(overrides: ProjectOverrides = {}): SidebarProjectEntry {
}
describe("buildSidebarProjectRowModel", () => {
+ it("prefers the active host when the grouped project can create there", () => {
+ const result = buildSidebarProjectRowModel({
+ project: project({
+ hosts: [
+ { serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
+ { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "supported" },
+ ],
+ }),
+ collapsed: false,
+ preferredServerId: "host-b",
+ });
+
+ expect(result.trailingAction).toEqual({
+ kind: "new_workspace",
+ target: { serverId: "host-b", projectId: "project-host-b", iconWorkingDir: "/repo/b" },
+ });
+ });
+
+ it.each(["host-b", "another-host"])(
+ "keeps the first eligible placement when preferred host %s cannot create here",
+ (preferredServerId) => {
+ const result = buildSidebarProjectRowModel({
+ project: project({
+ hosts: [
+ { serverId: "host-a", iconWorkingDir: "/repo/a", worktreeSupport: "supported" },
+ { serverId: "host-b", iconWorkingDir: "/repo/b", worktreeSupport: "unsupported" },
+ ],
+ }),
+ collapsed: false,
+ preferredServerId,
+ });
+
+ expect(result.trailingAction).toEqual({
+ kind: "new_workspace",
+ target: { serverId: "host-a", projectId: "project-host-a", iconWorkingDir: "/repo/a" },
+ });
+ },
+ );
+
it("renders a non-git single-workspace project as an expandable section", () => {
const result = buildSidebarProjectRowModel({
project: project({
diff --git a/packages/app/src/utils/sidebar-project-row-model.ts b/packages/app/src/utils/sidebar-project-row-model.ts
index 619e08dc4f..d5504d08bb 100644
--- a/packages/app/src/utils/sidebar-project-row-model.ts
+++ b/packages/app/src/utils/sidebar-project-row-model.ts
@@ -85,7 +85,9 @@ export function resolveSidebarProjectLocalPath(
function resolveNewWorkspaceTarget(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap,
+ preferredServerId: string | null | undefined,
): SidebarProjectHostTarget | null {
+ let fallbackTarget: SidebarProjectHostTarget | null = null;
for (const host of project.hosts) {
if (
host.worktreeSupport === "unsupported" &&
@@ -94,22 +96,30 @@ function resolveNewWorkspaceTarget(
continue;
}
const target = hostTarget(host);
- if (target) return target;
+ if (!target) continue;
+ if (host.serverId === preferredServerId) return target;
+ fallbackTarget ??= target;
}
- return null;
+ return fallbackTarget;
}
function projectTrailingAction(
project: SidebarProjectEntry,
supportsMultiplicityByServerId: ReadonlyMap,
+ preferredServerId: string | null | undefined,
): SidebarProjectTrailingAction {
- const target = resolveNewWorkspaceTarget(project, supportsMultiplicityByServerId);
+ const target = resolveNewWorkspaceTarget(
+ project,
+ supportsMultiplicityByServerId,
+ preferredServerId,
+ );
return target ? { kind: "new_workspace", target } : { kind: "none" };
}
export function buildSidebarProjectRowModel(input: {
project: SidebarProjectEntry;
collapsed: boolean;
+ preferredServerId?: string | null;
supportsMultiplicityByServerId?: ReadonlyMap;
}): SidebarProjectRowModel {
return {
@@ -118,6 +128,7 @@ export function buildSidebarProjectRowModel(input: {
trailingAction: projectTrailingAction(
input.project,
input.supportsMultiplicityByServerId ?? EMPTY_MULTIPLICITY_MAP,
+ input.preferredServerId,
),
};
}
From 2a9e86b542326e62abea5c062bae3ff3c191c3bf Mon Sep 17 00:00:00 2001
From: "paseo-ai[bot]" <266920839+paseo-ai[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 11:09:08 +0000
Subject: [PATCH 036/354] fix: update lockfile signatures and Nix hash [skip
ci]
---
nix/npm-deps.hash | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash
index 297a783afb..a35c995ec6 100644
--- a/nix/npm-deps.hash
+++ b/nix/npm-deps.hash
@@ -1 +1 @@
-sha256-O8sNh6oXFIddUljYiC6RbVZF5/7a7A7W9UWrOd0tIV4=
+sha256-tSE4nb4E06GFZ8I9UEbo93nlPiAKtW00nkywgKQmMhI=
From f7a0e2cbea875d44d36ac4801bfe5467fa17cdac Mon Sep 17 00:00:00 2001
From: Mohamed Boudra
Date: Sun, 18 Oct 2026 21:11:14 +0700
Subject: [PATCH 037/354] test(server): wait up to ten seconds for the OMP
relaunch to register MCP tools (#7304)
The crash-relaunch test waited with vi.waitFor's default one second for a
fresh runtime and MCP child to come up. On the Windows runner with three
workers that took longer once in six runs and failed main. The assertions
are unchanged; only the budget for the relaunch is realistic now.
---
.../server/agent/providers/omp/mcp-bridge.test.ts | 13 +++++++++----
1 file changed, 9 insertions(+), 4 deletions(-)
diff --git a/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts b/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts
index 20cab26fd5..db6454edd3 100644
--- a/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts
+++ b/packages/server/src/server/agent/providers/omp/mcp-bridge.test.ts
@@ -285,10 +285,15 @@ describe("OMP MCP host tools", () => {
expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
omp.processExit("crashed");
await omp.startTurn("again");
- await vi.waitFor(() => {
- expect(omp.runtimeSessions()).toHaveLength(2);
- expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
- });
+ // The relaunch spawns a new runtime and MCP child; on a loaded Windows
+ // runner that has taken longer than vi.waitFor's default one second.
+ await vi.waitFor(
+ () => {
+ expect(omp.runtimeSessions()).toHaveLength(2);
+ expect(omp.registeredHostTools().at(-1)?.[0]?.name).toMatch(/^mcp_local__echo_secret/);
+ },
+ { timeout: 10_000 },
+ );
} finally {
await omp.close();
}
From fcda2f1cf0a36a2e81583a5616773321918b2de4 Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 17:00:13 +0200
Subject: [PATCH 038/354] fix(hub): reconnect after silent connection loss
(#7315)
A network path can swallow close notifications while the Hub socket remains open locally. Probe it with protocol ping/pong frames and feed stale sockets into the existing reconnect path.
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
---
.../server/hub/relationship-liveness.test.ts | 161 ++++++++++++++++++
.../src/server/hub/relationship-remote.ts | 42 ++++-
2 files changed, 196 insertions(+), 7 deletions(-)
create mode 100644 packages/server/src/server/hub/relationship-liveness.test.ts
diff --git a/packages/server/src/server/hub/relationship-liveness.test.ts b/packages/server/src/server/hub/relationship-liveness.test.ts
new file mode 100644
index 0000000000..048ee8ff80
--- /dev/null
+++ b/packages/server/src/server/hub/relationship-liveness.test.ts
@@ -0,0 +1,161 @@
+import { createServer as createHttpServer } from "node:http";
+import { connect, createServer as createTcpServer, type AddressInfo, type Socket } from "node:net";
+import { mkdtemp, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import pino from "pino";
+import { expect, test, vi } from "vitest";
+import { WebSocket, WebSocketServer } from "ws";
+import { HubRelationshipController } from "./relationship-controller.js";
+import { DirectHubRelationshipRemote } from "./relationship-remote.js";
+
+test("a silently lost Hub connection leaves connected state and reconnects when the path recovers", async () => {
+ const home = await mkdtemp(path.join(tmpdir(), "paseo-hub-liveness-"));
+ const hub = createHttpServer(async (request, response) => {
+ let body = "";
+ for await (const chunk of request) body += chunk;
+ const enrollment = JSON.parse(body) as { daemonId: string };
+ response.writeHead(200, { "content-type": "application/json" }).end(
+ JSON.stringify({
+ daemonId: enrollment.daemonId,
+ permissions: ["hub.execute"],
+ webSocketUrl: `ws://127.0.0.1:${proxyPort}/api/daemons/socket`,
+ }),
+ );
+ });
+ const webSockets = new WebSocketServer({ server: hub });
+ const peers = new Set();
+ let connections = 0;
+ let pongs = 0;
+ webSockets.on("connection", (peer) => {
+ connections += 1;
+ peers.add(peer);
+ peer.on("close", () => peers.delete(peer));
+ });
+ await listen(hub);
+ const hubPort = (hub.address() as AddressInfo).port;
+ const streams = new Set();
+ let dropped = false;
+ const proxy = createTcpServer((client) => {
+ const upstream = connect(hubPort, "127.0.0.1");
+ streams.add(client);
+ streams.add(upstream);
+ client.on("data", (data) => {
+ if (!dropped) upstream.write(data);
+ });
+ upstream.on("data", (data) => {
+ if (!dropped) client.write(data);
+ });
+ client.on("end", () => {
+ if (!dropped) upstream.end();
+ });
+ upstream.on("end", () => {
+ if (!dropped) client.end();
+ });
+ for (const socket of [client, upstream]) {
+ socket.on("error", () => socket.destroy());
+ socket.on("close", () => streams.delete(socket));
+ }
+ });
+ await listen(proxy);
+ const proxyPort = (proxy.address() as AddressInfo).port;
+ let retry: (() => void) | undefined;
+ const controller = new HubRelationshipController({
+ paseoHome: home,
+ hostname: "test-daemon",
+ serverId: "server-1",
+ daemonPublicKey: "public-key",
+ logger: pino({ level: "silent" }),
+ remote: new DirectHubRelationshipRemote(),
+ clock: {
+ now: () => new Date(),
+ schedule: (_delay, task) => {
+ retry = task;
+ return { cancel: () => (retry = undefined) };
+ },
+ },
+ attachSocket: async (socket) => {
+ if (!(socket instanceof WebSocket)) throw new Error("Expected a real WebSocket");
+ socket.on("pong", () => (pongs += 1));
+ },
+ updateAttachedPermissions: () => undefined,
+ createExecutionAgents: () => ({
+ create: async () => {
+ throw new Error("Unexpected agent creation");
+ },
+ subscribe: () => () => undefined,
+ invalidateAuthority: async () => undefined,
+ }),
+ });
+
+ try {
+ // Keep TCP and WebSocket I/O real; accelerate only the keepalive interval and its clock.
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "Date"] });
+ await controller.connect({
+ hubUrl: `http://127.0.0.1:${proxyPort}`,
+ token: "test-token",
+ permissions: ["hub.execute"],
+ });
+ await expect.poll(() => controller.status().state).toBe("connected");
+ const connectedAt = controller.status().connectedAt;
+
+ expect(connections).toBe(1);
+
+ // Model the reported proxy request ending without a close reaching the daemon.
+ dropped = true;
+ for (const peer of peers) peer.terminate();
+ await expect.poll(() => peers.size).toBe(0);
+ await vi.advanceTimersByTimeAsync(60_000);
+ await expect.poll(() => controller.status().state).toBe("reconnecting");
+ expect(controller.status().lastError).toBe("Hub WebSocket heartbeat timed out");
+ expect(retry).toBeTypeOf("function");
+
+ dropped = false;
+ retry?.();
+ await expect.poll(() => controller.status().state).toBe("connected");
+ expect(connections).toBe(2);
+ expect(controller.status().connectedAt).not.toBe(connectedAt);
+ expect(controller.status().lastError).toBeNull();
+
+ // A healthy idle connection survives multiple liveness windows.
+ for (let tick = 0; tick < 12; tick += 1) {
+ const before = pongs;
+ await vi.advanceTimersByTimeAsync(10_000);
+ await expect.poll(() => pongs).toBeGreaterThan(before);
+ }
+ expect(controller.status().state).toBe("connected");
+ expect(connections).toBe(2);
+
+ await controller.stop();
+ await expect.poll(() => peers.size).toBe(0);
+ await expect.poll(() => vi.getTimerCount()).toBe(0);
+ } finally {
+ await controller.stop();
+ vi.useRealTimers();
+ for (const socket of streams) socket.destroy();
+ for (const peer of peers) peer.terminate();
+ webSockets.close();
+ await Promise.all([close(proxy), close(hub)]);
+ await rm(home, { recursive: true, force: true });
+ }
+});
+
+async function listen(
+ server: ReturnType | ReturnType,
+) {
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
+}
+
+async function close(
+ server: ReturnType | ReturnType,
+) {
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+}
diff --git a/packages/server/src/server/hub/relationship-remote.ts b/packages/server/src/server/hub/relationship-remote.ts
index d0ae01089d..9c6f88a63d 100644
--- a/packages/server/src/server/hub/relationship-remote.ts
+++ b/packages/server/src/server/hub/relationship-remote.ts
@@ -2,6 +2,9 @@ import { WebSocket } from "ws";
import { z } from "zod";
import type { WebSocketLike } from "../websocket-server.js";
+const SOCKET_PING_INTERVAL_MS = 10_000;
+const SOCKET_PONG_TIMEOUT_MS = 45_000;
+
export interface HubEnrollment {
daemonId: string;
idempotencyKey: string;
@@ -171,13 +174,42 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
},
});
let settled = false;
+ let heartbeat: ReturnType | undefined;
+ const stopHeartbeat = () => {
+ clearInterval(heartbeat);
+ heartbeat = undefined;
+ };
+ const fail = (error: Error) => {
+ if (settled) return;
+ settled = true;
+ stopHeartbeat();
+ socket.terminate();
+ events.failed(error);
+ };
+ let lastPongAt = 0;
+ socket.on("pong", () => {
+ lastPongAt = Date.now();
+ });
socket.once("upgrade", (response) => {
if (response.headers["x-paseo-session-protocol"] === "1") {
sessionProtocol = "session-v1";
}
});
socket.once("open", () => {
- if (!settled) events.connected(socket as WebSocketLike, sessionProtocol);
+ if (settled) return;
+ lastPongAt = Date.now();
+ heartbeat = setInterval(() => {
+ if (socket.readyState !== WebSocket.OPEN) return;
+ if (Date.now() - lastPongAt >= SOCKET_PONG_TIMEOUT_MS) {
+ fail(new Error("Hub WebSocket heartbeat timed out"));
+ return;
+ }
+ socket.ping(undefined, undefined, (error) => {
+ if (error) fail(error);
+ });
+ }, SOCKET_PING_INTERVAL_MS);
+ heartbeat.unref?.();
+ events.connected(socket as WebSocketLike, sessionProtocol);
});
socket.once("unexpected-response", (_request, response) => {
if (settled) {
@@ -194,16 +226,12 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
events.closed(1006);
});
socket.once("close", (code) => {
+ stopHeartbeat();
if (settled) return;
settled = true;
events.closed(code);
});
- socket.once("error", (error) => {
- if (settled) return;
- settled = true;
- socket.terminate();
- events.failed(error);
- });
+ socket.once("error", fail);
return socket;
}
From 303cebec063ac1541d1ed34f8b0f21df90f95c1e Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 17:54:51 +0200
Subject: [PATCH 039/354] Keep sidebar setup actions inside the empty-state
card (#7312)
* fix(app): wrap sidebar empty-state actions so longer labels stay inside the card
The "Add project" / "Import session" row in the sidebar's empty state is a
non-wrapping flex row. With longer translations (e.g. Russian) the second
button overflows the card at the default 320px sidebar width.
Co-Authored-By: Claude Opus 5.5
* test(app): cover sidebar empty-state actions staying inside the card
Renders the empty state in a real browser at the default sidebar width and
checks both actions' bounds against the card, for Russian (fails without the
wrap) and English.
Co-Authored-By: Claude Opus 5.5
* fix(app): keep long sidebar actions inside narrow cards
Wrapping the row still lets a single translated action exceed its available width. Bound each empty-state action to the row and allow its label to shrink and wrap.
Co-authored-by: Vyacheslav Scherbinin
---------
Co-authored-by: Vyacheslav Scherbinin
Co-authored-by: Claude Opus 5.5
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
---
.../sidebar/empty-states.browser.test.tsx | 85 +++++++++++++++++++
.../src/components/sidebar/empty-states.tsx | 33 ++++++-
2 files changed, 115 insertions(+), 3 deletions(-)
create mode 100644 packages/app/src/components/sidebar/empty-states.browser.test.tsx
diff --git a/packages/app/src/components/sidebar/empty-states.browser.test.tsx b/packages/app/src/components/sidebar/empty-states.browser.test.tsx
new file mode 100644
index 0000000000..85cd96c0e6
--- /dev/null
+++ b/packages/app/src/components/sidebar/empty-states.browser.test.tsx
@@ -0,0 +1,85 @@
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { getAllByRole } from "@testing-library/dom";
+import { i18n as testI18n } from "@/i18n/i18next";
+import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store";
+import { SidebarProjectEmptyState } from "./empty-states";
+
+// App sources compile against the classic JSX runtime, which expects React on the global.
+beforeEach(() => vi.stubGlobal("React", React));
+
+interface Mounted {
+ root: Root;
+ container: HTMLDivElement;
+}
+
+const mounted: Mounted[] = [];
+
+afterEach(async () => {
+ for (const entry of mounted.splice(0)) {
+ act(() => entry.root.unmount());
+ entry.container.remove();
+ }
+ await testI18n.changeLanguage("en");
+});
+
+async function mountEmptyState(language: string, width: number): Promise {
+ await testI18n.changeLanguage(language);
+
+ const container = document.createElement("div");
+ container.style.width = `${width}px`;
+ // Match the workspace list's horizontal inset around the card.
+ container.style.paddingInline = "6px";
+ container.style.boxSizing = "border-box";
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ act(() => root.render());
+ mounted.push({ root, container });
+
+ const card = container.querySelector('[data-testid="sidebar-project-empty-state"]');
+ if (!(card instanceof HTMLElement)) {
+ throw new Error("SidebarProjectEmptyState did not render its card");
+ }
+ return card;
+}
+
+function expectActionsInsideCard(card: HTMLElement) {
+ const cardRect = card.getBoundingClientRect();
+ const buttons = getAllByRole(card, "button");
+
+ expect(buttons).toHaveLength(2);
+ for (const button of buttons) {
+ const rect = button.getBoundingClientRect();
+ expect(rect.left).toBeGreaterThanOrEqual(cardRect.left);
+ expect(rect.right).toBeLessThanOrEqual(cardRect.right);
+
+ // Containing the button must also keep the whole label readable, without clipping.
+ const label = document.createRange();
+ label.selectNodeContents(button);
+ for (const fragment of Array.from(label.getClientRects())) {
+ expect(fragment.left).toBeGreaterThanOrEqual(rect.left);
+ expect(fragment.right).toBeLessThanOrEqual(rect.right);
+ expect(fragment.top).toBeGreaterThanOrEqual(rect.top);
+ expect(fragment.bottom).toBeLessThanOrEqual(rect.bottom);
+ }
+ }
+}
+
+describe("SidebarProjectEmptyState", () => {
+ const cases = ["en", "ru", "fr", "ja", "es", "pt-BR", "ar", "ko", "zh-CN"].flatMap((language) =>
+ [MIN_SIDEBAR_WIDTH, DEFAULT_SIDEBAR_WIDTH].map((width) => ({ language, width })),
+ );
+ it.each(cases)(
+ "keeps every action inside the card ($language, $width px sidebar)",
+ async ({ language, width }) => {
+ expectActionsInsideCard(await mountEmptyState(language, width));
+ },
+ );
+
+ it("keeps English actions on one row when they fit", async () => {
+ const card = await mountEmptyState("en", DEFAULT_SIDEBAR_WIDTH);
+ const [addProject, importSession] = getAllByRole(card, "button");
+ expect(addProject.getBoundingClientRect().top).toBe(importSession.getBoundingClientRect().top);
+ });
+});
diff --git a/packages/app/src/components/sidebar/empty-states.tsx b/packages/app/src/components/sidebar/empty-states.tsx
index 3328ed2c5c..c9f6b7c568 100644
--- a/packages/app/src/components/sidebar/empty-states.tsx
+++ b/packages/app/src/components/sidebar/empty-states.tsx
@@ -54,7 +54,13 @@ export function SidebarFilterEmptyState() {
title={t("sidebar.filterEmpty.title")}
description={t("sidebar.filterEmpty.description")}
>
-
+
{t("sidebar.filterEmpty.clear")}
@@ -76,10 +82,24 @@ export function SidebarProjectEmptyState({
title={t("sidebar.project.empty.title")}
description={t("sidebar.project.empty.description")}
>
-
+
{t("sidebar.actions.addProject")}
-
+
{t("importSession.title")}
@@ -99,9 +119,16 @@ const styles = StyleSheet.create((theme) => ({
},
actions: {
flexDirection: "row",
+ flexWrap: "wrap",
gap: theme.spacing[2],
marginTop: theme.spacing[4],
},
+ action: {
+ maxWidth: "100%",
+ },
+ actionText: {
+ flexShrink: 1,
+ },
title: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
From 7c7b6555f17a88cffddf484129cd04ad79704f59 Mon Sep 17 00:00:00 2001
From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com>
Date: Sun, 18 Oct 2026 20:00:36 +0200
Subject: [PATCH 040/354] Show provider artwork in the catalog and provider
rows (#7330)
* Add missing monochrome provider icons (kiro, devin, hermes, minimax-code, traecli, grok, deepseek-tui)
* fix(app): add Kiro catalog icon and Antigravity builtin icon
The ACP catalog's kiro entry had iconId null and no vendored svg, and
the native Antigravity provider resolved to the Bot fallback before the
daemon snapshot arrived. Vendor the Kiro brand mark, wire iconId, and
register a builtin AntigravityIcon matching the plugin's theme-aware
icon.svg.
* fix(app): keep Kiro eyes in monochrome icon
* test(providers): cover catalog artwork and snapshot-free icons
Lock down the missing provider marks through the catalog and the shared icon hook. Exercise the rendered catalog artwork in Chromium alongside coverage for the pre-snapshot fallback.
Co-authored-by: kevenhu001-cyber
Co-authored-by: Lu
* fix(website): preserve unrelated agent directory artwork
Drop the unrelated website mark introduced with the provider artwork. Cover the existing directory fallback and retained provider mark in a browser regression.
---------
Co-authored-by: kevenhu001-cyber
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Lu
---
.../browser/provider-catalog-icons.spec.ts | 51 +++++++++++++++++++
packages/app/src/assets/acp-provider-icons.ts | 9 +++-
.../src/assets/acp-provider-icons/devin.svg | 3 ++
.../src/assets/acp-provider-icons/grok.svg | 4 ++
.../src/assets/acp-provider-icons/hermes.svg | 3 ++
.../src/assets/acp-provider-icons/kiro.svg | 1 +
.../acp-provider-icons/minimax-code.svg | 3 ++
.../src/assets/acp-provider-icons/traecli.svg | 3 ++
.../src/components/icons/antigravity-icon.tsx | 17 +++++++
.../app/src/components/icons/kiro-icon.tsx | 18 +++++++
.../src/components/provider-icon-name.test.ts | 2 +
.../app/src/components/provider-icons.test.ts | 12 ++++-
packages/app/src/components/provider-icons.ts | 7 ++-
packages/app/src/data/acp-provider-catalog.ts | 6 +--
.../hooks/use-acp-provider-catalog.test.ts | 7 +++
packages/protocol/src/provider-icon-names.ts | 3 ++
packages/website/e2e/agent-icons.spec.ts | 26 ++++++++++
.../website/src/assets/agent-icons/hermes.svg | 3 ++
18 files changed, 170 insertions(+), 8 deletions(-)
create mode 100644 packages/app/e2e/browser/provider-catalog-icons.spec.ts
create mode 100644 packages/app/src/assets/acp-provider-icons/devin.svg
create mode 100644 packages/app/src/assets/acp-provider-icons/grok.svg
create mode 100644 packages/app/src/assets/acp-provider-icons/hermes.svg
create mode 100644 packages/app/src/assets/acp-provider-icons/kiro.svg
create mode 100644 packages/app/src/assets/acp-provider-icons/minimax-code.svg
create mode 100644 packages/app/src/assets/acp-provider-icons/traecli.svg
create mode 100644 packages/app/src/components/icons/antigravity-icon.tsx
create mode 100644 packages/app/src/components/icons/kiro-icon.tsx
create mode 100644 packages/website/e2e/agent-icons.spec.ts
create mode 100644 packages/website/src/assets/agent-icons/hermes.svg
diff --git a/packages/app/e2e/browser/provider-catalog-icons.spec.ts b/packages/app/e2e/browser/provider-catalog-icons.spec.ts
new file mode 100644
index 0000000000..818d88ab84
--- /dev/null
+++ b/packages/app/e2e/browser/provider-catalog-icons.spec.ts
@@ -0,0 +1,51 @@
+import { ACP_PROVIDER_ICON_SVGS } from "../../src/assets/acp-provider-icons";
+import { expect, test, type Page } from "../support/fixtures";
+import type { TestInfo } from "@playwright/test";
+import { gotoAppShell, openSettings } from "../support/helpers/app";
+import { getServerId } from "../support/helpers/server-id";
+import {
+ findAcpCatalogProvider,
+ openAddProviderArea,
+ openSettingsHost,
+ openSettingsHostSection,
+} from "../support/helpers/settings";
+
+async function openProviderCatalog(page: Page) {
+ await gotoAppShell(page);
+ await openSettings(page);
+ await openSettingsHost(page, getServerId());
+ await openSettingsHostSection(page, getServerId(), "providers");
+ await openAddProviderArea(page);
+}
+
+async function expectProviderArtwork(
+ page: Page,
+ testInfo: TestInfo,
+ id: "devin" | "hermes" | "kiro",
+ name: string,
+) {
+ await findAcpCatalogProvider(page, name);
+ // Catalog tiles have no semantic role; scope artwork to the named provider's tile.
+ const icon = page.getByTestId(`provider-catalog-tile-${id}`).locator("svg").first();
+ await expect(icon).toBeVisible();
+ // Provider artwork is 24px; the generic PackagePlus fallback is 20px.
+ await expect.soft(icon).toHaveAttribute("width", "24");
+ const expectedPaths = await page.evaluate((xml) => {
+ const document = new DOMParser().parseFromString(xml, "image/svg+xml");
+ return Array.from(document.querySelectorAll("path"), (path) => path.getAttribute("d")!);
+ }, ACP_PROVIDER_ICON_SVGS[id]);
+ await expect(icon.locator("path")).toHaveCount(expectedPaths.length);
+ for (const [index, path] of expectedPaths.entries()) {
+ await expect(icon.locator("path").nth(index)).toHaveAttribute("d", path);
+ }
+ await page.screenshot({ path: testInfo.outputPath(`${id}-catalog.png`) });
+}
+
+test("#7087: the provider catalog shows Devin, Hermes, and Kiro artwork", async ({
+ page,
+}, testInfo) => {
+ await openProviderCatalog(page);
+ await expectProviderArtwork(page, testInfo, "devin", "Devin CLI");
+ await expectProviderArtwork(page, testInfo, "hermes", "Hermes");
+ await expectProviderArtwork(page, testInfo, "kiro", "Kiro CLI");
+});
diff --git a/packages/app/src/assets/acp-provider-icons.ts b/packages/app/src/assets/acp-provider-icons.ts
index cebb4ca76d..703473e6be 100644
--- a/packages/app/src/assets/acp-provider-icons.ts
+++ b/packages/app/src/assets/acp-provider-icons.ts
@@ -53,9 +53,10 @@ export const ACP_PROVIDER_ICON_SVGS = {
junie:
'\n',
kilo: '\n',
+ kiro: '',
kimi: '\n',
"minimax-code":
- '\n',
+ '\n',
"minion-code":
'\n',
"mistral-vibe":
@@ -74,7 +75,11 @@ export const ACP_PROVIDER_ICON_SVGS = {
stakpak:
'\n',
traecli:
- '\n',
+ '\n',
vtcode:
'\n',
+ devin:
+ '\n',
+ hermes:
+ '\n',
} as const;
diff --git a/packages/app/src/assets/acp-provider-icons/devin.svg b/packages/app/src/assets/acp-provider-icons/devin.svg
new file mode 100644
index 0000000000..e0f58c6513
--- /dev/null
+++ b/packages/app/src/assets/acp-provider-icons/devin.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/app/src/assets/acp-provider-icons/grok.svg b/packages/app/src/assets/acp-provider-icons/grok.svg
new file mode 100644
index 0000000000..35db840319
--- /dev/null
+++ b/packages/app/src/assets/acp-provider-icons/grok.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/app/src/assets/acp-provider-icons/hermes.svg b/packages/app/src/assets/acp-provider-icons/hermes.svg
new file mode 100644
index 0000000000..c6fdd22500
--- /dev/null
+++ b/packages/app/src/assets/acp-provider-icons/hermes.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/app/src/assets/acp-provider-icons/kiro.svg b/packages/app/src/assets/acp-provider-icons/kiro.svg
new file mode 100644
index 0000000000..31715c335a
--- /dev/null
+++ b/packages/app/src/assets/acp-provider-icons/kiro.svg
@@ -0,0 +1 @@
+
diff --git a/packages/app/src/assets/acp-provider-icons/minimax-code.svg b/packages/app/src/assets/acp-provider-icons/minimax-code.svg
new file mode 100644
index 0000000000..61fcb8afab
--- /dev/null
+++ b/packages/app/src/assets/acp-provider-icons/minimax-code.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/app/src/assets/acp-provider-icons/traecli.svg b/packages/app/src/assets/acp-provider-icons/traecli.svg
new file mode 100644
index 0000000000..6d5388fcaa
--- /dev/null
+++ b/packages/app/src/assets/acp-provider-icons/traecli.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/app/src/components/icons/antigravity-icon.tsx b/packages/app/src/components/icons/antigravity-icon.tsx
new file mode 100644
index 0000000000..baf2b4c4a6
--- /dev/null
+++ b/packages/app/src/components/icons/antigravity-icon.tsx
@@ -0,0 +1,17 @@
+import Svg, { Path } from "react-native-svg";
+
+interface AntigravityIconProps {
+ size?: number;
+ color?: string;
+}
+
+export function AntigravityIcon({ size = 16, color = "currentColor" }: AntigravityIconProps) {
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/icons/kiro-icon.tsx b/packages/app/src/components/icons/kiro-icon.tsx
new file mode 100644
index 0000000000..830e9109aa
--- /dev/null
+++ b/packages/app/src/components/icons/kiro-icon.tsx
@@ -0,0 +1,18 @@
+import Svg, { Path } from "react-native-svg";
+
+interface KiroIconProps {
+ size?: number;
+ color?: string;
+}
+
+export function KiroIcon({ size = 16, color = "currentColor" }: KiroIconProps) {
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/provider-icon-name.test.ts b/packages/app/src/components/provider-icon-name.test.ts
index 65c75166a5..0083926627 100644
--- a/packages/app/src/components/provider-icon-name.test.ts
+++ b/packages/app/src/components/provider-icon-name.test.ts
@@ -17,8 +17,10 @@ describe("resolveProviderIconName", () => {
it("returns the catalog identifier for ACP catalog provider ids that ship an icon", () => {
expect(resolveProviderIconName("amp-acp")).toEqual({ kind: "catalog", id: "amp-acp" });
+ expect(resolveProviderIconName("devin")).toEqual({ kind: "catalog", id: "devin" });
expect(resolveProviderIconName("gemini")).toEqual({ kind: "catalog", id: "gemini" });
expect(resolveProviderIconName("gjc")).toEqual({ kind: "catalog", id: "gjc" });
+ expect(resolveProviderIconName("hermes")).toEqual({ kind: "catalog", id: "hermes" });
expect(resolveProviderIconName("traecli")).toEqual({ kind: "catalog", id: "traecli" });
});
diff --git a/packages/app/src/components/provider-icons.test.ts b/packages/app/src/components/provider-icons.test.ts
index 025ff14bef..aceed473be 100644
--- a/packages/app/src/components/provider-icons.test.ts
+++ b/packages/app/src/components/provider-icons.test.ts
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook } from "@testing-library/react";
-import { Bot } from "lucide-react-native";
+import { Bot, PackagePlus } from "lucide-react-native";
import { SvgXml } from "react-native-svg";
import { afterEach, describe, expect, it } from "vitest";
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
@@ -17,6 +17,16 @@ function renderIcon(Component: ProviderIconComponent) {
}
describe("useProviderIcon", () => {
+ it.each(["devin", "hermes", "kiro", "antigravity"])(
+ "#7087: %s has a provider mark before a host snapshot arrives",
+ (provider) => {
+ const Icon = renderHook(() => useProviderIcon(provider)).result.current;
+ expect(Icon).not.toBe(Bot);
+ expect(Icon).not.toBe(PackagePlus);
+ expect(typeof Icon).toBe("function");
+ },
+ );
+
it("renders registered snapshot SVG metadata with the requested size and color", () => {
const svg = '';
replaceProviderSnapshotIcons("server-1", [{ provider: "rendered-provider", iconSvg: svg }]);
diff --git a/packages/app/src/components/provider-icons.ts b/packages/app/src/components/provider-icons.ts
index 883b4820f7..d8548cbd16 100644
--- a/packages/app/src/components/provider-icons.ts
+++ b/packages/app/src/components/provider-icons.ts
@@ -1,12 +1,14 @@
import { useStore } from "zustand";
import { providerSnapshotIcons } from "@/data/provider-icons";
-import { Bot, PackagePlus } from "lucide-react-native";
+import { Bot } from "lucide-react-native";
import { createElement, useCallback, type ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { TERMINAL_PROFILE_ICON_NAMES } from "@getpaseo/protocol/provider-icon-names";
+import { AntigravityIcon } from "@/components/icons/antigravity-icon";
import { ClaudeIcon } from "@/components/icons/claude-icon";
import { CodexIcon } from "@/components/icons/codex-icon";
import { CopilotIcon } from "@/components/icons/copilot-icon";
+import { KiroIcon } from "@/components/icons/kiro-icon";
import { MiniMaxIcon } from "@/components/icons/minimax-icon";
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
import { OmpIcon } from "@/components/icons/omp-icon";
@@ -23,10 +25,11 @@ export interface ProviderIconProps {
export type ProviderIconComponent = ComponentType;
const BUILTIN_PROVIDER_ICONS: Record = {
+ antigravity: AntigravityIcon as unknown as ProviderIconComponent,
claude: ClaudeIcon as unknown as ProviderIconComponent,
codex: CodexIcon as unknown as ProviderIconComponent,
copilot: CopilotIcon as unknown as ProviderIconComponent,
- kiro: PackagePlus,
+ kiro: KiroIcon as unknown as ProviderIconComponent,
minimax: MiniMaxIcon as unknown as ProviderIconComponent,
omp: OmpIcon as unknown as ProviderIconComponent,
opencode: OpenCodeIcon as unknown as ProviderIconComponent,
diff --git a/packages/app/src/data/acp-provider-catalog.ts b/packages/app/src/data/acp-provider-catalog.ts
index 1bd32db53d..f91a9540f2 100644
--- a/packages/app/src/data/acp-provider-catalog.ts
+++ b/packages/app/src/data/acp-provider-catalog.ts
@@ -133,7 +133,7 @@ const CATALOG_DATA = [
title: "Devin CLI",
description: "Cognition's Devin for Terminal via Agent Client Protocol",
version: "manual",
- iconId: null,
+ iconId: "devin",
installLink: "https://cli.devin.ai/docs",
command: ["devin", "acp"],
},
@@ -235,7 +235,7 @@ const CATALOG_DATA = [
title: "Hermes",
description: "Nous Research self-improving AI agent",
version: "manual",
- iconId: null,
+ iconId: "hermes",
installLink: "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
command: ["hermes", "acp"],
},
@@ -272,7 +272,7 @@ const CATALOG_DATA = [
title: "Kiro CLI",
description: "Amazon's AI coding agent with native ACP support",
version: "manual",
- iconId: null,
+ iconId: "kiro",
installLink: "https://kiro.dev/docs/cli/acp/",
command: ["kiro-cli", "acp"],
},
diff --git a/packages/app/src/hooks/use-acp-provider-catalog.test.ts b/packages/app/src/hooks/use-acp-provider-catalog.test.ts
index 201c17c022..c5fd59d53e 100644
--- a/packages/app/src/hooks/use-acp-provider-catalog.test.ts
+++ b/packages/app/src/hooks/use-acp-provider-catalog.test.ts
@@ -11,6 +11,13 @@ function findProvider(id: string) {
}
describe("ACP provider catalog", () => {
+ it.each(["devin", "hermes", "kiro"])(
+ "#7087: %s supplies its provider mark to the catalog picker",
+ (provider) => {
+ expect(findProvider(provider).iconSvg).toContain("