From 74c8172f8b0ebdb10292b17b549e076fa5b03f2f Mon Sep 17 00:00:00 2001 From: HelloThereEveryone <49528864+Mtuchel@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:57:10 +0200 Subject: [PATCH 01/13] Wait for a plugin reload to finish instead of failing at the request deadline (#6306) * Wait for plugin reload completion instead of the request deadline The daemon answers plugin.reload.request only once the plugin is running again or has failed, and a reload queues behind other plugin lifecycle work such as an install with a preparation command. The shared client applied its sixty-second session RPC deadline to that request, so a slow reload failed with "Timeout waiting for message" while the daemon kept loading the plugin, inviting a reload the daemon was still running. Reload now waits for the daemon's own answer. Enable and disable keep the session deadline, and a real startup failure still reaches the caller. * Cover a slow plugin reload end to end The CLI fixture now holds its own git build on a gate file, starts a reload of an installed plugin behind that queued install, observes the reload request at the proxy, keeps the gate closed past the client's former one-minute deadline, and then asserts the reload's real result: CLI exit 0, the plugin running, and exactly one reactivation. Listing the plugin during the wait stays readable and does not restart it. A plugin that cannot start still fails its reload with the daemon's error. The request proxy that recorded install sources becomes a small observer that records any session request, so the wait is measured from the request boundary rather than from process start. Verified: npx tsx packages/cli/tests/e2e/plugin-lifecycle.test.ts exits 0 in 135 s; the same fixture without the new scenario takes 66 s. * Document how a reload reports its result The CLI docs now say `paseo plugin reload` waits for the daemon to stop and start the plugin, that cancelling the command or losing the connection does not stop the daemon's work, and that `plugin ls` shows the previous state until the reload finishes. The plugin reference records the lifecycle fact behind it: a reload queues behind the daemon's other plugin work, the daemon answers when the plugin is running again or has failed, and status reads, Settings and the logs are not the reload's outcome. * Cover the Settings reload wait end to end Adds a browser scenario for the ticket's user-facing half: with a gated git install holding the daemon's plugin lifecycle queue, the row's Reload goes out, the test keeps the gate closed past sixty seconds, asserts the row stays pending with no error feedback, then releases the build and asserts the daemon's plugin.reload.response and the app's "Reloaded e2e-plugin" result. The scenario turns plugins on through the daemon instead of the global switch, whose transient "Plugins enabled" confirmation is not its subject. The wait is measured from the reload request frame, which is an outbound frame, not from the response stream. Verified: npx playwright test --project=browser e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a minute" -> 1 passed (1.5m); plugin-reload-pending.png, plugin-reload-past-deadline.png and plugin-reload-completed.png show the pending row, the surface past the former deadline with no error, and the finished reload. * Correct the status guidance for a cancelled reload Review finding: the note said a listing keeps the plugin's previous state until the reload finishes, but that only holds while the reload is still queued. Once it executes, the daemon removes the plugin from the runtime catalog before stopping it (plugins/runtime.ts:377-382) and resolvePluginStatus maps an enabled plugin that is not running to "failed" (plugins/index.ts:71-78), so a listing read in that window shows failed for a healthy reload. Both passages now separate the two windows and say that neither a listing nor the logs report the outcome of the outstanding reload. The interim "failed" itself is TUC-1401. * Harden the reload coverage against failures and lost connections Review findings folded into the tests: The Settings scenario releases its gated build in a finally block. Before this, a failing assertion left the build polling forever, the daemon's plugin lifecycle queue stayed blocked, and the fixture teardown's own removePlugin timed out behind it. It also asserts the row's actions are enabled again once the reload settles, next to the result feedback. The connection-close regression now reconnects and asserts nothing resends the lost reload; the no-replay invariant already covered for legacy requests now holds for a pending reload too. Verified: npx vitest run packages/client/src/daemon-client.test.ts -> 161 passed. npx playwright test --project=browser e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a minute" -> 1 passed (1.4m). * Let the gated build finish before its files go Review finding on packages/cli/tests/e2e/plugin-lifecycle.test.ts:450: the finally block released the gate and deleted the gate directory without waiting for the install and the reload it had started. The build polls for the gate file, so cleanup could delete the release before the child saw it and leave the build waiting until daemon shutdown. The fixture now keeps both promises, releases the gate, and awaits them before deleting their files. Verified: npx tsx packages/cli/tests/e2e/plugin-lifecycle.test.ts -> exit 0, 161 s. * Read the reload scenario as intent, not transport code Review finding: the new browser scenario kept frame parsing, selectors, polling, deadline arithmetic, screenshots and gate cleanup in one body. docs/testing.md asks for complex setup in reusable helpers. The wire observation, the gated queue hold, the row-state assertions, the deadline wait and the surface captures are helpers now, and the body reads as the flow: open Settings, hold the queue, reload, still pending past the deadline, release, read the daemon's result. Verified: npx playwright test --project=browser e2e/browser/plugin-management.spec.ts -g "keeps Reload pending past a minute" -> 1 passed (1.4m). * Keep the reload lifecycle fact in one document Review finding: the cli.md paragraph repeated the queue and interim-state explanation that the plugin reference already owns. CLAUDE.md: "One fact, one doc. Every other mention is a link." The reference keeps the full explanation; the CLI page states what the command does and links there. * Release the gated build when its setup fails Review finding: holdPluginLifecycleQueue started the install, then waited up to 30 seconds for the build's started marker. A preparation that took longer made the helper throw before it returned its cleanup handle and before the test entered its try block, so nothing wrote the release file: the build polled forever, the daemon's plugin lifecycle queue stayed blocked, and the fixture's own removePlugin timed out behind it. The helper now owns that failure path: it releases the gate and drains the install before rethrowing, so a queue it hands out is always settleable. Verified on Linux (Chromium, Playwright): - With the started marker suppressed in a temporary local patch, the run fails at the 30-second marker wait inside the helper and the teardown still completes (both attempts ~33 seconds, no removePlugin request timeout). - With the marker in place, the scenario passes: 1 passed (1.4m). * Keep an early install failure out of the unhandled path Review finding: the marker wait in holdPluginLifecycleQueue ran before the install promise had a rejection handler, so a preparation that failed or a connection that closed before the build wrote its marker surfaced as an unhandled rejection, which fails the run before the helper's own catch can drain the queue. The helper attaches a handler the moment the install starts and races the marker wait against the install, so an early failure reaches the caller as the daemon's own error, a build that never writes its marker still fails as a 30-second marker timeout, and every path releases the gate and drains the install. Verified on Linux (Chromium, Playwright), patching the build command locally for each failure branch: - Early failure (build exits 1): fails in 4.2 s with the daemon's DaemonRpcError for plugin.source.install.request, no unhandled rejection, teardown completes. - No marker (build waits for the gate): fails at the 30-second marker wait, no unhandled rejection, teardown completes with no removePlugin request timeout. - Unpatched: 1 passed (1.5m). * Keep a failed launch from ending the run before its cleanup --- .../app/e2e/browser/plugin-management.spec.ts | 194 ++++++++++++ .../cli/tests/e2e/plugin-lifecycle.test.ts | 283 ++++++++++++++++-- packages/client/src/daemon-client.test.ts | 194 ++++++++++++ packages/client/src/daemon-client.ts | 4 + public-docs/cli.md | 7 + public-docs/plugins/reference.md | 6 + 6 files changed, 655 insertions(+), 33 deletions(-) diff --git a/packages/app/e2e/browser/plugin-management.spec.ts b/packages/app/e2e/browser/plugin-management.spec.ts index 84b4cc63a2..4a866a8f44 100644 --- a/packages/app/e2e/browser/plugin-management.spec.ts +++ b/packages/app/e2e/browser/plugin-management.spec.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import overviewCorpus from "../../../protocol/tests/fixtures/plugin-overview.json"; import { pluginRequirements } from "../support/helpers/plugin-fixture"; import { execFileSync } from "node:child_process"; @@ -316,6 +317,47 @@ export default function contribute(plugin) { return directory; } +/** Build command that holds the plugin lifecycle queue until the test writes the release file. */ +const gatedBuildCommand = [ + 'const fs = require("node:fs");', + 'fs.writeFileSync(process.argv[1], "started");', + "const wait = () => {", + " if (fs.existsSync(process.argv[2])) return;", + " setTimeout(wait, 50);", + "};", + "wait();", +].join("\n"); + +async function createGatedBuildRepository( + root: string, + gate: { started: string; release: string }, +): Promise { + const repository = path.join(root, "gated-repository"); + await mkdir(repository); + await writeFile( + path.join(repository, "paseo-plugin.json"), + JSON.stringify({ + id: "gated-build-plugin", + description: "Holds the plugin lifecycle queue while its build waits", + requirements: pluginRequirements, + build: [[process.execPath, "-e", gatedBuildCommand, gate.started, gate.release]], + }), + ); + await writeFile(path.join(repository, "index.client.tsx"), pluginSource("Gated build plugin")); + execFileSync("git", ["init", "-b", "main"], { cwd: repository, stdio: "ignore" }); + execFileSync("git", ["config", "user.name", "Paseo Tests"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync("git", ["config", "user.email", "paseo@example.test"], { + cwd: repository, + stdio: "ignore", + }); + execFileSync("git", ["add", "-A"], { cwd: repository, stdio: "ignore" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repository, stdio: "ignore" }); + return repository; +} + async function installFailedPlugin( page: Page, client: Awaited>, @@ -680,3 +722,155 @@ async function installPluginWithUntrustedDescription( ); await environment.client.installPluginSource({ source: directory }); } + +/** Waits for the reload request and the daemon's answer on the wire, so a scenario can measure it. */ +function observePluginReload(page: Page) { + const requests: number[] = []; + const responses: number[] = []; + const isSessionMessage = (payload: unknown, type: string): boolean => { + if (typeof payload !== "string") return false; + try { + const envelope = JSON.parse(payload) as { type?: unknown; message?: { type?: unknown } }; + const message = envelope.type === "session" ? envelope.message : envelope; + return message?.type === type; + } catch { + return false; + } + }; + page.on("websocket", (socket) => { + // The reload request travels client -> daemon (outbound); the answer comes back inbound. + socket.on("framesent", ({ payload }) => { + if (isSessionMessage(payload, "plugin.reload.request")) requests.push(Date.now()); + }); + socket.on("framereceived", ({ payload }) => { + if (isSessionMessage(payload, "plugin.reload.response")) responses.push(Date.now()); + }); + }); + return { + waitForRequest: () => expect.poll(() => requests.length, { timeout: 30_000 }).toBe(1), + waitForResponse: () => expect.poll(() => responses.length, { timeout: 120_000 }).toBe(1), + requestedAt: () => requests[0]!, + requestCount: () => requests.length, + }; +} + +/** Starts a gated git install that holds the daemon's plugin lifecycle queue until it is released. */ +async function holdPluginLifecycleQueue( + client: PluginEnvironment["client"], + root: string, +): Promise<{ release: () => Promise; settle: () => Promise }> { + const gate = { + started: path.join(root, "build-started"), + release: path.join(root, "build-release"), + }; + const repository = await createGatedBuildRepository(root, gate); + const install = client.installPluginSource({ source: `git:${pathToFileURL(repository).href}` }); + // The marker wait below can outlive an early failure: a refused preparation, a dropped + // connection, or a build command that exits before writing its marker. Handle that rejection now + // so it never surfaces as an unhandled one, and drain the same promise in finish(). + const installDrained = install.catch(() => undefined); + // Every path releases the gate and drains the install: a build still polling for its gate holds + // the daemon's plugin lifecycle queue open, and the fixture's own plugin removal would stall. + const finish = async () => { + await writeFile(gate.release, "go").catch(() => undefined); + await installDrained; + }; + try { + // Whichever comes first decides the scenario: the build reports that it started, or the setup + // fails with the daemon's own error instead of a bare marker timeout. + await Promise.race([ + expect.poll(() => existsSync(gate.started), { timeout: 30_000 }).toBe(true), + install, + ]); + } catch (error) { + // Setup that never reached the build must not hand the caller a queue it cannot settle. + await finish(); + throw error; + } + return { + release: () => writeFile(gate.release, "go"), + settle: finish, + }; +} + +function pluginRowActions(page: Page, pluginId: string) { + return page.getByRole("button", { name: `Actions for ${pluginId}`, exact: true }); +} + +async function expectPluginRowPending(page: Page, pluginId: string): Promise { + await expect(pluginRowActions(page, pluginId)).toBeDisabled(); +} + +async function expectPluginRowSettled(page: Page, pluginId: string): Promise { + await expect(pluginRowActions(page, pluginId)).toBeEnabled(); + await expect(page.getByLabel(`${pluginId} running`)).toBeVisible(); +} + +async function capturePluginSurface(page: Page, testInfo: TestInfo, name: string): Promise { + await page.screenshot({ + path: testInfo.outputPath(name), + fullPage: true, + animations: "disabled", + }); +} + +/** The wait under test is the daemon's lifecycle queue, so the scenario has to spend real time. */ +async function waitPastFormerReloadDeadline(page: Page, requestedAt: number): Promise { + const remaining = 66_000 - (Date.now() - requestedAt); + if (remaining > 0) await page.waitForTimeout(remaining); +} + +test("keeps Reload pending past a minute while the plugin queue is busy", async ({ + page, + pluginEnvironment, +}, testInfo) => { + test.setTimeout(180_000); + const { client, directory } = pluginEnvironment; + const pluginDirectory = await createDirectoryPlugin( + directory, + "e2e-plugin", + "Exercises the complete local plugin lifecycle", + "Plugin v1", + ); + + // Observe before the app loads: its daemon socket comes up with the page. + const reload = observePluginReload(page); + await gotoAppShell(page); + await openPluginSettings(page); + // Plugins on is a precondition here, not the subject: set it through the daemon instead of the + // switch, whose transient "Plugins enabled" confirmation this scenario must not depend on. + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installPluginSource({ source: pluginDirectory }); + await expectPluginRowSettled(page, "e2e-plugin"); + + const queue = await holdPluginLifecycleQueue(client, directory); + try { + await selectPluginAction(page, "e2e-plugin", "Reload"); + await reload.waitForRequest(); + // Pending: the row's actions stay disabled, so a second reload cannot be submitted. + await expectPluginRowPending(page, "e2e-plugin"); + await capturePluginSurface(page, testInfo, "plugin-reload-pending.png"); + + await waitPastFormerReloadDeadline(page, reload.requestedAt()); + + // Past the former deadline the reload is still pending, the row shows its previous state, and + // no timeout error reached the surface. + await expectPluginRowPending(page, "e2e-plugin"); + await expect(page.getByLabel("e2e-plugin running")).toBeVisible(); + await expect(page.getByTestId("plugin-management-feedback")).toHaveCount(0); + await capturePluginSurface(page, testInfo, "plugin-reload-past-deadline.png"); + + await queue.release(); + // The daemon's answer only arrives once the plugin really started again; a request timeout + // would have shown up as feedback during the wait above. + await reload.waitForResponse(); + expect(reload.requestCount()).toBe(1); + await expect(page.getByTestId("plugin-management-feedback")).toContainText( + "Reloaded e2e-plugin", + ); + await expectPluginRowSettled(page, "e2e-plugin"); + await capturePluginSurface(page, testInfo, "plugin-reload-completed.png"); + } finally { + await queue.settle(); + } +}); diff --git a/packages/cli/tests/e2e/plugin-lifecycle.test.ts b/packages/cli/tests/e2e/plugin-lifecycle.test.ts index 3e71b6b608..ee5b525c12 100644 --- a/packages/cli/tests/e2e/plugin-lifecycle.test.ts +++ b/packages/cli/tests/e2e/plugin-lifecycle.test.ts @@ -29,6 +29,116 @@ async function git(cwd: string, args: string[]): Promise { await execFileAsync("git", args, { cwd }); } +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor( + condition: () => Promise, + description: string, + timeoutMs = 60_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) return; + await sleep(50); + } + throw new Error(`Timed out waiting for ${description}`); +} + +interface ObservedRequest { + type: string; + message: Record; + observedAt: number; +} + +/** + * Observes real CLI requests while forwarding them to the isolated daemon. + * + * Requests are recorded where the CLI sends them, so a scenario can measure a wait from the + * request boundary instead of from process start. + */ +async function startRequestObserver(upstreamPort: number): Promise<{ + port: number; + requests: ObservedRequest[]; + waitForRequest: (type: string) => Promise; + close: () => Promise; +}> { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const requests: ObservedRequest[] = []; + server.on("connection", (socket, request) => { + const upstream = new WebSocket(`ws://127.0.0.1:${upstreamPort}${request.url}`, { + headers: { authorization: request.headers.authorization ?? "" }, + }); + const ready = once(upstream, "open"); + socket.on("message", async (data, isBinary) => { + const frame = JSON.parse(data.toString()) as { + type?: string; + message?: { type?: string } & Record; + }; + if (frame.type === "session" && typeof frame.message?.type === "string") { + requests.push({ + type: frame.message.type, + message: frame.message, + observedAt: Date.now(), + }); + } + await ready; + upstream.send(data, { binary: isBinary }); + }); + upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); + socket.on("close", () => upstream.close()); + upstream.on("close", () => socket.close()); + }); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + return { + port: address.port, + requests, + waitForRequest: async (type: string) => { + await waitFor( + async () => requests.some((entry) => entry.type === type), + `${type} to reach the daemon`, + ); + return requests.find((entry) => entry.type === type)!.observedAt; + }, + close: async () => { + for (const socket of server.clients) socket.close(); + await promisify(server.close.bind(server))(); + }, + }; +} + +/** Build command that blocks the plugin lifecycle queue until the test writes the gate file. */ +const gateWaitBuild = [ + 'const fs = require("node:fs");', + 'fs.writeFileSync(process.argv[1], "started");', + "const wait = () => {", + " if (fs.existsSync(process.argv[2])) return;", + " setTimeout(wait, 50);", + "};", + "wait();", +].join("\n"); + +/** Plugin whose every activation appends one line, so a test can count real reactivations. */ +function activationCountingPlugin(logPath: string): string { + return [ + 'import { appendFileSync } from "node:fs";', + `const activationLog = ${JSON.stringify(logPath)};`, + "export default function contribute(plugin: unknown) {", + ' appendFileSync(activationLog, "activate\\n");', + " void plugin;", + " return () => undefined;", + "}", + ].join("\n"); +} + +async function countActivations(logPath: string): Promise { + const content = await readFile(logPath, "utf8").catch(() => ""); + return content.split("\n").filter(Boolean).length; +} + async function main(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-cli-e2e-")); const gitDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-git-cli-e2e-")); @@ -48,31 +158,11 @@ async function main(): Promise { await writeFile(path.join(directory, "index.server.ts"), pluginSource); // Observe real CLI requests while forwarding them to the isolated daemon. - const proxy = new WebSocketServer({ host: "127.0.0.1", port: 0 }); - const sources: string[] = []; - proxy.on("connection", (socket, request) => { - const upstream = new WebSocket(`ws://127.0.0.1:${context.port}${request.url}`, { - headers: { authorization: request.headers.authorization ?? "" }, - }); - const ready = once(upstream, "open"); - socket.on("message", async (data, isBinary) => { - const message = JSON.parse(data.toString()); - if ( - message.type === "session" && - message.message.type === "plugin.source.install.request" - ) { - sources.push(message.message.source); - } - await ready; - upstream.send(data, { binary: isBinary }); - }); - upstream.on("message", (data, isBinary) => socket.send(data, { binary: isBinary })); - socket.on("close", () => upstream.close()); - upstream.on("close", () => socket.close()); - }); - await once(proxy, "listening"); - const address = proxy.address(); - assert.ok(address && typeof address !== "string"); + const observer = await startRequestObserver(context.port); + const installedSources = () => + observer.requests + .filter((entry) => entry.type === "plugin.source.install.request") + .map((entry) => entry.message.source); const sibling = path.join(context.workDir, "x"); const caller = path.join(context.workDir, "caller"); await mkdir(caller); @@ -96,12 +186,12 @@ async function main(): Promise { ["~", context.paseoHome, context.paseoHome, []], ["~/.:.", `${context.paseoHome}:.`, context.paseoHome, []], ] as const) { - sources.length = 0; + observer.requests.length = 0; const result = await context.paseo( - ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${address.port}`, "--json"], + ["plugin", "add", source, ...extraArgs, "--host", `127.0.0.1:${observer.port}`, "--json"], { cwd: caller }, ); - assert.deepEqual(sources, [expectedSource]); + assert.deepEqual(installedSources(), [expectedSource]); assert.equal(result.exitCode, 0, result.stderr); assert.equal(JSON.parse(result.stdout).path, expectedDirectory); const removed = await context.paseo(["plugin", "remove", "relative-cli", "--json"]); @@ -109,17 +199,16 @@ async function main(): Promise { } // An absolute path belongs to the daemon's platform, which can differ from the CLI's. const windowsSource = `C:\\${path.basename(context.workDir)}\\missing:sub`; - sources.length = 0; + observer.requests.length = 0; const missing = await context.paseo( - ["plugin", "add", windowsSource, "--host", `127.0.0.1:${address.port}`, "--json"], + ["plugin", "add", windowsSource, "--host", `127.0.0.1:${observer.port}`, "--json"], { cwd: caller }, ); - assert.deepEqual(sources, [windowsSource]); + assert.deepEqual(installedSources(), [windowsSource]); assert.equal(missing.exitCode, 1); assert.match(missing.stderr, /Plugin directory does not exist/); } finally { - for (const socket of proxy.clients) socket.close(); - await promisify(proxy.close.bind(proxy))(); + await observer.close(); } const install = await context.paseo(["plugin", "install", directory, "--json"]); @@ -242,6 +331,134 @@ async function main(): Promise { assert.equal(enable.exitCode, 0, enable.stderr); assert.equal(JSON.parse(enable.stdout).status, "running"); + // A reload that finishes after the client's former one-minute deadline must still report its + // real result. The wait is the daemon's own lifecycle queue, so it runs on real wall-clock time. + const gateDirectory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-reload-e2e-")); + const buildStarted = path.join(gateDirectory, "build-started"); + const buildGate = path.join(gateDirectory, "build-gate"); + const activationLog = path.join(gateDirectory, "activations.log"); + const targetDirectory = path.join(gateDirectory, "slow-reload-target"); + const slowRepository = path.join(gateDirectory, "slow-build"); + const reloadObserver = await startRequestObserver(context.port); + let slowInstall: ReturnType | undefined; + let slowReload: ReturnType | undefined; + try { + await mkdir(targetDirectory); + await writeFile( + path.join(targetDirectory, "paseo-plugin.json"), + JSON.stringify({ + id: "slow-reload-target", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile( + path.join(targetDirectory, "index.server.ts"), + activationCountingPlugin(activationLog), + ); + const targetInstall = await context.paseo(["plugin", "install", targetDirectory, "--json"]); + assert.equal(targetInstall.exitCode, 0, targetInstall.stderr); + assert.equal(await countActivations(activationLog), 1); + + await mkdir(slowRepository); + await git(slowRepository, ["init", "-b", "main"]); + await git(slowRepository, ["config", "user.name", "Paseo Tests"]); + await git(slowRepository, ["config", "user.email", "paseo@example.test"]); + await writeFile( + path.join(slowRepository, "paseo-plugin.json"), + JSON.stringify({ + id: "slow-build", + requirements: { paseo: `>=${resolveCliVersion()}` }, + build: [[process.execPath, "-e", gateWaitBuild, buildStarted, buildGate]], + }), + ); + await writeFile(path.join(slowRepository, "index.server.ts"), pluginSource); + await git(slowRepository, ["add", "-A"]); + await git(slowRepository, ["commit", "-m", "blocking build"]); + + // This install holds the plugin lifecycle queue while its build waits for the gate. + slowInstall = context.paseo( + ["plugin", "add", `git:${pathToFileURL(slowRepository).href}`, "--json"], + { timeout: 180_000 }, + ); + // A failed launch must not arrive as an unhandled rejection: that ends this direct Node run + // before the finally block below releases the build gate. Its result is still asserted. + void slowInstall.catch(() => undefined); + await waitFor( + async () => + readFile(buildStarted, "utf8").then( + () => true, + () => false, + ), + "the blocking build to start", + ); + + slowReload = context.paseo( + [ + "plugin", + "reload", + "slow-reload-target", + "--host", + `127.0.0.1:${reloadObserver.port}`, + "--json", + ], + { timeout: 180_000 }, + ); + // Same as the install above: a launch failure must not end the run before the cleanup. + void slowReload.catch(() => undefined); + const reloadRequestedAt = await reloadObserver.waitForRequest("plugin.reload.request"); + + // The interim listing is not the reload's outcome: it still reports the plugin running and + // no restart has happened while the reload waits behind the queue. + const duringReload = await context.paseo(["plugin", "ls", "slow-reload-target", "--json"]); + assert.equal(duringReload.exitCode, 0, duringReload.stderr); + assert.equal(JSON.parse(duringReload.stdout)[0].status, "running"); + assert.equal(await countActivations(activationLog), 1); + + const heldMs = Date.now() - reloadRequestedAt; + if (heldMs < 66_000) { + await sleep(66_000 - heldMs); + } + await writeFile(buildGate, "go"); + + const [buildResult, reloadResult] = await Promise.all([slowInstall, slowReload]); + assert.equal(buildResult.exitCode, 0, buildResult.stderr); + assert.equal(reloadResult.exitCode, 0, reloadResult.stderr); + assert.equal(JSON.parse(reloadResult.stdout).status, "running"); + assert.equal(await countActivations(activationLog), 2); + + // A plugin that cannot start still fails its reload with the daemon's error. + const brokenDirectory = path.join(gateDirectory, "broken-plugin"); + await mkdir(brokenDirectory); + await writeFile( + path.join(brokenDirectory, "paseo-plugin.json"), + JSON.stringify({ + id: "broken-reload", + requirements: { paseo: `>=${resolveCliVersion()}` }, + }), + ); + await writeFile(path.join(brokenDirectory, "index.server.ts"), "export default broken !!!"); + const brokenInstall = await context.paseo(["plugin", "install", brokenDirectory, "--json"]); + assert.equal(brokenInstall.exitCode, 1); + assert.match(brokenInstall.stderr, /broken-reload|broken|failed/i); + const brokenReload = await context.paseo(["plugin", "reload", "broken-reload", "--json"], { + timeout: 60_000, + }); + assert.equal(brokenReload.exitCode, 1); + assert.match(brokenReload.stderr, /broken-reload|broken|failed/i); + + for (const pluginId of ["slow-reload-target", "slow-build", "broken-reload"]) { + const removed = await context.paseo(["plugin", "remove", pluginId, "--json"]); + assert.equal(removed.exitCode, 0, removed.stderr); + } + } finally { + // Release the gate, then let the install and the reload settle before their files go: the + // build polls for the gate file, so cleanup must not delete it while the child still waits. + await writeFile(buildGate, "go").catch(() => undefined); + await Promise.all([slowInstall?.catch(() => undefined), slowReload?.catch(() => undefined)]); + await reloadObserver.close(); + await rm(gateDirectory, { recursive: true, force: true }); + } + const remove = await context.paseo(["plugin", "remove", "cli-e2e", "--json"]); assert.equal(remove.exitCode, 0, remove.stderr); const removeGit = await context.paseo(["plugin", "remove", "git-cli-e2e", "--json"]); diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index c02e1720cb..6b82922841 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1963,6 +1963,200 @@ test("gets a structured plugin log snapshot", async () => { ]); }); +test("keeps a plugin reload pending past the session RPC deadline", async () => { + useHeartbeatClock(); + try { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const reload = client.reloadPlugin("example"); + let settled: "pending" | "resolved" | "rejected" = "pending"; + void reload.then( + () => { + settled = "resolved"; + return null; + }, + () => { + settled = "rejected"; + return null; + }, + ); + + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual({ + type: "plugin.reload.request", + requestId: expect.any(String), + pluginId: "example", + }); + + // A daemon that reloads for almost two minutes is still reloading, not a failed request. + await vi.advanceTimersByTimeAsync(114_034); + expect(settled).toBe("pending"); + + // Another plugin's reload finishing early must not settle this request. + mock.triggerMessage( + wrapSessionMessage({ + type: "plugin.reload.response", + payload: { + requestId: "req-other-plugin", + plugin: { id: "other", path: "/plugins/other", enabled: true, status: "running" }, + }, + }), + ); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe("pending"); + + mock.triggerMessage( + wrapSessionMessage({ + type: "plugin.reload.response", + payload: { + requestId: request.requestId, + plugin: { id: "example", path: "/plugins/example", enabled: true, status: "running" }, + }, + }), + ); + + await expect(reload).resolves.toEqual({ + id: "example", + path: "/plugins/example", + enabled: true, + status: "running", + }); + } finally { + vi.useRealTimers(); + } +}); + +test("keeps enable and disable inside the session RPC deadline", async () => { + useHeartbeatClock(); + try { + for (const action of ["enable", "disable"] as const) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const pending = + action === "enable" ? client.enablePlugin("example") : client.disablePlugin("example"); + let settled = false; + void pending.then( + () => { + settled = true; + return null; + }, + () => { + settled = true; + return null; + }, + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: `plugin.${action}.request`, + requestId: expect.any(String), + pluginId: "example", + }); + + await vi.advanceTimersByTimeAsync(59_999); + expect(settled).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await expect(pending).rejects.toThrow("Timeout waiting for message (60000ms)"); + } + } finally { + vi.useRealTimers(); + } +}); + +test("reports a plugin reload failure that arrives after the session RPC deadline", async () => { + useHeartbeatClock(); + try { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const reload = client.reloadPlugin("example"); + const request = parseSentFrame(mock.sent[0]); + + await vi.advanceTimersByTimeAsync(114_034); + mock.triggerMessage( + wrapSessionMessage({ + type: "rpc_error", + payload: { + requestId: request.requestId, + requestType: "plugin.reload.request", + error: "Request failed: Plugin failed to start: example", + code: "handler_error", + }, + }), + ); + + await expect(reload).rejects.toThrow( + "Request failed: Plugin failed to start: example requestType=plugin.reload.request code=handler_error", + ); + } finally { + vi.useRealTimers(); + } +}); + +test("rejects a pending plugin reload when the connection closes", async () => { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger: createMockLogger(), + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const reload = client.reloadPlugin("example"); + expect(parseSentFrame(mock.sent[0])).toMatchObject({ type: "plugin.reload.request" }); + + mock.triggerClose({ code: 1006, reason: "network lost" }); + + await expect(reload).rejects.toThrow(/network lost|disconnected|closed/i); + + // Reconnecting must not resend the lost reload: only the caller decides to reload again. + const reconnecting = client.connect(); + mock.triggerOpen(); + await reconnecting; + expect(mock.sent).toEqual([]); +}); + test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => { useHeartbeatClock(); const logger = createMockLogger(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1aa0d3e995..e0e4d9ddc5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -5692,6 +5692,10 @@ export class DaemonClient { requestId, message: { type: `plugin.${action}.request`, requestId, pluginId }, responseType: `plugin.${action}.response`, + // Reload tears the plugin down and starts it again before the daemon answers, and it queues + // behind other plugin lifecycle work. Wait for that answer instead of failing the reload at + // the request deadline while the daemon keeps loading it. + ...(action === "reload" ? { timeout: 0 } : {}), }); return payload.plugin; } diff --git a/public-docs/cli.md b/public-docs/cli.md index 48f37e89de..c8f3cc0e1a 100644 --- a/public-docs/cli.md +++ b/public-docs/cli.md @@ -244,6 +244,13 @@ paseo plugin enable my-plugin paseo plugin remove my-plugin ``` +`paseo plugin reload ` reports the result of the reload itself: it waits while the daemon stops +and starts the plugin, however long that takes, and it queues behind the daemon's other plugin work. +Cancelling the command or losing the connection does not stop the daemon's work, and no listing or +log entry settles what a cancelled reload did — the [Plugin reference](/docs/plugins/reference) +covers those interim states. Running the reload again is what reports the result: it waits behind the +outstanding one, then stops and starts the plugin once more. + Bare `owner/slug` installs a reviewed registry listing. Use `git:owner/repository` for GitHub source, or `./plugin` for a local directory. Append `:` for a plugin in a monorepo. diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 525ffe1423..cc8023b251 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -2269,6 +2269,12 @@ remove Command Center items and clear the installation's query state. An already remains as unavailable until its matching contribution returns or the user closes it. Panel render failures stay inside the plugin error boundary. +Reload queues behind the daemon's other plugin work, such as a running installation or preparation +command, and the daemon answers when the plugin is running again or has failed. Nothing reads the +plugin's status as the reload's outcome: while the reload is queued, `plugin ls`, Settings, and the +logs show the plugin's previous state, and while the daemon stops and starts it, a status read +reports `failed` because the plugin is absent from the runtime catalog until its start completes. + ## Plugin sources Paste one of these source identifiers into **Settings → Plugins**, or pass it to From 1b450e487e8398aa5c58934ed42e6ee174f4b906 Mon Sep 17 00:00:00 2001 From: sulthan zahran Date: Sat, 17 Oct 2026 16:51:24 +0700 Subject: [PATCH 02/13] fix(claude): don't treat a wrapper command's --version output as Claude Code's version (#7209) resolveClaudeCodeVersion() probes --version and parsed any output with the generic fallback in parseClaudeCodeVersion. With a wrapper command (e.g. cux), that output is the wrapper's own version, which then failed every minimumClaudeCodeVersion gate and silently hid Opus 5.5, Opus 5, Fable 5, Sonnet 5.5 and Haiku 5.5. Only trust output that identifies itself as Claude Code (x.y.z (Claude Code)); anything else falls into the existing unknown-version path that does not gate models. --- .../agent/providers/claude/agent.test.ts | 30 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 8 ++++- .../agent/providers/claude/models.test.ts | 27 +++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index d07b0572b7..d6b869f1e0 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -575,6 +575,36 @@ describe("ClaudeAgentClient binary resolution", () => { await expect(resolveClaudeCodeVersion()).resolves.toMatch(/^\d+\.\d+\.\d+$/); }); + test("rejects a wrapper command's own version output instead of reading it as Claude Code's", async () => { + const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-")); + const wrapperPath = path.join(wrapperDir, "wrapper"); + await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n'); + try { + await expect( + resolveClaudeCodeVersion({ + command: { mode: "replace", argv: [process.execPath, wrapperPath] }, + }), + ).rejects.toThrow("Unable to parse Claude Code version from --version output"); + } finally { + await fs.rm(wrapperDir, { recursive: true, force: true }); + } + }); + + test("resolves the version when the command forwards the Claude Code banner", async () => { + const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-version-")); + const wrapperPath = path.join(wrapperDir, "wrapper"); + await fs.writeFile(wrapperPath, 'console.log("2.1.302 (Claude Code)");\n'); + try { + await expect( + resolveClaudeCodeVersion({ + command: { mode: "replace", argv: [process.execPath, wrapperPath] }, + }), + ).resolves.toBe("2.1.302"); + } finally { + await fs.rm(wrapperDir, { recursive: true, force: true }); + } + }); + test("loads user, project, and local Claude settings", async () => { const queryReturn = vi.fn(); queryReturn.mockResolvedValue(undefined); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 7517a87029..b12ab706da 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1839,7 +1839,13 @@ export async function resolveClaudeCodeVersion( timeout: 5_000, signal, }); - const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`); + // A wrapper command (the `command` provider setting) prints its own version here + // instead of Claude Code's. Only trust output that identifies itself as Claude Code, + // so a wrapper's version can't mis-gate models behind a minimumClaudeCodeVersion. + const rawVersionOutput = `${stdout}\n${stderr}`; + const version = /\d+\.\d+\.\d+\s+\(Claude Code\)/i.test(rawVersionOutput) + ? parseClaudeCodeVersion(rawVersionOutput) + : null; if (!version) { throw new Error("Unable to parse Claude Code version from --version output"); } diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 16d13b9d68..0daa5e533a 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -559,6 +559,33 @@ describe("ClaudeAgentClient.fetchCatalog", () => { expect(models.map((model) => model.id)).not.toContain("claude-opus-5[1m]"); expect(models.map((model) => model.id)).not.toContain("claude-opus-5"); }); + + it("keeps version-gated models when the probe command cannot identify Claude Code", async () => { + const configDir = await createClaudeConfigDir({}); + const wrapperDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-wrapper-")); + const wrapperPath = path.join(wrapperDir, "wrapper"); + await fs.writeFile(wrapperPath, 'console.log("cux 0.4.1");\n'); + try { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + runtimeSettings: { + command: { mode: "replace", argv: [process.execPath, wrapperPath] }, + env: { CLAUDE_CONFIG_DIR: configDir }, + }, + discoverModels: async () => [], + }); + + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: configDir, + force: false, + }); + + expect(models.map((model) => model.id)).toContain("claude-opus-5-5"); + } finally { + await fs.rm(wrapperDir, { recursive: true, force: true }); + } + }); }); describe("normalizeClaudeRuntimeModelId", () => { From 4a32f958488ad0b2e9187ba75a303dce34b3d494 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 17 Oct 2026 17:20:06 +0700 Subject: [PATCH 03/13] fix(app): soften the compact composer shadow in light mode (#7214) The compact composer used a hardcoded box shadow shared by both themes, so it read as a heavy band on light backgrounds. It now uses the shared sm shadow token, whose light value is raised from 0.02 to 0.08 opacity so the lift stays visible. Dark sm is unchanged. --- packages/app/src/composer/input/input.tsx | 2 +- packages/app/src/styles/theme.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index c6c267a38f..9fdab3fa28 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -2226,7 +2226,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ }, // Lifts the composer off the chat it sits under on phones. inputWrapperCompact: { - boxShadow: "0px 4px 16px rgba(0, 0, 0, 0.2)", + ...theme.shadow.sm, }, // Dotted says "this surface is the same box, but there is nothing to type // into it" without swapping the border colour, which reads as an error state. diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 8e1e77aac7..977a382c41 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -741,7 +741,7 @@ export const darkPureBlackTheme = buildDarkTheme(pureBlackDarkColors); const lightShadow = { sm: { - shadowColor: "rgba(0, 0, 0, 0.02)", + shadowColor: "rgba(0, 0, 0, 0.08)", shadowOffset: { width: 0, height: 2 }, shadowRadius: 8, elevation: 2, From 06cdc5a0b83a2f2f6875613441a86aaa96e59c15 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:55:19 +0200 Subject: [PATCH 04/13] fix(app): open nested Markdown folder links without dot prefixes (#7210) Explicit Markdown hrefs were rejected by the filename heuristic used for inferred text. Let authored nested paths resolve without a recognized extension while preserving inline-code, automatic-link, and domain handling. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/e2e/browser/folder-links.spec.ts | 12 +++++- .../app/src/assistant-file-links/parse.ts | 19 ++++++--- .../src/assistant-file-links/resolver.test.ts | 42 +++++++++++++++++++ .../app/src/assistant-file-links/resolver.ts | 1 + 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/browser/folder-links.spec.ts b/packages/app/e2e/browser/folder-links.spec.ts index 7e1bfac1ae..8c1339a8c6 100644 --- a/packages/app/e2e/browser/folder-links.spec.ts +++ b/packages/app/e2e/browser/folder-links.spec.ts @@ -16,13 +16,21 @@ async function expectFolderContents(page: Page) { ).toBeVisible(); } -for (const folder of ["docs/guides", "docs/guides/", "docs/reports.v1", ".github/workflows"]) { +for (const folder of [ + "./docs/guides", + "./docs/guides/", + "./docs/reports.v1", + "./.github/workflows", + "docs/guides", + "docs/guides/", + "docs/指南", +]) { test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => { const session = await seedMockAgentWorkspace({ repoPrefix: "folder-link-repro-", title: "Folder link reproduction", initialPrompt: "Show the folder link", - featureValues: { mockAssistantResponse: `[Open folder](./${folder})` }, + featureValues: { mockAssistantResponse: `[Open folder](${folder})` }, }); try { await mkdir(path.join(session.cwd, folder), { recursive: true }); diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 1904c11907..c195a277cf 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -30,6 +30,8 @@ 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 = @@ -278,6 +280,7 @@ export function parseAssistantFileLink( const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, { workspaceRoot: options.workspaceRoot, decodeHref: options.decodeHref, + allowExtensionlessNestedPaths: options.allowExtensionlessNestedPaths, }); if (relativeTarget) { return relativeTarget; @@ -411,7 +414,7 @@ function parseWorkspaceRelativeFileLink( function parseLocalPathParts( value: string, - options: Pick = {}, + options: Pick = {}, ): { path: string; lines: Pick; @@ -437,7 +440,7 @@ function parseLocalPathParts( if (options.decodeHref) { pathPart = safeDecodeURIComponent(pathPart); } - if (!isPlausibleAssistantLocalPath(pathPart)) { + if (!isPlausibleAssistantLocalPath(pathPart, options)) { return null; } return { @@ -458,7 +461,7 @@ function parseLocalPathParts( decodedBeforeHash = safeDecodeURIComponent(beforeHash); } const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash; - if (!isPlausibleAssistantLocalPath(plausiblePath)) { + if (!isPlausibleAssistantLocalPath(plausiblePath, options)) { return null; } @@ -583,7 +586,10 @@ function getHeuristicLocalPath(value: string): string | null { return beforeHash; } -function isPlausibleAssistantLocalPath(pathValue: string): boolean { +function isPlausibleAssistantLocalPath( + pathValue: string, + options: Pick = {}, +): boolean { const normalized = normalizePathToken(pathValue); if (!normalized) { return false; @@ -607,7 +613,10 @@ function isPlausibleAssistantLocalPath(pathValue: string): boolean { if (segments.length > 1) { const lastSegment = segments[segments.length - 1]; - return !isDomainLikePathSegment(firstSegment) && isPlausibleAssistantFileName(lastSegment); + return ( + !isDomainLikePathSegment(firstSegment) && + (options.allowExtensionlessNestedPaths === true || 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 384c3b6857..854c358627 100644 --- a/packages/app/src/assistant-file-links/resolver.test.ts +++ b/packages/app/src/assistant-file-links/resolver.test.ts @@ -18,6 +18,48 @@ const CONTEXT: AssistantFileLinkContext = { workspaceRoot: "/Users/test/project", }; +it.each(["docs/guides", "docs/guides/", "docs/%E6%8C%87%E5%8D%97"])( + "resolves an explicit extensionless folder href: %s", + (href) => { + expect(classifyForResolution({ href }, CONTEXT)).toMatchObject({ + kind: "resolved", + value: { + kind: "file", + target: { path: `${CONTEXT.workspaceRoot}/${decodeURIComponent(href).replace(/\/$/, "")}` }, + }, + }); + }, +); + +it.each(["docs/guides", "origin/main"])("leaves extensionless inline code alone: %s", (text) => { + expect(classifyForResolution({ href: text, text, sourceType: "inline-code" }, CONTEXT)).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); +}); + +it.each([{ markup: "linkify" }, { sourceInfo: "auto" }])( + "keeps auto-recognized extensionless text ignored: %j", + (origin) => { + expect( + classifyForResolution({ href: "docs/guides", text: "docs/guides", ...origin }, CONTEXT), + ).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); + }, +); + +it.each(["example.com/guides", "docs/guides?view=all"])( + "does not turn URL-like hrefs into workspace paths: %s", + (href) => { + expect(classifyForResolution({ href }, CONTEXT)).toEqual({ + kind: "resolved", + value: { kind: "ignored" }, + }); + }, +); + 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 774d39278b..59057ba0ca 100644 --- a/packages/app/src/assistant-file-links/resolver.ts +++ b/packages/app/src/assistant-file-links/resolver.ts @@ -117,6 +117,7 @@ export function classifyForResolution( const classification = classifyAssistantFileLink(token, { workspaceRoot: context.workspaceRoot, decodeHref: tokenInfo.fromHref, + allowExtensionlessNestedPaths: tokenInfo.fromHref && !isLinkifiedSource(source), }); if (!classification) { return { kind: "resolved", value: { kind: "ignored" } }; 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 05/13] 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 06/13] 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 07/13] 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 08/13] fix(markdown): render own-line images as blocks (#7237) An image alone on its line, inside a paragraph with other lines, rendered inline on iOS and web: the library only treats sole-child paragraph images as blocks, and with hardSoftBreaks every newline is a break node. The source adaptation now gives such images their own paragraph (keeping quote markers and list indentation), so all platforms render them as blocks with image margins. Images sharing a line with text stay inline. Verified through the library's md4c wasm parser in the renderer tests. --- docs/markdown-renderer.md | 2 +- .../src/components/markdown/renderer.test.ts | 53 +++++++++++++++++++ .../app/src/components/markdown/source.ts | 42 ++++++++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/docs/markdown-renderer.md b/docs/markdown-renderer.md index 418095730a..a4d6a93b5e 100644 --- a/docs/markdown-renderer.md +++ b/docs/markdown-renderer.md @@ -8,7 +8,7 @@ Native selection belongs to the library's text view. Keep an assistant message i Workspace file links use the shared file-link resolver. The workspace supplies its client, root and open-file action once; chat can supply the agent's more specific context. URLs use the same link press path. Recognized inline-code paths stay tappable. The library callback carries the tapped occurrence’s syntax context. Preserve it when opening files: explicit Markdown links decode URL escapes, while recognized inline-code paths preserve literal percent characters and use suffix lookup. Identical URL strings in different occurrences must stay distinct. Explicit absolute workspace links receive a relative hover title; existing authored titles remain intact. -Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. +Resolve workspace image paths over the existing session RPC, then give the library a cached native file URL or web blob URL. HTTP(S) images pass through unchanged. This preserves relay, SSH and local socket connections, which do not expose a daemon HTTP endpoint. Each mounted renderer owns its image acquisition identity, so a later reply reads a rewritten path again while the earlier mounted reply retains its snapshot. Image taps open the attachment lightbox. File previews resolve relative images against the Markdown file's directory. An image alone on its line becomes its own paragraph before the library sees it, so it renders as a block on every platform; the library only treats sole-child paragraph images as blocks, and with hard soft breaks every newline is a break node, which left own-line images inline on iOS and web. Images that share a line with text stay inline. ## Engine constraints diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 218cdc8884..8791cf40de 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,5 +1,8 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; +import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown"; +import { markInlineImages } from "react-native-enriched-markdown/src/web/utils"; +import type { ASTNode } from "react-native-enriched-markdown/src/web/types"; import { QueryClient } from "@tanstack/react-query"; import { decodeMarkdownImage, getMarkdownImageStatus } from "./image-decoding"; import { describe, expect, it } from "vitest"; @@ -31,6 +34,56 @@ describe("shared Markdown source adaptation", () => { "> - ![Image]()\n\n[picture]: ../a.png", ); }); + it("gives an image alone on its line its own paragraph", () => { + expect( + prepareMarkdownSource("Before the shot:\n![Shot](https://x/a.png)\nAfter it.").render(), + ).toBe("Before the shot:\n\n![Shot](https://x/a.png)\n\nAfter it."); + expect(prepareMarkdownSource("![A](https://x/a.png)\n![B](https://x/b.png)").render()).toBe( + "![A](https://x/a.png)\n\n![B](https://x/b.png)", + ); + expect( + prepareMarkdownSource( + "Line one\\\n[![Linked](https://x/a.png)](https://x)\nLine three", + ).render(), + ).toBe("Line one\n\n[![Linked](https://x/a.png)](https://x)\n\nLine three"); + }); + it("keeps images that share a line with text inline", () => { + const source = "Icon ![i](https://x/i.png) in text\n![a](https://x/a.png) caption\nNext line"; + expect(prepareMarkdownSource(source).render()).toBe(source); + }); + it("splits own-line images inside quotes and list items without ending the container", () => { + expect(prepareMarkdownSource("> Quote text\n> ![Shot](https://x/a.png)\n> More").render()).toBe( + "> Quote text\n>\n> ![Shot](https://x/a.png)\n>\n> More", + ); + expect( + prepareMarkdownSource("- Item text\n ![Shot](https://x/a.png)\n More\n- Next").render(), + ).toBe("- Item text\n\n ![Shot](https://x/a.png)\n\n More\n- Next"); + expect(prepareMarkdownSource("- ![Shot](https://x/a.png)\n Caption line").render()).toBe( + "- ![Shot](https://x/a.png)\n\n Caption line", + ); + }); + it("renders own-line images as blocks through the library parser", async () => { + const source = + "Before:\n![Shot](https://x/a.png)\nIcon ![i](https://x/i.png) in text\n> Quoted\n> ![Q](https://x/q.png)"; + const tree = await parseMarkdown(prepareMarkdownSource(source).render(), { + hardSoftBreaks: true, + latexMath: false, + }); + markInlineImages(tree); + const images: ASTNode[] = []; + const collect = (node: ASTNode) => { + if (node.type === "Image") images.push(node); + node.children?.forEach(collect); + }; + collect(tree); + expect( + images.map((image) => [image.attributes?.url, image.attributes?.isInline === true]), + ).toEqual([ + ["https://x/a.png", false], + ["https://x/i.png", true], + ["https://x/q.png", false], + ]); + }); it("escapes spoiler delimiters only in prose", () => { expect(prepareMarkdownSource("Show ||words|| and `||code||`.").render()).toBe( "Show \\|\\|words\\|\\| and `||code||`.", diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index fc6f7463a5..9fda25b728 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -5,7 +5,7 @@ import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; import type { AssistantFileLinkSource } from "@/assistant-file-links"; import { decodeString } from "micromark-util-decode-string"; import { fromMarkdown } from "mdast-util-from-markdown"; -import type { Link, Nodes, Root } from "mdast"; +import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast"; // Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex. // The Unicode flag makes Metro expand it to escapes rejected by native engines. @@ -130,10 +130,50 @@ export function prepareMarkdownSource( end, }); } + // Line boundaries already turned into paragraph breaks, by offset of their newline. + const splitBoundaries = new Set(); + function isImageLike(node: PhrasingContent): boolean { + if (node.type === "image" || node.type === "imageReference") return true; + return ( + (node.type === "link" || node.type === "linkReference") && + node.children.length === 1 && + isImageLike(node.children[0]) + ); + } + // An image alone on its line becomes its own paragraph, so every platform renders + // it as a block. The library only treats sole-child paragraph images as blocks; + // with hardSoftBreaks every newline is a break node, which made these inline. + function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) { + for (const child of paragraph.children) { + const childStart = child.position?.start.offset; + const childEnd = child.position?.end.offset; + if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue; + const lineStart = source.lastIndexOf("\n", childStart - 1) + 1; + const newline = source.indexOf("\n", childEnd); + const lineEnd = newline === -1 ? source.length : newline; + const prefix = source.slice(lineStart, childStart); + const suffix = source.slice(childEnd, lineEnd); + const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix); + const aloneAfter = /^\s*\\?$/.test(suffix); + if (!aloneBefore || !aloneAfter) continue; + // Continuation lines inside quotes and lists keep their quote markers on the blank line. + const blank = prefix.replace(/[^>]/g, " ").trimEnd(); + const split = (boundary: number) => { + if (splitBoundaries.has(boundary)) return; + splitBoundaries.add(boundary); + // A trailing backslash was a hard break; at a paragraph end it would render literally. + const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary; + edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` }); + }; + if (lineStart > start) split(lineStart - 1); + if (lineEnd < end) split(lineEnd); + } + } function visit(node: Nodes) { const start = node.position?.start.offset; const end = node.position?.end.offset; if (start !== undefined && end !== undefined) { + if (node.type === "paragraph") splitStandaloneImages(node, start, end); if (node.type === "link") { const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot); if (title !== null) From 86ab49153ae7aa43017fadda2eb115879d05c5b8 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:54:51 +0200 Subject: [PATCH 09/13] 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 10/13] 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 11/13] 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 ( - +