diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 98aad68ca..7632d567f 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -1750,6 +1750,7 @@ describe("createSyncRemoteCommandService", () => { "chat.prepareCrossMachineHandoff", "chat.validateCrossMachineSource", "chat.preflightCrossMachineDestination", + "chat.fastForwardCrossMachineHandoffLane", "chat.acceptCrossMachineHandoff", "chat.markCrossMachineHandoff", "chat.getContextUsage", @@ -1866,6 +1867,10 @@ describe("createSyncRemoteCommandService", () => { blockingErrors: [], warnings: [], }); + const fastForwardCrossMachineHandoffLane = vi.fn().mockResolvedValue({ + ok: true, + head: capsule.source.headSha, + }); const acceptCrossMachineHandoff = vi.fn().mockResolvedValue({ handoffId: capsule.handoffId, laneId: "lane-2", @@ -1879,6 +1884,7 @@ describe("createSyncRemoteCommandService", () => { prepareCrossMachineHandoff, validateCrossMachineSource, preflightCrossMachineDestination, + fastForwardCrossMachineHandoffLane, acceptCrossMachineHandoff, markCrossMachineHandoff, }, @@ -1918,6 +1924,10 @@ describe("createSyncRemoteCommandService", () => { sourceBranchRef: capsule.source.branchRef, sourceHeadSha: capsule.source.headSha, }))).resolves.toMatchObject({ providerAuthorized: true, modelAvailable: true }); + await expect(service.execute(makePayload("chat.fastForwardCrossMachineHandoffLane", { + laneId: " lane-2 ", + expectedHead: ` ${capsule.source.headSha} `, + }))).resolves.toEqual({ ok: true, head: capsule.source.headSha }); await expect(service.execute(makePayload("chat.acceptCrossMachineHandoff", { capsule, capsuleFingerprint: "fingerprint-1", @@ -1936,6 +1946,10 @@ describe("createSyncRemoteCommandService", () => { sourceBranchRef: capsule.source.branchRef, sourceHeadSha: capsule.source.headSha, }); + expect(fastForwardCrossMachineHandoffLane).toHaveBeenCalledWith({ + laneId: "lane-2", + expectedHead: capsule.source.headSha, + }); expect(acceptCrossMachineHandoff).toHaveBeenCalledWith({ capsule, capsuleFingerprint: "fingerprint-1", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index b5d820219..2f121a2a8 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -884,6 +884,21 @@ function parseCrossMachineDestinationPreflightArgs( }; } +function parseFastForwardCrossMachineHandoffLaneArgs( + value: Record, +): { laneId: string; expectedHead: string } { + return { + laneId: requireString( + value.laneId, + "chat.fastForwardCrossMachineHandoffLane requires laneId.", + ), + expectedHead: requireString( + value.expectedHead, + "chat.fastForwardCrossMachineHandoffLane requires expectedHead.", + ), + }; +} + function parsePrepareCrossMachineHandoffArgs( value: Record, ): AgentChatPrepareCrossMachineHandoffArgs { @@ -4116,6 +4131,10 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio requireService(args.agentChatService, "Agent chat service not available.").preflightCrossMachineDestination( parseCrossMachineDestinationPreflightArgs(payload), )); + register("chat.fastForwardCrossMachineHandoffLane", { viewerAllowed: true, queueable: false }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").fastForwardCrossMachineHandoffLane( + parseFastForwardCrossMachineHandoffLaneArgs(payload), + )); register("chat.acceptCrossMachineHandoff", { viewerAllowed: true, queueable: false }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").acceptCrossMachineHandoff( parseAcceptCrossMachineHandoffArgs(payload), diff --git a/apps/ade-cli/src/tuiClient/__tests__/externalSessionBrowser.test.ts b/apps/ade-cli/src/tuiClient/__tests__/externalSessionBrowser.test.ts index 3b67b1349..9a82789f3 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/externalSessionBrowser.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/externalSessionBrowser.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest"; import type { ExternalSessionSummary } from "../../../../desktop/src/shared/types/externalSessions"; import { clampExternalSessionBrowserContent, + externalSessionAnchors, externalSessionBrowserActions, + externalSessionRowTitle, normalizeExternalSessionListResult, visibleExternalSessions, } from "../externalSessionBrowser"; @@ -51,6 +53,60 @@ describe("externalSessionBrowser helpers", () => { .toEqual(["cursor", "newest"]); }); + it("searches the sampled conversation, not just the title", () => { + const rows = [ + session({ id: "titled", title: "Release plan", updatedAt: 50 }), + session({ + id: "sampled", + title: null, + preview: "look at the checkout flow", + updatedAt: 40, + messages: [ + { role: "user", text: "look at the checkout flow", at: 1 }, + { role: "assistant", text: "The regression is in the coupon validator.", at: 2 }, + ], + }), + ]; + + expect(visibleExternalSessions(rows, "all", "coupon").map((row) => row.id)).toEqual(["sampled"]); + // Rows without a message sample still match on the legacy fields only. + expect(visibleExternalSessions(rows, "all", "release").map((row) => row.id)).toEqual(["titled"]); + }); + + it("names titleless rows by their opening prompt and never repeats it as an anchor", () => { + const titleless = session({ + title: null, + preview: "look at\n the checkout flow", + messages: [ + { role: "user", text: "look at the checkout flow", at: 1 }, + { role: "assistant", text: "Found it:\nthe coupon validator", at: 2 }, + ], + }); + + expect(externalSessionRowTitle(titleless)).toBe("look at the checkout flow"); + expect(externalSessionAnchors(titleless)).toEqual({ + started: null, + latest: "Found it: the coupon validator", + }); + + const titled = session({ title: "Checkout", preview: "look at the checkout flow" }); + // Older hosts send no `messages`; the opening prompt is then the only anchor. + expect(externalSessionAnchors(titled)).toEqual({ + started: "look at the checkout flow", + latest: null, + }); + + const singleTurn = session({ + title: "Checkout", + preview: "look at the checkout flow", + messages: [{ role: "user", text: "look at the checkout flow", at: 1 }], + }); + expect(externalSessionAnchors(singleTurn)).toEqual({ + started: "look at the checkout flow", + latest: null, + }); + }); + it("clamps selection and action indexes after filter changes", () => { const content: Extract = { kind: "external-session-browser", diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index fc15ae590..4f9f16168 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -14,8 +14,10 @@ import type { TuiChatSessionSummary } from "../adeApi"; import { theme } from "../theme"; import { externalSessionActionKey, + externalSessionAnchors, externalSessionBrowserActions, externalSessionProviderLabel, + externalSessionRowTitle, shortenCwd, visibleExternalSessions, } from "../externalSessionBrowser"; @@ -1608,7 +1610,8 @@ function ExternalSessionBrowserPane({ const absoluteIndex = windowStart + offset; const selected = absoluteIndex === selectedIndex; const brand = theme.provider(session.provider); - const title = session.title?.trim() || session.preview?.trim() || session.id; + const title = externalSessionRowTitle(session); + const anchors = externalSessionAnchors(session); const cwd = shortenCwd(session.cwd, 4); const messageCount = typeof session.messageCount === "number" && Number.isFinite(session.messageCount) ? `${compactNumber(session.messageCount)} prompt${session.messageCount === 1 ? "" : "s"}` @@ -1634,9 +1637,28 @@ function ExternalSessionBrowserPane({ {` ${endTruncate(badges.join(" · "), Math.max(8, inner - 2))}`} ) : null} - {selected && session.preview?.trim() ? ( - - {` ${endTruncate(session.preview.trim(), Math.max(8, inner - 2))}`} + {/* + Two anchors beat one snippet when you are picking a session out of a + list: the opening ask says which task this was, the last turn says how + far it got. Selected row only, one truncated line each, and each is + suppressed when the heading above already carries that text — so the + common titleless row still costs exactly the one line the old preview + did, spent on newer information. + */} + {selected && anchors.started ? ( + + {" started "} + + {endTruncate(anchors.started, Math.max(8, inner - 12))} + + + ) : null} + {selected && anchors.latest ? ( + + {" latest "} + + {endTruncate(anchors.latest, Math.max(8, inner - 12))} + ) : null} {selected && session.alreadyImported && session.importedSessionRef ? ( diff --git a/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts b/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts index be950d976..b7f194616 100644 --- a/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts +++ b/apps/ade-cli/src/tuiClient/externalSessionBrowser.ts @@ -75,6 +75,42 @@ export function externalSessionProviderLabel(provider: ExternalSessionProvider | return provider === "all" ? "All" : PROVIDER_LABELS[provider] ?? provider; } +/** Collapses provider text to one line so a TUI row can print it without wrapping. */ +function collapseLine(value: string | null | undefined): string | null { + const collapsed = value?.replace(/\s+/gu, " ").trim(); + return collapsed ? collapsed : null; +} + +/** + * Row heading, mirroring the desktop browser's `sessionHeading`: a provider-persisted + * title when there is one, otherwise the opening prompt, otherwise the raw id. Most + * Claude CLI transcripts carry no title, so without the prompt fallback the row would + * name itself with a uuid. + */ +export function externalSessionRowTitle(session: ExternalSessionSummary): string { + return collapseLine(session.title) ?? collapseLine(session.preview) ?? session.id; +} + +/** + * The two anchors the selected row prints: what the thread started as, and where it left + * off. Either can be absent — an older host sends no `messages` at all, and neither + * anchor may repeat text the row is already showing. The TUI gives each anchor a single + * truncated line, so a duplicate reads as a rendering bug rather than as emphasis. + */ +export function externalSessionAnchors(session: ExternalSessionSummary): { + started: string | null; + latest: string | null; +} { + const heading = externalSessionRowTitle(session); + const started = collapseLine(session.preview); + const messages = session.messages ?? []; + const latest = collapseLine(messages[messages.length - 1]?.text); + return { + started: started && started !== heading ? started : null, + latest: latest && latest !== heading && latest !== started ? latest : null, + }; +} + export function normalizeExternalSessionListResult(result: unknown): ExternalSessionSummary[] { if (Array.isArray(result)) return result as ExternalSessionSummary[]; if (!result || typeof result !== "object") return []; @@ -97,6 +133,10 @@ export function visibleExternalSessions( session.preview, session.cwd, session.id, + // Search the whole thread sample, not just the title: the words you remember + // from a conversation are usually in the conversation, and provider titles are + // frequently absent entirely. Matches the desktop browser's corpus. + ...(session.messages ?? []).map((message) => message.text), ] .filter((value): value is string => typeof value === "string" && value.length > 0) .some((value) => value.toLowerCase().includes(needle)); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 5d3970753..5df211be7 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -555,6 +555,7 @@ export const ADE_ACTION_ALLOWLIST: Partial = {}) { } const HANDOFF_TEST_SHA = "1234567890abcdef1234567890abcdef12345678"; +const HANDOFF_BEHIND_SHA = "0123456789abcdef0123456789abcdef01234567"; +const HANDOFF_DIVERGED_SHA = "fedcba9876543210fedcba9876543210fedcba98"; function installCleanCrossMachineGitFixture( branchRef = "feature/primary", @@ -1599,6 +1601,65 @@ function installCleanCrossMachineGitFixture( }); } +function installCrossMachineDestinationLaneGitFixture(options: { + branchRef?: string; + laneHead?: string; + remoteHead?: string; + dirtyPorcelain?: string; + ancestorExitCode?: number; + behindBy?: number; + expectedReachable?: boolean; + mergeExitCode?: number; +} = {}) { + const branchRef = options.branchRef ?? "feature/primary"; + const laneHead = options.laneHead ?? HANDOFF_BEHIND_SHA; + const remoteHead = options.remoteHead ?? HANDOFF_TEST_SHA; + let merged = false; + vi.mocked(runGit).mockImplementation(async (args) => { + const command = args.join(" "); + if (command === "status --porcelain=v1") { + return { stdout: options.dirtyPorcelain ?? "", stderr: "", exitCode: 0 }; + } + if (command === "rev-parse HEAD") { + return { stdout: `${merged ? HANDOFF_TEST_SHA : laneHead}\n`, stderr: "", exitCode: 0 }; + } + if (command === `rev-parse refs/remotes/origin/${branchRef}`) { + return { stdout: `${remoteHead}\n`, stderr: "", exitCode: 0 }; + } + if (command === `rev-parse --verify refs/heads/${branchRef}`) { + return { stdout: "", stderr: "", exitCode: 1 }; + } + if (command === `cat-file -e ${HANDOFF_TEST_SHA}^{commit}`) { + return { + stdout: "", + stderr: options.expectedReachable === false ? "missing commit" : "", + exitCode: options.expectedReachable === false ? 1 : 0, + }; + } + if (command === `merge-base --is-ancestor ${laneHead} ${HANDOFF_TEST_SHA}`) { + return { stdout: "", stderr: "", exitCode: options.ancestorExitCode ?? 0 }; + } + if (command === `rev-list --count ${laneHead}..${HANDOFF_TEST_SHA}`) { + return { stdout: `${options.behindBy ?? 3}\n`, stderr: "", exitCode: 0 }; + } + if (command === `merge --ff-only ${HANDOFF_TEST_SHA}`) { + const exitCode = options.mergeExitCode ?? 0; + if (exitCode === 0) merged = true; + return { + stdout: exitCode === 0 ? "Fast-forward\n" : "", + stderr: exitCode === 0 ? "" : "not possible to fast-forward", + exitCode, + }; + } + if (args[0] === "ls-remote") { + return { stdout: `${HANDOFF_TEST_SHA}\trefs/heads/${branchRef}\n`, stderr: "", exitCode: 0 }; + } + if (args[0] === "check-ref-format") return { stdout: `${branchRef}\n`, stderr: "", exitCode: 0 }; + if (args[0] === "fetch") return { stdout: "", stderr: "", exitCode: 0 }; + return { stdout: "", stderr: "", exitCode: 0 }; + }); +} + function gzipForkContent(content: Buffer | string) { const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8"); return { @@ -2295,6 +2356,7 @@ describe("createAgentChatService", () => { expect(service.handoffSession).toBeTypeOf("function"); expect(service.prepareCrossMachineHandoff).toBeTypeOf("function"); expect(service.preflightCrossMachineDestination).toBeTypeOf("function"); + expect(service.fastForwardCrossMachineHandoffLane).toBeTypeOf("function"); expect(service.acceptCrossMachineHandoff).toBeTypeOf("function"); expect(service.markCrossMachineHandoff).toBeTypeOf("function"); expect(service.sendMessage).toBeTypeOf("function"); @@ -5383,6 +5445,26 @@ describe("createAgentChatService", () => { })).rejects.toThrow("transcript history without fork mode"); }); + it("refuses a cross-machine Droid fork with the portability message", async () => { + installCleanCrossMachineGitFixture(); + const { service } = createService(); + const source = await service.createSession({ + laneId: "lane-1", + provider: "droid", + model: "custom:claude-sonnet-5-thinking-32000", + modelId: "droid/custom:claude-sonnet-5-thinking-32000", + }); + + await expect(service.prepareCrossMachineHandoff({ + sourceSessionId: source.id, + handoffId: "handoff-droid-fork-1", + targetModelId: "droid/custom:claude-sonnet-5-thinking-32000", + mode: "fork", + })).rejects.toThrow( + "Droid sessions aren't portable between machines yet. Use a brief handoff instead.", + ); + }); + it("packages Claude native history and bounded ADE transcript envelopes", async () => { installCleanCrossMachineGitFixture(); installRealTranscriptParser(); @@ -5954,6 +6036,143 @@ describe("createAgentChatService", () => { }); }); + describe("destination lane fast-forward", () => { + const preflightArgs = { + targetModelId: "anthropic/claude-sonnet-5" as const, + sourceBranchRef: "feature/primary", + sourceHeadSha: HANDOFF_TEST_SHA, + }; + + const authorizeClaude = () => { + vi.mocked(detectAllAuth).mockResolvedValue([ + { type: "cli-subscription", cli: "claude", path: "/usr/local/bin/claude", authenticated: true, verified: true }, + ] as any); + }; + + it("offers a fast-forward for an existing clean lane strictly behind the source", async () => { + installCrossMachineDestinationLaneGitFixture({ behindBy: 3 }); + authorizeClaude(); + const { service } = createService(); + + const result = await service.preflightCrossMachineDestination(preflightArgs); + + expect(result.blockingErrors).toEqual([]); + expect(result.laneFastForward).toEqual({ + laneId: "lane-1", + laneName: "Primary", + behindBy: 3, + }); + expect(result.warnings).toContain( + "Destination lane 'Primary' is 3 commits behind — ADE can fast-forward it.", + ); + }); + + it.each([ + ["dirty", { dirty: true, rebaseInProgress: false }, "uncommitted changes"], + ["rebasing", { dirty: false, rebaseInProgress: true }, "rebase in progress"], + ])("does not offer a fast-forward for a %s destination lane", async (_label, status, message) => { + installCrossMachineDestinationLaneGitFixture(); + authorizeClaude(); + const { service, laneService } = createService(); + const lane = await laneService.getSummary("lane-1"); + Object.assign(lane.status, status); + + const result = await service.preflightCrossMachineDestination(preflightArgs); + + expect(result).not.toHaveProperty("laneFastForward"); + expect(result.blockingErrors.join(" ")).toContain(message); + expect(result.blockingErrors).toContain("Destination lane 'Primary' is not at the source commit."); + }); + + it("labels a non-ancestor destination lane as diverged", async () => { + installCrossMachineDestinationLaneGitFixture({ + laneHead: HANDOFF_DIVERGED_SHA, + ancestorExitCode: 1, + }); + authorizeClaude(); + const { service } = createService(); + + const result = await service.preflightCrossMachineDestination(preflightArgs); + + expect(result).not.toHaveProperty("laneFastForward"); + expect(result.blockingErrors).toContain( + "Destination lane 'Primary' has diverged from the source commit.", + ); + }); + + it("refuses to fast-forward a dirty lane", async () => { + installCrossMachineDestinationLaneGitFixture({ dirtyPorcelain: " M src/dirty.ts\n" }); + const { service } = createService(); + + await expect(service.fastForwardCrossMachineHandoffLane({ + laneId: "lane-1", + expectedHead: HANDOFF_TEST_SHA, + })).rejects.toThrow("has uncommitted changes and cannot be fast-forwarded"); + expect(vi.mocked(runGit).mock.calls.some(([args]) => args[0] === "merge")).toBe(false); + }); + + it("refuses to fast-forward while a rebase is in progress", async () => { + installCrossMachineDestinationLaneGitFixture(); + const { service, laneService } = createService(); + const lane = await laneService.getSummary("lane-1"); + lane.status.rebaseInProgress = true; + + await expect(service.fastForwardCrossMachineHandoffLane({ + laneId: "lane-1", + expectedHead: HANDOFF_TEST_SHA, + })).rejects.toThrow("while a rebase is in progress"); + }); + + it("refuses when the fetched branch has moved away from the expected source commit", async () => { + installCrossMachineDestinationLaneGitFixture({ remoteHead: HANDOFF_DIVERGED_SHA }); + const { service } = createService(); + + await expect(service.fastForwardCrossMachineHandoffLane({ + laneId: "lane-1", + expectedHead: HANDOFF_TEST_SHA, + })).rejects.toThrow("no longer points at the expected source commit"); + }); + + it("refuses an unreachable expected source commit", async () => { + installCrossMachineDestinationLaneGitFixture({ expectedReachable: false }); + const { service } = createService(); + + await expect(service.fastForwardCrossMachineHandoffLane({ + laneId: "lane-1", + expectedHead: HANDOFF_TEST_SHA, + })).rejects.toThrow("cannot reach the expected source commit"); + }); + + it("refuses when the lane head is not an ancestor of the expected source commit", async () => { + installCrossMachineDestinationLaneGitFixture({ + laneHead: HANDOFF_DIVERGED_SHA, + ancestorExitCode: 1, + }); + const { service } = createService(); + + await expect(service.fastForwardCrossMachineHandoffLane({ + laneId: "lane-1", + expectedHead: HANDOFF_TEST_SHA, + })).rejects.toThrow("current commit is not an ancestor"); + }); + + it("fast-forwards a clean behind lane with git merge --ff-only", async () => { + installCrossMachineDestinationLaneGitFixture(); + const { service } = createService(); + + await expect(service.fastForwardCrossMachineHandoffLane({ + laneId: "lane-1", + expectedHead: HANDOFF_TEST_SHA, + })).resolves.toEqual({ ok: true, head: HANDOFF_TEST_SHA }); + expect(runGit).toHaveBeenCalledWith( + ["merge", "--ff-only", HANDOFF_TEST_SHA], + expect.objectContaining({ cwd: tmpRoot, timeoutMs: 60_000 }), + ); + expect(vi.mocked(runGit).mock.calls.some(([args]) => args.includes("--force"))).toBe(false); + expect(vi.mocked(runGit).mock.calls.some(([args]) => args[0] === "reset")).toBe(false); + }); + }); + it("builds a bounded portable capsule only after the source is clean and published", async () => { installCleanCrossMachineGitFixture(); const { service, laneService } = createService(); @@ -5995,6 +6214,79 @@ describe("createAgentChatService", () => { expect(JSON.stringify(prepared.capsule)).not.toContain("sdkSessionId"); }); + it("inherits source settings into the capsule while preserving explicit target overrides", async () => { + installCleanCrossMachineGitFixture(); + const { service } = createService(); + const source = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/openai/gpt-5.4", + }); + Object.assign(source, { + reasoningEffort: "high", + fastMode: true, + claudePermissionMode: "bypassPermissions", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "config-toml", + opencodePermissionMode: "full-auto", + droidPermissionMode: "agi", + permissionMode: "full-auto", + cursorModeId: "plan", + cursorConfigValues: { privateSetting: "machine-local" }, + }); + + const inherited = await service.prepareCrossMachineHandoff({ + sourceSessionId: source.id, + handoffId: "handoff-settings-inherited-1", + targetModelId: "opencode/openai/gpt-5.4-mini", + }); + expect(inherited.capsule.target).toEqual({ + targetModelId: "opencode/openai/gpt-5.4-mini", + reasoningEffort: "high", + fastMode: true, + claudePermissionMode: "bypassPermissions", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + codexConfigSource: "config-toml", + opencodePermissionMode: "full-auto", + droidPermissionMode: "agi", + permissionMode: "full-auto", + cursorModeId: "plan", + }); + + const overridden = await service.prepareCrossMachineHandoff({ + sourceSessionId: source.id, + handoffId: "handoff-settings-overridden-1", + targetModelId: "opencode/openai/gpt-5.4-mini", + reasoningEffort: null, + fastMode: false, + claudePermissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + permissionMode: "edit", + cursorModeId: null, + }); + expect(overridden.capsule.target).toEqual({ + targetModelId: "opencode/openai/gpt-5.4-mini", + reasoningEffort: null, + fastMode: false, + claudePermissionMode: "plan", + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags", + opencodePermissionMode: "edit", + droidPermissionMode: "auto-low", + permissionMode: "edit", + cursorModeId: null, + }); + expect(overridden.capsule.target).not.toHaveProperty("cursorConfigValues"); + }); + it("removes credentials from remote URLs, titles, and lane names before transfer", async () => { installCleanCrossMachineGitFixture( "feature/primary", @@ -6201,7 +6493,9 @@ describe("createAgentChatService", () => { const remoteAuthCalls = vi.mocked(runGit).mock.calls.filter(([args]) => args[0] === "ls-remote" || args[0] === "fetch", ); - expect(remoteAuthCalls).toHaveLength(2); + // Preflight now fetches before evaluating whether an existing lane is a + // strict ancestor; acceptance fetches again at its mutation boundary. + expect(remoteAuthCalls).toHaveLength(3); for (const [, options] of remoteAuthCalls) { expect(options?.env).toMatchObject({ GIT_TERMINAL_PROMPT: "0", diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 262636a95..aef758268 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -329,6 +329,7 @@ import type { import { isPtySendPreDeliveryError, isTrackedAgentCliToolType, + providerSupportsCrossMachineHandoffFork, providerSupportsHandoffFork, } from "../../../shared/types"; import { providerDisplayLabel } from "../../../shared/pendingInputLabels"; @@ -29057,15 +29058,15 @@ export function createAgentChatService(args: { } const targetProvider = resolveProviderGroupForModel(targetDescriptor); if (mode === "fork") { - if (!providerSupportsHandoffFork(managed.session.provider)) { + if (!providerSupportsCrossMachineHandoffFork(managed.session.provider)) { + if (managed.session.provider === "droid") { + throw new Error(DROID_FORK_NOT_PORTABLE_MESSAGE); + } throw new Error("This chat's provider can't fork history. Use a brief handoff instead."); } if (targetProvider !== managed.session.provider) { throw new Error(FORK_SAME_PROVIDER_MESSAGE); } - if (managed.session.provider === "droid") { - throw new Error(DROID_FORK_NOT_PORTABLE_MESSAGE); - } } const portableText = (value: string): string => { let next = redactSecrets(value); @@ -29140,6 +29141,22 @@ export function createAgentChatService(args: { ? portableText(sourceSession.title.trim()).slice(0, 300) : null; const sourceLaneName = portableText(lane.name).slice(0, 200); + const targetReasoningEffort = args.reasoningEffort !== undefined + ? args.reasoningEffort + : managed.session.reasoningEffort; + const targetFastMode = args.fastMode !== undefined + ? args.fastMode + : managed.session.fastMode; + const targetClaudePermissionMode = args.claudePermissionMode || managed.session.claudePermissionMode; + const targetCodexApprovalPolicy = args.codexApprovalPolicy || managed.session.codexApprovalPolicy; + const targetCodexSandbox = args.codexSandbox || managed.session.codexSandbox; + const targetCodexConfigSource = args.codexConfigSource || managed.session.codexConfigSource; + const targetOpenCodePermissionMode = args.opencodePermissionMode || managed.session.opencodePermissionMode; + const targetDroidPermissionMode = args.droidPermissionMode || managed.session.droidPermissionMode; + const targetPermissionMode = args.permissionMode || managed.session.permissionMode; + const targetCursorModeId = args.cursorModeId !== undefined + ? args.cursorModeId + : managed.session.cursorModeId; const capsule: AgentChatCrossMachineHandoffCapsule = { version: 1, handoffId, @@ -29157,16 +29174,16 @@ export function createAgentChatService(args: { }, target: { targetModelId: targetDescriptor.id, - ...(args.reasoningEffort !== undefined ? { reasoningEffort: args.reasoningEffort } : {}), - ...(args.fastMode !== undefined ? { fastMode: args.fastMode } : {}), - ...(args.claudePermissionMode ? { claudePermissionMode: args.claudePermissionMode } : {}), - ...(args.codexApprovalPolicy ? { codexApprovalPolicy: args.codexApprovalPolicy } : {}), - ...(args.codexSandbox ? { codexSandbox: args.codexSandbox } : {}), - ...(args.codexConfigSource ? { codexConfigSource: args.codexConfigSource } : {}), - ...(args.opencodePermissionMode ? { opencodePermissionMode: args.opencodePermissionMode } : {}), - ...(args.droidPermissionMode ? { droidPermissionMode: args.droidPermissionMode } : {}), - ...(args.permissionMode ? { permissionMode: args.permissionMode } : {}), - ...(args.cursorModeId !== undefined ? { cursorModeId: args.cursorModeId } : {}), + ...(targetReasoningEffort !== undefined ? { reasoningEffort: targetReasoningEffort } : {}), + ...(targetFastMode !== undefined ? { fastMode: targetFastMode } : {}), + ...(targetClaudePermissionMode ? { claudePermissionMode: targetClaudePermissionMode } : {}), + ...(targetCodexApprovalPolicy ? { codexApprovalPolicy: targetCodexApprovalPolicy } : {}), + ...(targetCodexSandbox ? { codexSandbox: targetCodexSandbox } : {}), + ...(targetCodexConfigSource ? { codexConfigSource: targetCodexConfigSource } : {}), + ...(targetOpenCodePermissionMode ? { opencodePermissionMode: targetOpenCodePermissionMode } : {}), + ...(targetDroidPermissionMode ? { droidPermissionMode: targetDroidPermissionMode } : {}), + ...(targetPermissionMode ? { permissionMode: targetPermissionMode } : {}), + ...(targetCursorModeId !== undefined ? { cursorModeId: targetCursorModeId } : {}), // Cursor config values are machine-local and may contain private // runtime configuration. The destination resolves its own settings. }, @@ -29299,6 +29316,7 @@ export function createAgentChatService(args: { } let remoteBranchHeadSha: string | null = null; + let fetchedExpectedHead = false; if (branchRef && /^[0-9a-f]{40,64}$/i.test(expectedHead)) { const remote = await runGit(["ls-remote", "--heads", "origin", `refs/heads/${branchRef}`], { cwd: projectRoot, @@ -29311,10 +29329,38 @@ export function createAgentChatService(args: { remoteBranchHeadSha = remote.stdout.trim().split(/\s+/)[0] || null; if (!remoteBranchHeadSha) blockingErrors.push(`Origin does not contain branch '${branchRef}'.`); else if (remoteBranchHeadSha !== expectedHead) blockingErrors.push("The destination sees a different remote branch commit than the source machine."); + else { + const fetch = await runGit( + // Forced refspec: the remote-tracking ref is only a local mirror of origin, + // and if the destination still records a newer or rewound commit the + // unforced update is rejected as non-fast-forward — blocking a + // fast-forward that is otherwise safe. The equality check below is what + // actually gates the merge. + ["fetch", "origin", `+refs/heads/${branchRef}:refs/remotes/origin/${branchRef}`], + { + cwd: projectRoot, + timeoutMs: 60_000, + env: await destinationGitEnv(), + }, + ); + if (fetch.exitCode !== 0) { + blockingErrors.push(`The destination could not fetch '${branchRef}': ${fetch.stderr.trim() || "unknown Git error"}`); + } else { + const fetchedHead = await runGit(["rev-parse", `refs/remotes/origin/${branchRef}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }); + fetchedExpectedHead = fetchedHead.exitCode === 0 && fetchedHead.stdout.trim() === expectedHead; + if (!fetchedExpectedHead) { + blockingErrors.push(`The destination could not resolve origin/${branchRef} at the source commit.`); + } + } + } } } let existingLaneId: string | null = null; + let laneFastForward: AgentChatCrossMachineDestinationPreflightResult["laneFastForward"]; if (branchRef) { const lanes = await laneService.list({ includeArchived: false, includeStatus: true }); const existingLane = lanes.find((lane) => lane.branchRef.replace(/^refs\/heads\//, "") === branchRef) ?? null; @@ -29323,10 +29369,45 @@ export function createAgentChatService(args: { if (existingLane.status.dirty) blockingErrors.push(`Destination lane '${existingLane.name}' has uncommitted changes.`); if (existingLane.status.rebaseInProgress) blockingErrors.push(`Destination lane '${existingLane.name}' has a rebase in progress.`); const existingHead = await runGit(["rev-parse", "HEAD"], { cwd: existingLane.worktreePath, timeoutMs: 8_000 }); - if (existingHead.exitCode !== 0 || existingHead.stdout.trim() !== expectedHead) { - blockingErrors.push(`Destination lane '${existingLane.name}' is not at the source commit.`); - } else if (!existingLane.status.dirty && !existingLane.status.rebaseInProgress) { + const laneHead = existingHead.stdout.trim(); + if (existingHead.exitCode !== 0 || !laneHead) { + blockingErrors.push(`Destination lane '${existingLane.name}' does not have a readable commit.`); + } else if (laneHead === expectedHead && !existingLane.status.dirty && !existingLane.status.rebaseInProgress) { warnings.push(`ADE will reuse the existing clean lane '${existingLane.name}'.`); + } else if (laneHead !== expectedHead && fetchedExpectedHead) { + const ancestor = await runGit(["merge-base", "--is-ancestor", laneHead, expectedHead], { + cwd: existingLane.worktreePath, + timeoutMs: 15_000, + }); + if ( + ancestor.exitCode === 0 + && !existingLane.status.dirty + && !existingLane.status.rebaseInProgress + ) { + const count = await runGit(["rev-list", "--count", `${laneHead}..${expectedHead}`], { + cwd: existingLane.worktreePath, + timeoutMs: 15_000, + }); + const behindBy = Number.parseInt(count.stdout.trim(), 10); + if (count.exitCode === 0 && Number.isSafeInteger(behindBy) && behindBy > 0) { + laneFastForward = { + laneId: existingLane.id, + laneName: existingLane.name, + behindBy, + }; + warnings.push( + `Destination lane '${existingLane.name}' is ${behindBy} ${behindBy === 1 ? "commit" : "commits"} behind — ADE can fast-forward it.`, + ); + } else { + blockingErrors.push(`Destination lane '${existingLane.name}' is not at the source commit.`); + } + } else if (ancestor.exitCode === 1) { + blockingErrors.push(`Destination lane '${existingLane.name}' has diverged from the source commit.`); + } else { + blockingErrors.push(`Destination lane '${existingLane.name}' is not at the source commit.`); + } + } else if (laneHead !== expectedHead) { + blockingErrors.push(`Destination lane '${existingLane.name}' is not at the source commit.`); } } else { const localHead = await runGit(["rev-parse", "--verify", `refs/heads/${branchRef}`], { @@ -29342,17 +29423,14 @@ export function createAgentChatService(args: { const sourceProvider = typeof args.sourceProvider === "string" ? args.sourceProvider.trim() : ""; let forkHandoffSupport: AgentChatCrossMachineDestinationPreflightResult["forkHandoffSupport"]; if (sourceProvider) { - if (!providerSupportsHandoffFork(sourceProvider)) { + if (!providerSupportsCrossMachineHandoffFork(sourceProvider)) { forkHandoffSupport = { supported: false, - reason: sourceProvider === "cursor" - ? "Cursor chats can't fork history." - : "This chat's provider can't fork history.", - }; - } else if (sourceProvider === "droid") { - forkHandoffSupport = { - supported: false, - reason: DROID_FORK_NOT_PORTABLE_REASON, + reason: sourceProvider === "droid" + ? DROID_FORK_NOT_PORTABLE_REASON + : sourceProvider === "cursor" + ? "Cursor chats can't fork history." + : "This chat's provider can't fork history.", }; } else { const targetProvider = targetDescriptor ? resolveProviderGroupForModel(targetDescriptor) : null; @@ -29390,9 +29468,131 @@ export function createAgentChatService(args: { blockingErrors: Array.from(new Set(blockingErrors)), warnings: Array.from(new Set(warnings)), ...(forkHandoffSupport ? { forkHandoffSupport } : {}), + ...(laneFastForward ? { laneFastForward } : {}), }; }; + const fastForwardCrossMachineHandoffLane = async ( + args: { laneId: string; expectedHead: string }, + ): Promise<{ ok: true; head: string }> => { + const laneId = typeof args.laneId === "string" ? args.laneId.trim() : ""; + if (!laneId) { + throw new Error("Cross-machine handoff lane fast-forward requires a destination lane."); + } + const expectedHead = typeof args.expectedHead === "string" ? args.expectedHead.trim() : ""; + if (!/^[0-9a-f]{40,64}$/i.test(expectedHead)) { + throw new Error("Cross-machine handoff lane fast-forward received an invalid source commit."); + } + + const lane = await laneService.getSummary(laneId, { includeStatus: true }); + if (!lane || lane.archivedAt) { + throw new Error(`Cross-machine handoff lane '${laneId}' is unavailable or archived.`); + } + const branchRef = await requireGitBranchForHandoff(lane.branchRef, lane.worktreePath); + const fetch = await runGit( + ["fetch", "origin", `refs/heads/${branchRef}:refs/remotes/origin/${branchRef}`], + { + cwd: lane.worktreePath, + timeoutMs: 60_000, + env: await destinationGitEnv(), + }, + ); + if (fetch.exitCode !== 0) { + throw new Error( + `Cross-machine handoff lane '${lane.name}' could not fetch '${branchRef}': ${fetch.stderr.trim() || "unknown Git error"}`, + ); + } + const fetchedHead = await requireGitOutputForHandoff( + ["rev-parse", `refs/remotes/origin/${branchRef}`], + lane.worktreePath, + `Cross-machine handoff lane '${lane.name}' could not resolve origin/${branchRef}.`, + ); + if (fetchedHead !== expectedHead) { + throw new Error( + `Cross-machine handoff lane '${lane.name}' cannot fast-forward because origin/${branchRef} no longer points at the expected source commit.`, + ); + } + const reachable = await runGit(["cat-file", "-e", `${expectedHead}^{commit}`], { + cwd: lane.worktreePath, + timeoutMs: 8_000, + }); + if (reachable.exitCode !== 0) { + throw new Error( + `Cross-machine handoff lane '${lane.name}' cannot reach the expected source commit after fetching origin.`, + ); + } + + const refreshedLane = await laneService.getSummary(laneId, { includeStatus: true }); + if (!refreshedLane || refreshedLane.archivedAt) { + throw new Error(`Cross-machine handoff lane '${laneId}' became unavailable before it could be updated.`); + } + if (refreshedLane.status.rebaseInProgress) { + throw new Error( + `Cross-machine handoff lane '${refreshedLane.name}' cannot fast-forward while a rebase is in progress.`, + ); + } + const status = await runGit(["status", "--porcelain=v1"], { + cwd: refreshedLane.worktreePath, + timeoutMs: 15_000, + }); + if (status.exitCode !== 0) { + throw new Error( + `Cross-machine handoff lane '${refreshedLane.name}' could not be checked for local changes: ${status.stderr.trim() || "unknown Git error"}`, + ); + } + if (status.stdout.trim()) { + throw new Error( + `Cross-machine handoff lane '${refreshedLane.name}' has uncommitted changes and cannot be fast-forwarded.`, + ); + } + + const laneHead = await requireGitOutputForHandoff( + ["rev-parse", "HEAD"], + refreshedLane.worktreePath, + `Cross-machine handoff lane '${refreshedLane.name}' does not have a readable commit.`, + ); + if (laneHead === expectedHead) { + throw new Error( + `Cross-machine handoff lane '${refreshedLane.name}' is already at the expected source commit.`, + ); + } + const ancestor = await runGit(["merge-base", "--is-ancestor", laneHead, expectedHead], { + cwd: refreshedLane.worktreePath, + timeoutMs: 15_000, + }); + if (ancestor.exitCode === 1) { + throw new Error( + `Cross-machine handoff lane '${refreshedLane.name}' has diverged; its current commit is not an ancestor of the expected source commit.`, + ); + } + if (ancestor.exitCode !== 0) { + throw new Error( + `Cross-machine handoff lane '${refreshedLane.name}' ancestry could not be verified: ${ancestor.stderr.trim() || "unknown Git error"}`, + ); + } + + const merge = await runGit(["merge", "--ff-only", expectedHead], { + cwd: refreshedLane.worktreePath, + timeoutMs: 60_000, + }); + if (merge.exitCode !== 0) { + throw new Error( + `Cross-machine handoff lane '${refreshedLane.name}' could not be fast-forwarded: ${merge.stderr.trim() || "git merge --ff-only failed"}`, + ); + } + const head = await requireGitOutputForHandoff( + ["rev-parse", "HEAD"], + refreshedLane.worktreePath, + `Cross-machine handoff lane '${refreshedLane.name}' could not verify the updated commit.`, + ); + if (head !== expectedHead) { + throw new Error( + `Cross-machine handoff lane '${refreshedLane.name}' did not reach the expected source commit.`, + ); + } + return { ok: true, head }; + }; + const buildCrossMachineHandoffPrompt = (capsule: AgentChatCrossMachineHandoffCapsule): string => { const issueLines = capsule.linearIssues.map((issue) => `- ${issue.identifier}: ${issue.title}${issue.url ? ` (${issue.url})` : ""}`, @@ -41337,6 +41537,7 @@ export function createAgentChatService(args: { prepareCrossMachineHandoff, validateCrossMachineSource, preflightCrossMachineDestination, + fastForwardCrossMachineHandoffLane, acceptCrossMachineHandoff, markCrossMachineHandoff, sendMessage, diff --git a/apps/desktop/src/main/services/externalSessions/discoverClaude.ts b/apps/desktop/src/main/services/externalSessions/discoverClaude.ts index 31a752611..6df6b1ae0 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverClaude.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverClaude.ts @@ -9,6 +9,7 @@ import { normalizeExternalSessionLimit, readFileSuffix, readJsonlRecords, + recentExternalSessionMessagesFromRecords, recordWithFile, resolveHomeDir, safeReadDir, @@ -59,16 +60,20 @@ function explicitClaudeTitleFromRecords(records: unknown[]): string | null { return null; } -function claudeScanRecords(filePath: string): unknown[] { +function claudeScanRecords(filePath: string): { + prefix: unknown[]; + suffix: unknown[]; + combined: unknown[]; +} { const prefix = readJsonlRecords(filePath); const suffixText = readFileSuffix(filePath); - if (!suffixText) return prefix; + if (!suffixText) return { prefix, suffix: [], combined: prefix }; const suffixLines = suffixText.split(/\r?\n/u); if (suffixText.length > 0 && !suffixText.startsWith("{")) suffixLines.shift(); const suffix = suffixLines .map((line) => safeParseJson(line)) .filter((record): record is Record => record != null); - return [...prefix, ...suffix]; + return { prefix, suffix, combined: [...prefix, ...suffix] }; } function latestClaudeCwd(records: unknown[]): string | null { @@ -121,7 +126,8 @@ export async function discoverClaudeSessions( const records: ExternalSessionDiscoveryRecord[] = []; for (const candidate of newestById.values()) { - const jsonl = claudeScanRecords(candidate.filePath); + const scan = claudeScanRecords(candidate.filePath); + const jsonl = scan.combined; if (!isClaudeCliTranscript(jsonl)) continue; const cwd = latestClaudeCwd(jsonl); let createdAt: number | null = null; @@ -132,13 +138,14 @@ export async function discoverClaudeSessions( if (createdAt) break; } if (!cwdIsInScope(cwd, args.scopeRoots)) continue; - const firstUserText = firstUserTextFromRecords(jsonl); + const firstUserText = firstUserTextFromRecords(scan.prefix); records.push(recordWithFile({ provider: "claude", id: candidate.id, cwd, title: explicitClaudeTitleFromRecords(jsonl), preview: firstUserText, + messages: recentExternalSessionMessagesFromRecords(scan.suffix), createdAt, messageCount: countJsonlUserMessagesCheap(candidate.filePath, "claude"), filePath: candidate.filePath, diff --git a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts index f4cde105f..776eb24da 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCodex.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCodex.ts @@ -12,6 +12,8 @@ import { normalizeExternalSessionLimit, readFileSuffix, readJsonlRecords, + canonicalCodexRecords, + recentExternalSessionMessagesFromRecords, recordWithFile, resolveHomeDir, safeParseJson, @@ -33,6 +35,8 @@ import type { const CODEX_LAUNCH_BACKWARD_SCAN_CHUNK_BYTES = 256 * 1024; const CODEX_LAUNCH_BACKWARD_SCAN_MAX_BYTES = 64 * 1024 * 1024; const CODEX_LAUNCH_BACKWARD_SCAN_MAX_LINE_BYTES = 1024 * 1024; +const CODEX_RECENT_MESSAGES_BROWSE_BYTE_LIMIT = 64 * 1024; +const CODEX_RECENT_MESSAGES_EXACT_BYTE_LIMIT = 128 * 1024; type CodexIndexEntry = { id: string; @@ -427,6 +431,21 @@ function firstCodexUserText(records: unknown[]): string | null { : firstUserTextFromRecords(records); } +function recentCodexRecords(filePath: string, exactLookup: boolean): unknown[] { + const text = readFileSuffix( + filePath, + exactLookup + ? CODEX_RECENT_MESSAGES_EXACT_BYTE_LIMIT + : CODEX_RECENT_MESSAGES_BROWSE_BYTE_LIMIT, + ); + if (!text) return []; + const lines = text.split(/\r?\n/u); + if (!text.startsWith("{")) lines.shift(); + return lines + .map((line) => safeParseJson(line)) + .filter((record): record is Record => record != null); +} + function codexApprovalPolicy(payload: Record): AgentChatCodexApprovalPolicy | null { const value = ( asString(payload.approval_policy) @@ -785,5 +804,14 @@ export async function discoverCodexSessions( })); } - return sortDiscoveryRecords(Array.from(recordsById.values()), limit); + return sortDiscoveryRecords(Array.from(recordsById.values()), limit) + .map((record) => { + if (!record.sourcePath || record.sourcePath.endsWith(".jsonl.zst")) return record; + return { + ...record, + messages: recentExternalSessionMessagesFromRecords( + canonicalCodexRecords(recentCodexRecords(record.sourcePath, lookupId != null)), + ), + }; + }); } diff --git a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts index a619db26f..7d6e85678 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverCursor.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverCursor.ts @@ -224,12 +224,13 @@ export async function discoverCursorSessions( const cwd = transcriptCwd ?? candidate.trustedCwd ?? resolveCursorCwdFromSlug(candidate.projectSlug); if (!cwdIsInScope(cwd, args.scopeRoots)) continue; const existing = recordsById.get(candidate.agentId); + const firstPrompt = firstUserTextFromRecords(jsonl); recordsById.set(candidate.agentId, recordWithFile({ provider: "cursor", id: candidate.agentId, cwd: existing?.cwd ?? cwd, title: existing?.title ?? null, - preview: firstUserTextFromRecords(jsonl), + preview: firstPrompt, createdAt: existing?.createdAt ?? asEpochMs(first?.timestamp) ?? asEpochMs(asRecord(first?.message)?.timestamp), updatedAt: Math.max(existing?.updatedAt ?? 0, candidate.mtimeMs), messageCount: countJsonlUserMessagesCheap(candidate.filePath, "cursor"), diff --git a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts index 38899d57b..8a4261977 100644 --- a/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoverProviders.test.ts @@ -136,11 +136,109 @@ describe("external session provider discovery", () => { cwd, title: "Test message", preview: "this is a test message", + messages: [ + { role: "user", text: "/model", at: null }, + { + role: "user", + text: "this is a test message", + at: Date.parse("2026-07-06T10:01:00.000Z"), + }, + { role: "assistant", text: "Understood.", at: null }, + { + role: "user", + text: "this is a test message", + at: Date.parse("2026-07-06T10:02:00.000Z"), + }, + ], createdAt: Date.parse("2026-07-06T10:00:00.000Z"), }); expect(sessions[0]?.messageCount).toBe(2); }); + it("never sources a Claude first prompt or preview from suffix-only records", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const id = "12121212-1212-4212-8212-121212121212"; + const rows = [ + { type: "system", sessionId: id, cwd, entrypoint: "cli" }, + ...Array.from({ length: 79 }, (_, index) => ({ + type: "user", + sessionId: id, + cwd, + isMeta: true, + message: { role: "user", content: `metadata ${index}` }, + })), + { + type: "user", + sessionId: id, + cwd, + timestamp: "2026-07-06T10:01:00.000Z", + message: { role: "user", content: "tail-only background completion" }, + }, + ]; + writeJsonl( + path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(cwd), `${id}.jsonl`), + rows, + ); + + const [session] = await discoverClaudeSessions({ homeDir, limit: 1 }); + + expect(session).toMatchObject({ + id, + preview: null, + messages: [{ + role: "user", + text: "tail-only background completion", + at: Date.parse("2026-07-06T10:01:00.000Z"), + }], + messageCount: 1, + }); + }); + + it("keeps recoverable assistant context when the semantic prompt count is zero", async () => { + const homeDir = path.join(root, "home"); + const cwd = path.join(root, "repo"); + const id = "13131313-1313-4313-8313-131313131313"; + writeJsonl( + path.join(homeDir, ".claude", "projects", claudeProjectSlugForCwd(cwd), `${id}.jsonl`), + [ + { type: "system", sessionId: id, cwd, entrypoint: "cli" }, + { + type: "user", + sessionId: id, + cwd, + message: { + role: "user", + content: "/modelopus", + }, + }, + { + type: "assistant", + sessionId: id, + cwd, + timestamp: "2026-07-06T10:02:00.000Z", + message: { role: "assistant", content: "The model is now Opus." }, + }, + ], + ); + + const [session] = await discoverClaudeSessions({ homeDir, limit: 1 }); + + expect(session).toMatchObject({ + id, + preview: null, + messageCount: 0, + messages: [ + { role: "user", text: "/model", at: null }, + { + role: "assistant", + text: "The model is now Opus.", + at: Date.parse("2026-07-06T10:02:00.000Z"), + }, + ], + }); + }); + it("excludes Claude SDK sessions without starving older resumable CLI results", async () => { const homeDir = path.join(root, "home"); const cwd = path.join(root, "repo"); @@ -238,6 +336,11 @@ describe("external session provider discovery", () => { cwd, title: "Investigate flaky test", preview: "please fix flakes", + messages: [{ + role: "user", + text: "please fix flakes", + at: Date.parse("2026-07-06T10:01:00.000Z"), + }], updatedAt: Date.parse("2026-07-06T11:00:00.000Z"), messageCount: 1, launch: { diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts index 9e97d2154..7cd10201d 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.test.ts @@ -5,8 +5,11 @@ import { describe, expect, it } from "vitest"; import { cleanExternalSessionUserText, cleanSessionTitle, + clipExternalSessionText, + canonicalCodexRecords, countExternalSessionUserMessages, firstUserTextFromRecords, + recentExternalSessionMessagesFromRecords, resolveCursorCwdFromSlug, slashEscapedCwd, } from "./discoveryUtils"; @@ -60,7 +63,6 @@ describe("firstUserTextFromRecords", () => { }); describe("external session user text", () => { - it("counts semantic prompts and de-duplicates Codex storage representations", () => { const codexRows = [ { type: "response_item", payload: { type: "message", role: "user", content: "synthetic copy" } }, @@ -88,6 +90,123 @@ describe("external session user text", () => { expect(cleanSessionTitle("New Agent")).toBeNull(); expect(slashEscapedCwd("C:\\Users\\dev\\ADE")).toBe("C:-Users-dev-ADE"); }); + + it("strips known transport wrappers, including truncated ones", () => { + expect(cleanExternalSessionUserText( + "bcyw3zwwjtoolu_123/tmp/resultcompleted", + )).toBeNull(); + expect(cleanExternalSessionUserText( + "bcyw3zwwj/tmp/truncated", + )).toBeNull(); + expect(cleanExternalSessionUserText( + " Keep this human-authored request", + )).toBe("Keep this human-authored request"); + }); + + /** + * The markup-density gate exists to keep junk out of row *previews*. It must + * not run on `cleanExternalSessionUserText`, which also builds the imported + * chat transcript — rejecting there silently deletes real messages from + * someone's history. + */ + /** + * Regression: the count/preview reader and the messages reader each derived + * "is this a user turn" inline and had already drifted — a record with + * `type: "message"` and no explicit role counted toward `messageCount` and + * could become the preview, but was silently dropped from `messages`. They now + * share one classifier, so a record either appears in all three or none. + */ + /** + * Codex writes each turn twice — a canonical `event_msg` and a mirrored + * `response_item`. Sampling the raw rows showed every turn twice and evicted + * genuinely older exchanges from the capped window. + */ + it("drops Codex mirror rows so a turn is sampled once", () => { + const rows = [ + { type: "event_msg", payload: { type: "user_message", message: "first ask" } }, + { type: "response_item", payload: { type: "message", role: "user", content: "first ask" } }, + { type: "event_msg", payload: { type: "agent_message", message: "the answer" } }, + { type: "response_item", payload: { type: "message", role: "assistant", content: "the answer" } }, + ]; + const messages = recentExternalSessionMessagesFromRecords(canonicalCodexRecords(rows)); + expect(messages).toHaveLength(2); + expect(messages.map((m) => m.role)).toEqual(["user", "assistant"]); + }); + + it("leaves records untouched when there is no canonical form to prefer", () => { + const rows = [{ type: "response_item", payload: { type: "message", role: "user", content: "only form" } }]; + expect(canonicalCodexRecords(rows)).toHaveLength(1); + }); + + it("agrees across count, preview, and messages about what a user turn is", () => { + const records = [ + { type: "message", message: { content: "roleless message rows are user turns" } }, + ]; + expect(countExternalSessionUserMessages(records, "claude")).toBe(1); + expect(firstUserTextFromRecords(records)).toBe("roleless message rows are user turns"); + const messages = recentExternalSessionMessagesFromRecords(records); + expect(messages).toHaveLength(1); + expect(messages[0]?.role).toBe("user"); + expect(messages[0]?.text).toBe("roleless message rows are user turns"); + }); + + it("keeps markup-heavy and very short user turns intact for the import path", () => { + const jsx = "Fix this:
{title}
"; + expect(cleanExternalSessionUserText(jsx)).toBe(jsx); + expect(cleanExternalSessionUserText("ok")).toBe("ok"); + expect(cleanExternalSessionUserText("\u597d\u7684")).toBe("\u597d\u7684"); + expect(cleanExternalSessionUserText("completed")) + .toBe("completed"); + }); + + it("still keeps markup-dominant text out of previews", () => { + expect(firstUserTextFromRecords([ + { type: "user", message: { role: "user", content: "completed" } }, + { type: "user", message: { role: "user", content: "Actually fix the truncation bug" } }, + ])).toBe("Actually fix the truncation bug"); + }); + + it("clips on word boundaries without leaving a partial markup tag", () => { + expect(clipExternalSessionText("alpha beta gamma delta epsilon", 22)) + .toBe("alpha beta gamma..."); + const clippedTag = clipExternalSessionText( + "ReadablePrefixWithoutSpaces { + const rows = Array.from({ length: 10 }, (_, index) => ({ + type: "message", + timestamp: `2026-07-06T10:00:${String(index).padStart(2, "0")}.000Z`, + message: { + role: index % 2 === 0 ? "user" : "assistant", + content: index % 2 === 0 + ? `message ${index}` + : [ + { type: "text", text: `message ${index}` }, + { type: "tool_use", name: "Read", input: { path: "/private/file" } }, + ], + }, + })); + + const messages = recentExternalSessionMessagesFromRecords(rows, 99); + + expect(messages).toHaveLength(8); + expect(messages.map((message) => message.text)).toEqual( + Array.from({ length: 8 }, (_, index) => `message ${index + 2}`), + ); + expect(messages.map((message) => message.role)).toEqual([ + "user", "assistant", "user", "assistant", "user", "assistant", "user", "assistant", + ]); + expect(messages[0]?.at).toBe(Date.parse("2026-07-06T10:00:02.000Z")); + expect(JSON.stringify(messages)).not.toContain("/private/file"); + }); }); describe("resolveCursorCwdFromSlug", () => { diff --git a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts index 6fe7f39ca..9a31d1242 100644 --- a/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts +++ b/apps/desktop/src/main/services/externalSessions/discoveryUtils.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { + ExternalSessionMessage, ExternalSessionProvider, ExternalSessionSummary, } from "../../../shared/types/externalSessions"; @@ -43,6 +44,12 @@ export const JSONL_SCAN_BYTE_LIMIT = 512 * 1024; export const MESSAGE_COUNT_MAX_BYTES = 768 * 1024; export const EXTERNAL_SESSION_PREVIEW_MAX_LENGTH = 240; export const EXTERNAL_SESSION_TITLE_MAX_LENGTH = 160; +export const EXTERNAL_SESSION_MESSAGES_MAX_COUNT = 8; +export const EXTERNAL_SESSION_MESSAGE_MAX_LENGTH = 320; +// Markup-dominant transport receipts are not useful session previews, while +// ordinary prose containing a short JSX/XML fragment remains recoverable. +export const EXTERNAL_SESSION_MARKUP_TEXT_MIN_RATIO = 0.35; +const EXTERNAL_SESSION_CLIP_BOUNDARY_WINDOW_RATIO = 0.25; const PLACEHOLDER_SESSION_TITLES = new Set([ "new session", @@ -199,8 +206,7 @@ export function cleanSessionTitle(raw: string | null | undefined): string | null const normalized = title.toLowerCase(); if (PLACEHOLDER_SESSION_TITLES.has(normalized)) return null; if (/^new (?:session|chat)\s*[-:]\s*\d{4}-\d{2}-\d{2}/u.test(normalized)) return null; - if (title.length <= EXTERNAL_SESSION_TITLE_MAX_LENGTH) return title; - return `${title.slice(0, EXTERNAL_SESSION_TITLE_MAX_LENGTH - 1).trimEnd()}…`; + return clipNormalizedExternalSessionText(title, EXTERNAL_SESSION_TITLE_MAX_LENGTH, "…"); } export function asEpochMs(value: unknown): number | null { @@ -238,8 +244,20 @@ function stripTerminalControlSequences(raw: string): string { .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/gu, ""); } +/** + * Transport wrappers stripped wholesale. Every name here must be distinctive + * enough that it cannot plausibly occur in prose or pasted code — the unclosed + * form below deletes everything after the tag, so a generic word like `status` + * would truncate a real message. (`` needs no entry of its own: it only + * appears inside ``, which is stripped as a block.) + */ const EXTERNAL_SESSION_NOISE_TAGS = [ "system-reminder", + "task-notification", + "task-id", + "tool-use-id", + "output-file", + "local-command-args", "local-command-caveat", "local-command-stdout", "command-name", @@ -253,10 +271,53 @@ function stripKnownNoiseTags(raw: string): string { let text = raw; for (const tag of EXTERNAL_SESSION_NOISE_TAGS) { text = text.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, "giu"), " "); + text = text.replace(new RegExp(`^(?:\\s*<\\/${tag}\\s*>)+`, "iu"), " "); + // Suffix reads can begin or end inside a provider wrapper. Once a known + // opening tag has no close, everything after it is untrusted transport. + text = text.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*$`, "giu"), " "); } return text; } +function hasEnoughTextOutsideMarkup(text: string): boolean { + const originalLength = text.replace(/\s+/gu, "").length; + if (!originalLength) return false; + const outsideMarkup = text + .replace(/<[^>]*>/gu, " ") + .replace(/<\/?[A-Za-z][A-Za-z0-9:_-]*\b[^<>\n]*$/gu, " ") + .replace(/\s+/gu, " ") + .trim(); + const outsideLength = outsideMarkup.replace(/\s+/gu, "").length; + return outsideLength > 0 + && outsideLength / originalLength >= EXTERNAL_SESSION_MARKUP_TEXT_MIN_RATIO + && /[\p{L}\p{N}_]{3}/u.test(outsideMarkup); +} + +/** + * Strip transport noise and return whatever real text is left. + * + * Deliberately does NOT apply the markup-density gate: this cleaner also feeds + * `externalChatHistoryImport`, which builds the *imported chat transcript*. + * Rejecting markup-heavy or very short turns there silently deletes real user + * messages from someone's history — a pasted JSX snippet, or a reply as ordinary + * as "ok". Preview selection wants that gate; history does not. + */ +function cleanExternalSessionText(raw: string): string | null { + const cleaned = stripKnownNoiseTags(stripTerminalControlSequences(raw)).trim(); + return cleaned || null; +} + +/** + * Preview-only: text that is mostly markup makes a useless row heading, which is + * how a raw `` blob once became a session's entire preview. + * Import and message-count paths must not use this. + */ +function previewWorthyText(raw: string | null | undefined): string | null { + const cleaned = raw?.trim(); + if (!cleaned) return null; + return hasEnoughTextOutsideMarkup(cleaned) ? cleaned : null; +} + function isProviderGeneratedNotice(text: string): boolean { return /^token limit reached\.\s+use \/limits\b/iu.test(text) || /^caveat:\s+the messages below were generated by the user while running local commands\b/iu.test(text); @@ -300,10 +361,10 @@ export function cleanExternalSessionUserText(raw: string): string | null { if (markerIndex >= 0) text = text.slice(markerIndex + markerLength); } - const cleaned = stripKnownNoiseTags(text) - .trim(); + const cleaned = cleanExternalSessionText(text); + if (!cleaned) return null; const normalizedForNotice = cleaned.replace(/\s+/gu, " "); - return cleaned && !isProviderGeneratedNotice(normalizedForNotice) ? cleaned : null; + return !isProviderGeneratedNotice(normalizedForNotice) ? cleaned : null; } export function stripAdeGuidance(raw: string): string { @@ -317,8 +378,26 @@ export function clipExternalSessionText( const stripped = stripAdeGuidance(raw ?? ""); if (!stripped) return null; const normalized = stripped.replace(/\s+/gu, " ").trim(); + return clipNormalizedExternalSessionText(normalized, max, "..."); +} + +function clipNormalizedExternalSessionText( + normalized: string, + max: number, + suffix: string, +): string | null { + if (!normalized) return null; if (normalized.length <= max) return normalized; - return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}...`; + const contentBudget = Math.max(0, max - suffix.length); + if (contentBudget <= 0) return suffix.slice(0, Math.max(0, max)) || null; + let end = contentBudget; + const lastWhitespace = normalized.lastIndexOf(" ", contentBudget); + const boundaryFloor = Math.floor(contentBudget * (1 - EXTERNAL_SESSION_CLIP_BOUNDARY_WINDOW_RATIO)); + if (lastWhitespace >= boundaryFloor) end = lastWhitespace; + let clipped = normalized.slice(0, end).trimEnd(); + const lastOpen = clipped.lastIndexOf("<"); + if (lastOpen > clipped.lastIndexOf(">")) clipped = clipped.slice(0, lastOpen).trimEnd(); + return clipped ? `${clipped}${suffix}` : null; } function extractUserFacingText(value: unknown, depth = 0): string | null { @@ -337,38 +416,111 @@ function extractUserFacingText(value: unknown, depth = 0): string | null { return extractText(record, depth); } -function externalSessionUserTextFromRecord(record: unknown): string | null { +/** + * The shape both record readers need, parsed once. + * + * These two used to derive role, type, text, and timestamp with near-identical + * inline chains — and they had already drifted: a record with `type: "message"` + * and no explicit role counted toward `messageCount` and could become the + * preview, but was dropped from `messages`. One classifier means they cannot + * disagree about what a record is. + */ +type ExternalSessionRecordShape = { + explicitRole: string | null; + topType: string | null; + payloadType: string | null; + rawText: string | null; + at: number | null; + isMeta: boolean; +}; + +function externalSessionRecordShape(record: unknown): ExternalSessionRecordShape | null { const obj = asRecord(record); - if (!obj || obj.isMeta === true) return null; + if (!obj) return null; const payload = asRecord(obj.payload); const message = asRecord(obj.message); const payloadMessage = asRecord(payload?.message); - const role = ( - asString(message?.role) - ?? asString(payloadMessage?.role) - ?? asString(payload?.role) - ?? asString(obj.role) - )?.toLowerCase() ?? null; - const topType = asString(obj.type)?.toLowerCase() ?? null; - const payloadType = asString(payload?.type)?.toLowerCase() ?? null; - const explicitNonUserRole = role === "assistant" || role === "system" || role === "tool" || role === "developer"; - const isUser = role === "user" - || (!explicitNonUserRole && ( - topType === "user" - || topType === "user_message" - || payloadType === "user" - || payloadType === "user_message" - || ((!role || role === "user") && (topType === "message" || payloadType === "message")) - )); - if (!isUser) return null; - const text = extractUserFacingText(payloadMessage?.content) - ?? extractUserFacingText(message?.content) - ?? extractUserFacingText(payload?.message) - ?? extractUserFacingText(payload?.content) - ?? extractUserFacingText(payload?.text) - ?? extractUserFacingText(obj.content) - ?? extractUserFacingText(obj.text); - return text ? cleanExternalSessionUserText(text) : null; + return { + explicitRole: ( + asString(message?.role) + ?? asString(payloadMessage?.role) + ?? asString(payload?.role) + ?? asString(obj.role) + )?.toLowerCase() ?? null, + topType: asString(obj.type)?.toLowerCase() ?? null, + payloadType: asString(payload?.type)?.toLowerCase() ?? null, + rawText: extractUserFacingText(payloadMessage?.content) + ?? extractUserFacingText(message?.content) + ?? extractUserFacingText(payload?.message) + ?? extractUserFacingText(payload?.content) + ?? extractUserFacingText(payload?.text) + ?? extractUserFacingText(obj.content) + ?? extractUserFacingText(obj.text), + at: asEpochMs(obj.timestamp) + ?? asEpochMs(message?.timestamp) + ?? asEpochMs(payload?.timestamp) + ?? asEpochMs(payloadMessage?.timestamp), + isMeta: obj.isMeta === true, + }; +} + +/** Single definition of "this record is a user turn", shared by both readers. */ +function isUserShape(shape: ExternalSessionRecordShape): boolean { + const { explicitRole, topType, payloadType } = shape; + if (explicitRole === "user") return true; + if (explicitRole === "assistant" || explicitRole === "system" + || explicitRole === "tool" || explicitRole === "developer") return false; + return topType === "user" + || topType === "user_message" + || payloadType === "user" + || payloadType === "user_message" + || topType === "message" + || payloadType === "message"; +} + +function isAssistantShape(shape: ExternalSessionRecordShape): boolean { + const { explicitRole, topType, payloadType } = shape; + if (explicitRole === "assistant") return true; + if (explicitRole) return false; + return topType === "assistant" + || topType === "assistant_message" + || payloadType === "assistant" + || payloadType === "assistant_message" + || payloadType === "agent_message"; +} + +function externalSessionUserTextFromRecord(record: unknown): string | null { + const shape = externalSessionRecordShape(record); + if (!shape || shape.isMeta || !isUserShape(shape)) return null; + return shape.rawText ? cleanExternalSessionUserText(shape.rawText) : null; +} + +function recoverExternalSessionCommandName(raw: string | null): string | null { + if (!raw) return null; + const matches = Array.from(raw.matchAll(/]*>\s*([\s\S]*?)\s*<\/command-name>/giu)); + const command = matches.at(-1)?.[1]?.replace(/<[^>]*>/gu, " ").replace(/\s+/gu, " ").trim() ?? ""; + return command ? cleanExternalSessionText(command) : null; +} + +function externalSessionMessageFromRecord(record: unknown): ExternalSessionMessage | null { + const shape = externalSessionRecordShape(record); + if (!shape) return null; + const role = isUserShape(shape) ? "user" : isAssistantShape(shape) ? "assistant" : null; + if (!role) return null; + // A meta row is plumbing, but a slash command inside one is a real thing the + // user typed, so recover just that. + if (shape.isMeta) { + const command = recoverExternalSessionCommandName(shape.rawText); + const text = clipExternalSessionText(command, EXTERNAL_SESSION_MESSAGE_MAX_LENGTH); + return text ? { role: "user", text, at: shape.at } : null; + } + const cleaned = role === "user" + ? shape.rawText + ? cleanExternalSessionUserText(shape.rawText) ?? recoverExternalSessionCommandName(shape.rawText) + : null + : shape.rawText ? cleanExternalSessionText(shape.rawText) : null; + const text = clipExternalSessionText(cleaned, EXTERNAL_SESSION_MESSAGE_MAX_LENGTH); + return text ? { role, text, at: shape.at } : null; } export function firstUserTextFromRecords( @@ -376,12 +528,44 @@ export function firstUserTextFromRecords( max = EXTERNAL_SESSION_PREVIEW_MAX_LENGTH, ): string | null { for (const record of records) { - const clipped = clipExternalSessionText(externalSessionUserTextFromRecord(record), max); + // The markup gate belongs here, not in the shared cleaner: a preview must be + // readable, while imported history must stay verbatim. + const worthy = previewWorthyText(externalSessionUserTextFromRecord(record)); + const clipped = clipExternalSessionText(worthy, max); if (clipped) return clipped; } return null; } +export function recentExternalSessionMessagesFromRecords( + records: unknown[], + maxMessages = EXTERNAL_SESSION_MESSAGES_MAX_COUNT, +): ExternalSessionMessage[] { + const limit = Math.max(0, Math.min(EXTERNAL_SESSION_MESSAGES_MAX_COUNT, Math.floor(maxMessages))); + if (!limit) return []; + return records + .map((record) => externalSessionMessageFromRecord(record)) + .filter((message): message is ExternalSessionMessage => message != null) + .filter((message) => previewWorthyText(message.text) != null) + .slice(-limit); +} + +/** + * Codex persists a single conversational turn twice: a canonical `event_msg` + * and a mirrored `response_item`. Sampling the raw rows would show every turn + * twice and evict genuinely older exchanges from the capped window, so drop the + * mirror whenever the canonical form is present in the same file. + */ +export function canonicalCodexRecords(records: unknown[]): unknown[] { + const hasCanonical = records.some((record) => ( + asString(asRecord(record)?.type)?.toLowerCase() === "event_msg" + )); + if (!hasCanonical) return records; + return records.filter((record) => ( + asString(asRecord(record)?.type)?.toLowerCase() !== "response_item" + )); +} + function isCanonicalCodexUserRecord(record: unknown): boolean { const obj = asRecord(record); const payload = asRecord(obj?.payload); @@ -460,6 +644,7 @@ export function recordWithFile(args: { cwd: string | null; title?: string | null; preview?: string | null; + messages?: ExternalSessionMessage[] | null; createdAt?: number | null; updatedAt?: number | null; messageCount?: number | null; @@ -477,6 +662,7 @@ export function recordWithFile(args: { cwd: args.cwd, title: args.title ?? null, preview: args.preview ?? null, + ...(args.messages !== undefined ? { messages: args.messages } : {}), createdAt: args.createdAt ?? null, updatedAt: args.updatedAt ?? sourceMtimeMs, messageCount: args.messageCount ?? null, diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts index 684ec4eb4..ed6e16391 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts @@ -127,6 +127,8 @@ describe("externalSessionsService", () => { provider: "claude", id, cwd: laneCwd, + preview: "import me", + messages: [{ role: "user", text: "import me", at: Date.parse("2026-07-06T10:00:00.000Z") }], alreadyImported: true, importedSessionRef: { kind: "cli", sessionId: "ade-session" }, possiblyActive: true, @@ -141,6 +143,18 @@ describe("externalSessionsService", () => { }); }); + it("copies optional preview fields through both summary construction paths", () => { + // The exact-lookup summary is private and the fields are optional, so a + // structural assertion pins both DTO boundaries without widening the API. + const source = fs.readFileSync( + path.join(__dirname, "externalSessionsService.ts"), + "utf8", + ); + + expect(source.match(/messages: session\.messages/gu)).toHaveLength(2); + expect(source.match(/preview: session\.preview/gu)).toHaveLength(2); + }); + it("checks a repeated session cwd only once per list call", async () => { const homeDir = path.join(root, "home"); const projectRoot = path.join(root, "repo"); diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts index 3afc6ae0c..2d2ced84b 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts @@ -564,6 +564,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) cwd: session.cwd, title: session.title, preview: session.preview, + messages: session.messages, createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messageCount, @@ -609,6 +610,7 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) cwd: session.cwd, title: session.title, preview: session.preview, + messages: session.messages, createdAt: session.createdAt, updatedAt: session.updatedAt, messageCount: session.messageCount, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index f6974cfb3..f7cbcc985 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DeviceMobile, GearSix, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, ShieldCheck, ShieldWarning, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; +import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DeviceMobile, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; import { inferAttachmentType, @@ -46,6 +46,10 @@ import { type ComposerTrigger, } from "../../../shared/composerTriggers"; import { cn } from "../ui/cn"; +import { + PermissionModePicker, + type PermissionModePickerOption, +} from "../shared/PermissionModePicker"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import type { AuthStatus } from "../shared/ModelPicker/ModelPickerRail"; import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog"; @@ -548,81 +552,9 @@ function ComposerFastModeButton({ ); } -const COMPOSER_PERMISSION_TRIGGER_CLASS = cn( - "ade-chat-composer-permission-trigger", - "inline-flex h-6 min-w-0 shrink-0 items-center justify-start gap-1 rounded-md border px-1.5", - "font-sans text-[length:calc(var(--chat-font-size)*9/14)] leading-none transition-colors duration-150", - "border-white/[0.06] bg-white/[0.03] text-fg/80", - "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", -); - const COMPOSER_COMPACT_MENU_WIDTH = 240; -type PermissionModeTone = "green" | "amber" | "blue" | "purple" | "red" | "slate"; -type PermissionModeIconKind = "manual" | "auto" | "edit" | "plan" | "full" | "config" | "agent" | "agi"; - -type PermissionModePickerOption = { - value: Value; - label: string; - triggerLabel?: string; - detail: string; - tone: PermissionModeTone; - icon: PermissionModeIconKind; -}; -const PERMISSION_MODE_TONE_STYLES: Record< - PermissionModeTone, - { - dot: string; - trigger: string; - iconSurface: string; - rowActive: string; - rowHover: string; - } -> = { - green: { - dot: "bg-emerald-400", - trigger: "border-emerald-400/24 bg-emerald-500/[0.08] text-emerald-100", - iconSurface: "border-emerald-300/20 bg-emerald-500/[0.12] text-emerald-200", - rowActive: "bg-emerald-500/[0.12] text-emerald-50", - rowHover: "hover:bg-emerald-500/[0.08] hover:text-emerald-50", - }, - amber: { - dot: "bg-amber-400", - trigger: "border-amber-300/22 bg-amber-500/[0.08] text-amber-100", - iconSurface: "border-amber-300/20 bg-amber-500/[0.12] text-amber-200", - rowActive: "bg-amber-500/[0.12] text-amber-50", - rowHover: "hover:bg-amber-500/[0.08] hover:text-amber-50", - }, - blue: { - dot: "bg-sky-400", - trigger: "border-sky-300/22 bg-sky-500/[0.08] text-sky-100", - iconSurface: "border-sky-300/20 bg-sky-500/[0.12] text-sky-200", - rowActive: "bg-sky-500/[0.12] text-sky-50", - rowHover: "hover:bg-sky-500/[0.08] hover:text-sky-50", - }, - purple: { - dot: "bg-violet-400", - trigger: "border-violet-300/24 bg-violet-500/[0.09] text-violet-100", - iconSurface: "border-violet-300/20 bg-violet-500/[0.14] text-violet-200", - rowActive: "bg-violet-500/[0.14] text-violet-50", - rowHover: "hover:bg-violet-500/[0.08] hover:text-violet-50", - }, - red: { - dot: "bg-red-400", - trigger: "border-red-300/24 bg-red-500/[0.09] text-red-100", - iconSurface: "border-red-300/20 bg-red-500/[0.14] text-red-200", - rowActive: "bg-red-500/[0.14] text-red-50", - rowHover: "hover:bg-red-500/[0.08] hover:text-red-50", - }, - slate: { - dot: "bg-slate-300", - trigger: "border-white/[0.08] bg-white/[0.045] text-fg/80", - iconSurface: "border-white/[0.08] bg-white/[0.06] text-fg/72", - rowActive: "bg-white/[0.08] text-fg/90", - rowHover: "hover:bg-white/[0.055] hover:text-fg/90", - }, -}; const CLAUDE_MODE_OPTIONS: Array> = [ { value: "default", label: "Manual", detail: "Claude asks before edits, Bash, and other sensitive tools.", tone: "green", icon: "manual" }, @@ -632,173 +564,7 @@ const CLAUDE_MODE_OPTIONS: Array; - case "auto": - return ; - case "edit": - return ; - case "plan": - return ; - case "full": - return ; - case "config": - return ; - case "agent": - return ; - case "agi": - return ; - } -} - -function PermissionModePicker({ - ariaLabel, - selectedValue, - options, - disabled, - onSelect, - title, -}: { - ariaLabel: string; - selectedValue: Value; - options: Array>; - disabled?: boolean; - onSelect?: (value: Value) => void; - title?: string; -}) { - const [open, setOpen] = useState(false); - const ref = useRef(null); - const selectedOption = options.find((option) => option.value === selectedValue) ?? options[0]; - const selectedTone = PERMISSION_MODE_TONE_STYLES[selectedOption?.tone ?? "slate"]; - - useEffect(() => { - if (!open) return; - const handleClick = (event: MouseEvent) => { - if (ref.current?.contains(event.target as Node)) return; - const target = event.target as Element | null; - if (target?.closest?.("[data-permission-mode-picker-dropdown]")) return; - setOpen(false); - }; - const handleKey = (event: KeyboardEvent) => { - if (event.key === "Escape") setOpen(false); - }; - window.addEventListener("mousedown", handleClick); - window.addEventListener("keydown", handleKey); - return () => { - window.removeEventListener("mousedown", handleClick); - window.removeEventListener("keydown", handleKey); - }; - }, [open]); - - if (!selectedOption) return null; - - const triggerTitle = title ?? selectedOption.detail; - return ( -
- - {open && ref.current ? createPortal( - (() => { - const rect = ref.current.getBoundingClientRect(); - const width = COMPOSER_COMPACT_MENU_WIDTH; - const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8)); - return ( -
-
    - {options.map((option) => { - const active = option.value === selectedValue; - const tone = PERMISSION_MODE_TONE_STYLES[option.tone]; - return ( -
  • - -
  • - ); - })} -
-
- ); - })(), - document.body, - ) : null} -
- ); -} type CodexPermissionPreset = "default" | "edit" | "plan" | "full-auto" | "config-toml" | "custom"; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 4ec24b506..1f2d8f2ed 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -5472,6 +5472,22 @@ export function AgentChatPane({ handoffCursorModeId, handoffCursorConfigValues, ]); + /** + * Writes a whole `NativeControlState` back into the individual handoff fields. + * The cross-machine modal edits permissions against the *destination* model, + * so it needs to hand back a full state rather than poke one provider's field + * — which provider is even relevant depends on the model it just picked. + */ + const applyHandoffNativeControls = useCallback((next: NativeControlState) => { + setHandoffClaudePermissionMode(next.claudePermissionMode); + setHandoffCodexApprovalPolicy(next.codexApprovalPolicy); + setHandoffCodexSandbox(next.codexSandbox); + setHandoffCodexConfigSource(next.codexConfigSource); + setHandoffOpenCodePermissionMode(next.opencodePermissionMode); + setHandoffDroidPermissionMode(next.droidPermissionMode); + setHandoffCursorModeId(next.cursorModeId); + setHandoffCursorConfigValues(next.cursorConfigValues); + }, []); const handoffNativePermissionMode = useMemo((): AgentChatPermissionMode | undefined | null => { if (!handoffTargetProvider) return null; return summarizeNativeControls(handoffTargetProvider, handoffNativeControlState).permissionMode @@ -5511,7 +5527,11 @@ export function AgentChatPane({ const crossMachineHandoffTarget = useMemo(() => ({ targetModelId: remoteHandoffModelId, reasoningEffort: handoffReasoningEffort, - ...(remoteHandoffTargetProvider === "codex" || remoteHandoffTargetProvider === "opencode" + // Serialize fast mode for exactly the models whose toggle the modal renders + // (`modelSupportsFastMode`), not a hardcoded provider pair — otherwise a + // fast-capable Claude model shows a live control that never reaches the + // capsule, and the destination silently inherits the source's tier. + ...(remoteHandoffTargetDescriptor && modelSupportsFastMode(remoteHandoffTargetDescriptor) ? { fastMode: handoffFastMode } : {}), claudePermissionMode: handoffClaudePermissionMode, @@ -5535,6 +5555,7 @@ export function AgentChatPane({ handoffOpenCodePermissionMode, handoffReasoningEffort, remoteHandoffModelId, + remoteHandoffTargetDescriptor, remoteHandoffNativePermissionMode, remoteHandoffTargetProvider, ]); @@ -12624,6 +12645,12 @@ export function AgentChatPane({ onModelChange={setRemoteHandoffModelId} availableModelIds={handoffAvailableModelIds} forkAvailableModelIds={handoffForkAvailableModelIds} + reasoningEffort={handoffReasoningEffort} + onReasoningEffortChange={setHandoffReasoningEffort} + fastMode={handoffFastMode} + onFastModeChange={setHandoffFastMode} + nativeControls={handoffNativeControlState} + onNativeControlsChange={applyHandoffNativeControls} onOpenSignIn={openProviderSignIn} turnActive={turnActive} awaitingInput={selectedSessionAwaitingInput} diff --git a/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.test.tsx b/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.test.tsx index c1359ab15..3090155be 100644 --- a/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.test.tsx +++ b/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.test.tsx @@ -4,6 +4,17 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { CrossMachineHandoffModal } from "./CrossMachineHandoffModal"; +import { getPermissionOptions } from "../shared/permissionOptions"; +import { + branchRowDetail, + branchRowState, + forkFallbackReasonForPrepareError, + PERMISSION_MODE_ICONS, + PERMISSION_SAFETY_TONES, + repoReadinessLabel, + toPermissionPickerOption, + type SourceCheck, +} from "./crossMachineHandoffPresentation"; const SOURCE_SHA = "1234567890abcdef1234567890abcdef12345678"; @@ -100,6 +111,7 @@ describe("CrossMachineHandoffModal", () => { branch: "feature/handoff", }), push: vi.fn().mockResolvedValue({ message: "pushed" }), + pull: vi.fn().mockResolvedValue({ message: "pulled" }), }, agentChat: { prepareCrossMachineHandoff, validateCrossMachineSource, markCrossMachineHandoff }, remoteRuntime: { @@ -178,7 +190,7 @@ describe("CrossMachineHandoffModal", () => { expect(await screen.findByText("Studio")).toBeTruthy(); expect(screen.queryByText("Old Mac")).toBeNull(); expect(screen.getByText(/1 connected machine needs an ADE update/i)).toBeTruthy(); - expect(screen.getByText("Model for the new chat")).toBeTruthy(); + expect(screen.getByText("The new chat")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /^continue$/i })); expect(await screen.findByText(/Ready to continue on Studio/i)).toBeTruthy(); @@ -469,13 +481,28 @@ describe("CrossMachineHandoffModal", () => { ); }); - it("disables fork for a provider that can't fork history", async () => { + /** + * Regression: a branch that was pushed but 2 commits *behind* origin pushed a + * blocker into the list, disabled Continue, and rendered none of it — three + * green check rows above a dead button, with no way to learn why. + */ + it("names a behind branch and offers the pull instead of silently disabling Continue", async () => { + (window as any).ade.git.getSyncStatus.mockResolvedValue({ + hasUpstream: true, + upstreamState: "tracking", + upstreamRef: "origin/feature/handoff", + ahead: 0, + behind: 2, + diverged: false, + recommendedAction: "pull", + }); + render( { />, ); - const forkButton = await screen.findByRole("button", { name: /^fork$/i }); - expect(forkButton).toHaveProperty("disabled", true); - expect(screen.getByText(/Cursor can't fork chat history/i)).toBeTruthy(); + expect(await screen.findByText(/2 commits behind origin/i)).toBeTruthy(); + const continueButton = screen.getByRole("button", { name: /^continue/i }); + expect(continueButton).toHaveProperty("disabled", true); + // The reason has to reach the user, not just the disabled attribute. + expect(continueButton.getAttribute("title")).toMatch(/behind origin/i); + + fireEvent.click(screen.getByRole("button", { name: /update branch/i })); + await waitFor(() => expect((window as any).ade.git.pull).toHaveBeenCalledWith({ laneId: "lane-1" })); }); - it("defaults Droid handoffs to brief while keeping fork selectable", async () => { - stubPrepareByMode(); - callAction.mockReset(); - callAction.mockImplementation(async (_target: string, _project: string, payload: { action: string }) => { - if (payload.action === "preflightCrossMachineDestination") return { result: preflightResult() }; - return { result: ACCEPT_RESULT }; + /** + * Regression: the blocker list and two standalone panels rendered the same + * blocker and the same fix button twice, with different disabled behavior on + * each copy. Every blocker must reach the user exactly once. + */ + it("renders each blocker and its fix exactly once", async () => { + (window as any).ade.git.getSyncStatus.mockResolvedValue({ + hasUpstream: false, + upstreamState: "missing", + upstreamRef: null, + ahead: 2, + behind: 0, + diverged: false, + recommendedAction: "push", }); render( @@ -506,12 +546,44 @@ describe("CrossMachineHandoffModal", () => { open sourceSessionId="session-1" sourceLaneId="lane-1" - sourceProvider="droid" + sourceProvider="codex" + target={{ targetModelId: "openai/gpt-5.5" }} + modelId="openai/gpt-5.5" + onModelChange={vi.fn()} + availableModelIds={["openai/gpt-5.5"]} + turnActive + awaitingInput={false} + onStopTurn={vi.fn()} + onClose={vi.fn()} + onFinished={vi.fn()} + />, + ); + + expect(await screen.findAllByRole("button", { name: /publish branch/i })).toHaveLength(1); + expect(screen.getAllByRole("button", { name: /stop current response/i })).toHaveLength(1); + }); + + it("blocks a diverged branch without offering a one-click fix", async () => { + (window as any).ade.git.getSyncStatus.mockResolvedValue({ + hasUpstream: true, + upstreamState: "tracking", + upstreamRef: "origin/feature/handoff", + ahead: 3, + behind: 2, + diverged: true, + recommendedAction: "force_push_lease", + }); + + render( + { />, ); - expect((await screen.findByRole("button", { name: /^brief$/i })).getAttribute("aria-pressed")).toBe("true"); - expect(screen.getByRole("button", { name: /^fork$/i })).toHaveProperty("disabled", false); - fireEvent.click(screen.getByRole("button", { name: /^continue$/i })); - await waitFor(() => expect(prepareCrossMachineHandoff).toHaveBeenCalledWith(expect.objectContaining({ mode: "brief" }))); + expect(await screen.findByText(/has diverged from origin/i)).toBeTruthy(); + expect(screen.getByRole("button", { name: /^continue/i })).toHaveProperty("disabled", true); + // Choosing merge-vs-rebase for the user is exactly what this flow must not do. + expect(screen.queryByRole("button", { name: /update branch/i })).toBeNull(); + // A diverged branch is also `ahead`, so it reports needsPush — but its + // upstream already exists and the push would be rejected as non-fast-forward. + expect(screen.queryByRole("button", { name: /publish branch/i })).toBeNull(); }); - it("offers the brief fallback for a non-portable Droid fork", async () => { - prepareCrossMachineHandoff.mockReset(); - prepareCrossMachineHandoff.mockRejectedValueOnce( - new Error("Droid sessions aren't portable between machines yet. Use a brief handoff instead."), + it("disables fork for a provider that can't fork history", async () => { + render( + , ); + const forkButton = await screen.findByRole("button", { name: /^fork$/i }); + expect(forkButton).toHaveProperty("disabled", true); + expect(screen.getByText(/Cursor can't fork chat history/i)).toBeTruthy(); + }); + + it("refuses cross-machine fork for Droid instead of offering a tab that throws", async () => { + stubPrepareByMode(); + callAction.mockReset(); + callAction.mockImplementation(async (_target: string, _project: string, payload: { action: string }) => { + if (payload.action === "preflightCrossMachineDestination") return { result: preflightResult() }; + return { result: ACCEPT_RESULT }; + }); + render( { />, ); - fireEvent.click(await screen.findByRole("button", { name: /^fork$/i })); + expect((await screen.findByRole("button", { name: /^brief$/i })).getAttribute("aria-pressed")).toBe("true"); + // Droid can fork locally but never onto another machine — its session index + // is machine-local. The tab used to stay enabled and throw on confirm. + expect(screen.getByRole("button", { name: /^fork$/i })).toHaveProperty("disabled", true); + expect(screen.getByText(/can't fork chat history/i)).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: /^continue$/i })); - expect(await screen.findByText(/history can't move between machines — a brief works everywhere/i)).toBeTruthy(); + await waitFor(() => expect(prepareCrossMachineHandoff).toHaveBeenCalledWith(expect.objectContaining({ mode: "brief" }))); }); it("offers a one-click brief when the destination is too old to fork", async () => { @@ -761,3 +866,157 @@ describe("CrossMachineHandoffModal", () => { expect(onFinished).toHaveBeenCalledTimes(1); }); }); + +function check(overrides: Partial = {}): SourceCheck { + return { + lane: null, + sync: { + hasUpstream: true, + upstreamState: "tracking", + upstreamRef: "origin/main", + ahead: 0, + behind: 0, + diverged: false, + recommendedAction: "none", + }, + originUrl: "git@github.com:arul28/ade.git", + branch: "main", + needsPush: false, + blockingErrors: [], + warnings: [], + ...overrides, + }; +} + +describe("branch row", () => { + /** + * Regression: the row read only the push direction, so a branch that was fully + * pushed but two commits behind rendered a green "main is pushed" — while that + * same state silently disabled Continue. + */ + it("reports a behind branch as an error, not as pushed", () => { + const behind = check({ sync: { ...check().sync!, behind: 2, recommendedAction: "pull" } }); + expect(branchRowDetail(behind)).toBe("main is 2 commits behind origin"); + expect(branchRowState(behind)).toBe("error"); + }); + + it("uses the singular for one commit", () => { + const behind = check({ sync: { ...check().sync!, behind: 1 } }); + expect(branchRowDetail(behind)).toContain("1 commit behind"); + }); + + it("reports divergence distinctly from being behind", () => { + const diverged = check({ sync: { ...check().sync!, ahead: 3, behind: 2, diverged: true } }); + expect(branchRowDetail(diverged)).toBe("main has diverged from origin"); + expect(branchRowState(diverged)).toBe("error"); + }); + + it("warns rather than errors when the branch merely needs pushing", () => { + expect(branchRowState(check({ needsPush: true }))).toBe("warn"); + }); + + it("is only green when the branch is genuinely in sync", () => { + expect(branchRowDetail(check())).toBe("main is pushed and up to date"); + expect(branchRowState(check())).toBe("ok"); + }); +}); + +describe("permission lookups", () => { + /** + * Regression: these were typed `Record` with invented key names, so + * every lookup fell through to a default and the whole permission row rendered + * grey while the composer's rendered green/amber/red. Keying on the real + * unions makes a missing key a compile error; this asserts the values too. + */ + it("covers every safety level a permission option can carry", () => { + const families = ["anthropic", "openai", "factory", "cursor", "google"]; + const safeties = new Set( + families.flatMap((family) => getPermissionOptions({ family, isCliWrapped: true })) + .map((option) => option.safety), + ); + expect(safeties.size).toBeGreaterThan(1); + for (const safety of safeties) { + expect(PERMISSION_SAFETY_TONES[safety]).toBeTruthy(); + } + }); + + it("keeps the danger tier visually distinct from the safe one", () => { + expect(PERMISSION_SAFETY_TONES.safe).toBe("green"); + expect(PERMISSION_SAFETY_TONES["full-auto"]).toBe("red"); + expect(PERMISSION_SAFETY_TONES.danger).toBe("red"); + expect(PERMISSION_SAFETY_TONES.safe).not.toBe(PERMISSION_SAFETY_TONES.danger); + }); + + it("maps every permission mode to an icon", () => { + const families = ["anthropic", "openai", "factory", "cursor", "google"]; + for (const family of families) { + for (const option of getPermissionOptions({ family, isCliWrapped: true })) { + expect(PERMISSION_MODE_ICONS[option.value]).toBeTruthy(); + } + } + }); +}); + +describe("repoReadinessLabel", () => { + it("says nothing for states it has not resolved", () => { + // An unanswered question is not worth a row. + expect(repoReadinessLabel("checking")).toBeNull(); + expect(repoReadinessLabel("unknown")).toBeNull(); + expect(repoReadinessLabel(undefined)).toBeNull(); + }); + + it("distinguishes a present repository from one that must be cloned", () => { + expect(repoReadinessLabel("present")).toBe("repo ready"); + expect(repoReadinessLabel("absent")).toBe("will clone the repo"); + }); +}); + +describe("permission option mapping", () => { + /** + * Regression: an unmappable native combination fell back to `options[0]` and + * rendered as "Default". The raw controls are what actually travel in the + * capsule, so that claimed the destination would ask for approval when it + * would not. + */ + it("labels a preset-representable mode with its own option", () => { + const options = getPermissionOptions({ family: "openai", isCliWrapped: true }); + const mapped = options.map(toPermissionPickerOption); + expect(mapped.length).toBe(options.length); + for (const option of mapped) { + expect(option.label).toBeTruthy(); + expect(option.tone).toBeTruthy(); + expect(option.icon).toBeTruthy(); + } + }); + + it("never maps two different modes onto the same label", () => { + const mapped = getPermissionOptions({ family: "openai", isCliWrapped: true }) + .map(toPermissionPickerOption); + expect(new Set(mapped.map((option) => option.label)).size).toBe(mapped.length); + }); +}); + +describe("forkFallbackReasonForPrepareError", () => { + /** + * `/quality` gate item: this classifies a service error by matching its + * *message* across an IPC boundary, so a copy edit on the throwing side + * silently turns the one-click brief fallback into a dead end. Pinning the + * three shapes the service actually throws at least makes that break loud + * here until the errors carry a code. + */ + it("recognizes each cause the service can throw for an unforkable chat", () => { + expect(forkFallbackReasonForPrepareError( + "This chat's history is too large to fork across machines. Send it as a brief instead.", + )).toMatch(/too big to send/i); + expect(forkFallbackReasonForPrepareError( + "This Codex rollout can't be forked across machines.", + )).toMatch(/can't be forked/i); + expect(forkFallbackReasonForPrepareError( + "Droid sessions aren't portable between machines yet. Use a brief handoff instead.", + )).toMatch(/can't move between machines/i); + }); + + it("returns null for an unrelated failure so it is surfaced as a real error", () => { + expect(forkFallbackReasonForPrepareError("Network unreachable")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx b/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx index 60ef88786..af8f89ebc 100644 --- a/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx +++ b/apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ArrowLeft, ArrowRight, @@ -10,6 +10,7 @@ import { GitBranch, GitFork, HardDrives, + Lightning, LockKey, ShieldWarning, Warning, @@ -17,6 +18,7 @@ import { } from "@phosphor-icons/react"; import type { AgentChatAcceptCrossMachineHandoffResult, + AgentChatPermissionMode, AgentChatCrossMachineDestinationPreflightResult, AgentChatCrossMachineTargetConfig, AgentChatPrepareCrossMachineHandoffResult, @@ -33,133 +35,64 @@ import { normalizeGitRemoteIdentity, requireRemoteRuntimeRouteKind, } from "../../../shared/crossMachineHandoff"; -import { providerSupportsHandoffFork } from "../../../shared/types/chat"; +import { providerSupportsCrossMachineHandoffFork } from "../../../shared/types/chat"; import { providerDisplayLabel as providerDisplayLabelShared } from "../../../shared/pendingInputLabels"; import { isRemoteRuntimeConnectionError, isRuntimeTransportTimeoutError, } from "../../../shared/runtimeErrors"; import { + getModelById, + modelSupportsFastMode, resolveProviderGroupForModel, type ModelDescriptor, type ProviderFamily, } from "../../../shared/modelRegistry"; +import { + applyUnifiedPermissionToNativeControls, + summarizeNativeControls, +} from "../../lib/nativeLaunchControls"; +import type { NativeControlState } from "../../lib/draftLaunchJobs"; +import { getPermissionOptions, type SafetyLevel } from "../shared/permissionOptions"; +import { + PERMISSION_TRIGGER_CLASS, + PermissionModePicker, + type PermissionModeIconKind, + type PermissionModeTone, +} from "../shared/PermissionModePicker"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; +import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; +import { + BlockedActionButton, + BlockedReasons, + type BlockedActionReason, +} from "../shared/BlockedAction"; +import { formatBytes } from "../../lib/format"; +import { + branchRowDetail, + branchRowState, + CheckRow, + CROSS_MACHINE_HANDOFF_STILL_COMPLETING_MESSAGE, + EMPTY_SOURCE_CHECK, + forkFallbackReasonForPrepareError, + isInsecureRoute, + PERMISSION_MODE_ICONS, + PERMISSION_SAFETY_TONES, + providerDisplayLabel, + repoNameFromRemote, + repoReadinessClass, + repoReadinessLabel, + routeLabel, + SEND_STEPS, + toPermissionPickerOption, + type ForkHandoffSupport, + type HandoffMode, + type ModalStage, + type SendStep, + type SourceCheck, +} from "./crossMachineHandoffPresentation"; import { cn } from "../ui/cn"; -type SourceCheck = { - lane: LaneSummary | null; - sync: GitUpstreamSyncStatus | null; - originUrl: string | null; - branch: string | null; - needsPush: boolean; - blockingErrors: string[]; - warnings: string[]; -}; - -type ModalStage = "choose" | "clone" | "review" | "sending" | "complete"; -type HandoffMode = "brief" | "fork"; - -type ForkHandoffSupport = { supported: boolean; reason?: string }; - -const CROSS_MACHINE_HANDOFF_STILL_COMPLETING_MESSAGE = - "ADE lost confirmation from the destination while it was creating the handoff. " - + "The new chat may still appear there. Check that computer before retrying; retrying this handoff is safe."; - -function providerDisplayLabel(provider: AgentChatProvider | null | undefined): string { - return providerDisplayLabelShared(provider, "This chat"); -} - -/** - * Prepare errors that mean "this chat can't fork, but a brief always can" — the - * source history is over the transport cap ("too large"), or the provider's - * native session file can't be forked at all (e.g. a Codex `.zst` rollout). Both - * get the one-click brief fallback; the plain-language reason differs by cause. - */ -function forkFallbackReasonForPrepareError(message: string): string | null { - if (/too large|too big/i.test(message)) { - return "This chat's history is too big to send — a brief works everywhere."; - } - if (/can'?t be forked|cannot be forked|not forkable/i.test(message)) { - return "This chat's history can't be forked — a brief works everywhere."; - } - if (/aren'?t portable|not portable/i.test(message)) { - return "This chat's history can't move between machines — a brief works everywhere."; - } - return null; -} - -const EMPTY_SOURCE_CHECK: SourceCheck = { - lane: null, - sync: null, - originUrl: null, - branch: null, - needsPush: false, - blockingErrors: [], - warnings: [], -}; - -function repoNameFromRemote(value: string): string { - const normalized = value.trim().replace(/[\\/]$/, "").replace(/\.git$/i, ""); - return normalized.split(/[/:]/).filter(Boolean).at(-1) || "repository"; -} - -function formatBytes(value: number): string { - if (!Number.isFinite(value) || value <= 0) return "Unavailable"; - const units = ["B", "KB", "MB", "GB", "TB"]; - let next = value; - let index = 0; - while (next >= 1024 && index < units.length - 1) { - next /= 1024; - index += 1; - } - return `${next >= 10 || index === 0 ? next.toFixed(0) : next.toFixed(1)} ${units[index]}`; -} - -function routeLabel(connection: RemoteRuntimeConnectionStatus): string { - switch (connection.route?.kind) { - case "tailnet": return "Tailscale · encrypted"; - case "ssh": return "SSH · encrypted"; - case "relay": return "ADE relay"; - case "lan": return "Local network"; - default: return "Connected route"; - } -} - -function isInsecureRoute(connection: RemoteRuntimeConnectionStatus | null): boolean { - return connection?.route?.kind === "lan" || connection?.route?.kind === "relay"; -} - -function CheckRow({ - label, - detail, - state, -}: { - label: string; - detail: string; - state: "ok" | "warn" | "error" | "pending"; -}) { - const Icon = state === "ok" ? CheckCircle : state === "pending" ? CircleNotch : Warning; - return ( -
- -
-
{label}
-
{detail}
-
-
- ); -} export function CrossMachineHandoffModal({ open, @@ -171,6 +104,12 @@ export function CrossMachineHandoffModal({ onModelChange, availableModelIds, forkAvailableModelIds, + reasoningEffort, + onReasoningEffortChange, + fastMode, + onFastModeChange, + nativeControls, + onNativeControlsChange, onOpenSignIn, turnActive, awaitingInput, @@ -190,6 +129,18 @@ export function CrossMachineHandoffModal({ availableModelIds?: string[]; /** Same-provider models offered when forking (fork must stay on one provider). */ forkAvailableModelIds?: string[]; + /** + * Destination reasoning/permission settings. The capsule has always carried + * these and the destination has always honored them — until now there was + * simply no UI to set them, so every handoff silently shipped whatever the + * local handoff drawer happened to hold. + */ + reasoningEffort?: string | null; + onReasoningEffortChange?: (effort: string | null) => void; + fastMode?: boolean; + onFastModeChange?: (next: boolean) => void; + nativeControls?: NativeControlState; + onNativeControlsChange?: (next: NativeControlState) => void; onOpenSignIn?: (family?: ProviderFamily) => void; turnActive: boolean; awaitingInput: boolean; @@ -213,7 +164,38 @@ export function CrossMachineHandoffModal({ const [error, setError] = useState(null); const [result, setResult] = useState(null); const [sourceMarkerWarning, setSourceMarkerWarning] = useState(null); - const sourceProviderSupportsFork = providerSupportsHandoffFork(sourceProvider); + /** + * Which of the real send checkpoints have completed. These mirror the durable + * states the destination actually walks (validate -> lane_ready/chat_ready -> + * dispatched), so the list is reporting progress rather than animating it. + */ + const [sendProgress, setSendProgress] = useState([]); + /** + * Per-machine repository readiness, resolved while the picker is on screen so + * the choice is informed instead of a guess you find out about two steps + * later. Deliberately narrow: it answers "is this repo already there", not + * "is everything ready" — provider auth and branch state are still the review + * step's job, and claiming more here would be a lie the user can't check. + */ + const [machineRepoReadiness, setMachineRepoReadiness] = useState< + Record + >({}); + /** + * Projects seen while resolving readiness, reused by `prepareDestination` so + * the hint costs nothing: the picker already had to ask each machine what it + * has, and prepare would otherwise ask the same question again a moment later. + */ + const machineProjectsRef = useRef>({}); + /** + * `inspectSource` builds the blocker list, and the "behind" blocker needs to + * offer the pull that clears it — but `updateBranch` is defined below and + * itself calls `inspectSource`. The ref breaks that cycle without making + * either callback depend on the other's identity. + */ + const updateBranchRef = useRef<(() => Promise) | null>(null); + // Cross-machine fork is narrower than local fork: Droid's session index is + // machine-local, so it can fork here but never onto another machine. + const sourceProviderSupportsFork = providerSupportsCrossMachineHandoffFork(sourceProvider); const [mode, setMode] = useState(sourceProviderSupportsFork ? "fork" : "brief"); // Destination fork capability, learned only after preflight. `null` = not yet // checked; absent field on the response resolves to { supported: false }. @@ -228,6 +210,73 @@ export function CrossMachineHandoffModal({ Boolean(sourceProvider && resolveProviderGroupForModel(descriptor) === sourceProvider) ), [sourceProvider]); + /** + * Everything about the *destination* chat's controls is derived from the model + * chosen here, never from the local handoff drawer's model. Getting that wrong + * is how the modal previously shipped permission values computed against a + * different provider than the one that would actually run them. + */ + const destinationDescriptor = useMemo( + () => (modelId ? getModelById(modelId) ?? null : null), + [modelId], + ); + const destinationFastModeSupported = Boolean( + destinationDescriptor && modelSupportsFastMode(destinationDescriptor), + ); + const destinationPermissionPicker = useMemo(() => { + if (!modelId || !nativeControls || !onNativeControlsChange || !destinationDescriptor) return null; + const providerGroup = resolveProviderGroupForModel(destinationDescriptor); + if (!providerGroup) return null; + // Two different vocabularies, and they are not interchangeable: + // `getPermissionOptions` branches on ProviderFamily ("anthropic", "openai", + // "factory") while `summarizeNativeControls` keys off the provider group + // ("claude", "codex", "droid"). Passing the group as the family silently + // falls through to the generic option list, which then cannot represent the + // mode the capsule is actually carrying — so the pill shows one thing and + // the destination runs another. + const options = getPermissionOptions({ + family: destinationDescriptor.family, + isCliWrapped: destinationDescriptor.isCliWrapped, + }); + if (options.length === 0) return null; + const summarized = summarizeNativeControls(providerGroup, nativeControls).permissionMode; + const representable = options.some((option) => option.value === summarized); + // A native combination the presets cannot express (e.g. Codex approval + // "never" with sandbox "workspace-write") must not borrow the first option's + // label. The raw controls are what actually travel in the capsule, so + // showing "Default" there would claim the destination asks for approval when + // it does not. Surface it as Custom instead, and leave it unselectable — + // picking a real preset is what overwrites the underlying controls. + const customValue = "__custom__"; + const pickerOptions = representable + ? options.map(toPermissionPickerOption) + : [ + ...options.map(toPermissionPickerOption), + { + value: customValue, + label: "Custom", + detail: "This chat's provider settings don't match a preset. They travel as-is.", + tone: "slate" as PermissionModeTone, + icon: "config" as PermissionModeIconKind, + }, + ]; + const current = representable ? summarized! : customValue; + return ( + { + if (value === customValue) return; + onNativeControlsChange( + applyUnifiedPermissionToNativeControls(modelId, value as AgentChatPermissionMode, nativeControls), + ); + }} + /> + ); + }, [destinationDescriptor, modelId, nativeControls, onNativeControlsChange]); + const selectedConnection = useMemo( () => connections.find((connection) => connection.target.id === selectedTargetId) ?? null, [connections, selectedTargetId], @@ -240,6 +289,16 @@ export function CrossMachineHandoffModal({ ), [connections], ); + /** + * A stable identity for "which machines are eligible". `eligibleConnections` + * is a fresh array on every connection snapshot, and `listProjects` itself + * triggers a snapshot broadcast — depending on the array meant the readiness + * effect re-fired forever, hammering every paired machine with RPCs. + */ + const eligibleTargetIds = useMemo( + () => eligibleConnections.map((connection) => connection.target.id).join("\u0000"), + [eligibleConnections], + ); const incompatibleConnectedCount = connections.filter((connection) => connection.state === "connected" && connection.capabilities?.machineProjects.handoffStoragePreflight !== true, @@ -252,14 +311,60 @@ export function CrossMachineHandoffModal({ window.ade.git.getOriginRemote({ laneId: sourceLaneId }), ]); const lane = lanes.find((candidate) => candidate.id === sourceLaneId) ?? null; - const blockingErrors: string[] = []; + const blockingErrors: BlockedActionReason[] = []; const warnings: string[] = []; - if (!lane) blockingErrors.push("ADE could not find the source lane."); - if (lane?.status.dirty) blockingErrors.push("Commit or discard all source lane changes."); - if (lane?.status.rebaseInProgress) blockingErrors.push("Finish or abort the source lane rebase."); - if (sync.behind > 0 || sync.diverged) blockingErrors.push("Update the source branch before handing it off."); - if (!origin.remoteUrl) blockingErrors.push("Add an origin remote to this repository."); - if (!origin.branch) blockingErrors.push("The source lane must be on a named branch."); + if (!lane) { + blockingErrors.push({ + id: "lane-missing", + title: "ADE could not find this chat's lane", + detail: "Reopen the project, then try again.", + }); + } + if (lane?.status.dirty) { + blockingErrors.push({ + id: "dirty", + title: "You have uncommitted changes", + detail: "The other machine picks the work up from Git, so anything uncommitted would be left behind here. Commit or discard it first.", + }); + } + if (lane?.status.rebaseInProgress) { + blockingErrors.push({ + id: "rebase", + title: "A rebase is in progress", + detail: "Finish or abort it before handing this chat off.", + }); + } + // Behind/diverged is the blocker that used to be invisible: nothing rendered + // it, and the "Remote branch" row reported a cheerful " is pushed" + // because it only ever looked at the push direction. + if (sync.diverged) { + blockingErrors.push({ + id: "diverged", + title: `${origin.branch ?? "This branch"} has diverged from origin`, + detail: `Local and origin both have commits the other doesn't (${sync.ahead} here, ${sync.behind} there). Reconcile them before handing off — ADE won't pick a strategy for you.`, + }); + } else if (sync.behind > 0) { + blockingErrors.push({ + id: "behind", + title: `${origin.branch ?? "This branch"} is ${sync.behind} ${sync.behind === 1 ? "commit" : "commits"} behind origin`, + detail: "The other machine would start from older code than origin has.", + fix: { label: "Update branch", onFix: () => void updateBranchRef.current?.() }, + }); + } + if (!origin.remoteUrl) { + blockingErrors.push({ + id: "no-origin", + title: "This repository has no origin remote", + detail: "The other machine fetches your branch from origin, so one is required.", + }); + } + if (!origin.branch) { + blockingErrors.push({ + id: "no-branch", + title: "This lane isn't on a named branch", + detail: "Detached HEAD can't be handed off. Check out a branch first.", + }); + } const needsPush = !sync.hasUpstream || sync.ahead > 0 || sync.recommendedAction === "push"; if (needsPush) warnings.push("Publish the branch before ADE can prepare the destination."); const next = { lane, sync, originUrl: origin.remoteUrl, branch: origin.branch, needsPush, blockingErrors, warnings }; @@ -305,7 +410,10 @@ export function CrossMachineHandoffModal({ setRouteApproved(false); setResult(null); setSourceMarkerWarning(null); - setMode(sourceProvider === "droid" ? "brief" : (sourceProviderSupportsFork ? "fork" : "brief")); + setSendProgress([]); + machineProjectsRef.current = {}; + setMachineRepoReadiness({}); + setMode(sourceProviderSupportsFork ? "fork" : "brief"); setForkHandoffSupport(null); setForkFallbackReason(null); void loadInitial(); @@ -322,6 +430,43 @@ export function CrossMachineHandoffModal({ setRouteApproved(false); }, [selectedConnection?.route?.kind]); + // Resolve repository presence for every eligible machine once the source + // origin is known. Failures resolve to "unknown" rather than a scary state — + // a machine we couldn't ask about is not a machine that's broken. + useEffect(() => { + if (stage !== "choose") return; + const sourceOrigin = sourceCheck.originUrl ? normalizeGitRemoteIdentity(sourceCheck.originUrl) : null; + if (!sourceOrigin) return; + const targets = eligibleTargetIds ? eligibleTargetIds.split("\u0000") : []; + if (targets.length === 0) return; + let cancelled = false; + setMachineRepoReadiness((current) => { + const next = { ...current }; + for (const id of targets) next[id] ??= "checking"; + return next; + }); + void Promise.all(targets.map(async (targetId) => { + let state: "present" | "absent" | "unknown" = "unknown"; + let projects: RemoteRuntimeProjectRecord[] | null = null; + try { + projects = await window.ade.remoteRuntime.listProjects(targetId); + state = projects.some((project) => normalizeGitRemoteIdentity(project.gitOriginUrl) === sourceOrigin) + ? "present" + : "absent"; + } catch { + state = "unknown"; + } + // The cache write is inside the guard too: a superseded response landing + // after a newer one would otherwise leave a stale list that + // `prepareDestination` consumes, walking the user into a clone prompt for + // a repository the destination already has. + if (cancelled) return; + if (projects) machineProjectsRef.current[targetId] = projects; + setMachineRepoReadiness((current) => ({ ...current, [targetId]: state })); + })); + return () => { cancelled = true; }; + }, [eligibleTargetIds, sourceCheck.originUrl, stage]); + useEffect(() => { if (!open) return; const onKeyDown = (event: KeyboardEvent) => { @@ -387,7 +532,10 @@ export function CrossMachineHandoffModal({ ...target, }); setPrepared(handoff); - const projects = await window.ade.remoteRuntime.listProjects(selectedConnection.target.id); + // Prefer what the readiness pass already fetched; only ask again when the + // picker never got an answer for this machine. + const projects = machineProjectsRef.current[selectedConnection.target.id] + ?? await window.ade.remoteRuntime.listProjects(selectedConnection.target.id); const sourceOrigin = normalizeGitRemoteIdentity(handoff.capsule.source.originUrl); const matchingProject = projects.find((project) => normalizeGitRemoteIdentity(project.gitOriginUrl) === sourceOrigin) ?? null; if (matchingProject) { @@ -477,6 +625,29 @@ export function CrossMachineHandoffModal({ } }, [inspectSource, sourceLaneId]); + /** + * Clears the "behind origin" blocker in place. Only offered when the branch is + * strictly behind — a diverged branch keeps the hard block, because picking + * merge-vs-rebase for the user is exactly the kind of decision this flow + * should not be making on their behalf. + */ + const updateBranch = useCallback(async () => { + setBusyLabel("Updating source branch…"); + setError(null); + try { + await window.ade.git.pull({ laneId: sourceLaneId }); + await inspectSource(); + } catch (pullError) { + setError(pullError instanceof Error ? pullError.message : String(pullError)); + } finally { + setBusyLabel(null); + } + }, [inspectSource, sourceLaneId]); + + useEffect(() => { + updateBranchRef.current = updateBranch; + }, [updateBranch]); + const cloneDestination = useCallback(async () => { if (!selectedConnection || !prepared || !storagePreflight || !cloneApproved) return; setBusyLabel("Cloning repository on destination…"); @@ -512,6 +683,30 @@ export function CrossMachineHandoffModal({ } }, [cloneApproved, mode, prepared, runDestinationPreflight, selectedConnection, storagePreflight]); + /** + * Asks the destination to catch its own lane up. The destination re-validates + * everything and only ever does a `--ff-only` merge, so a stale preflight here + * can be refused there rather than silently rewriting someone's branch. + */ + const fastForwardDestinationLane = useCallback(async () => { + const target = destinationPreflight?.laneFastForward; + if (!target || !selectedConnection || !destinationProject || !prepared) return; + setBusyLabel("Fast-forwarding the lane on the other machine…"); + setError(null); + try { + await window.ade.remoteRuntime.callAction(selectedConnection.target.id, destinationProject.projectId, { + domain: "chat", + action: "fastForwardCrossMachineHandoffLane", + args: { laneId: target.laneId, expectedHead: prepared.capsule.source.headSha }, + }); + await runDestinationPreflight(selectedConnection, destinationProject, prepared, mode); + } catch (ffError) { + setError(ffError instanceof Error ? ffError.message : String(ffError)); + } finally { + setBusyLabel(null); + } + }, [destinationPreflight, destinationProject, mode, prepared, runDestinationPreflight, selectedConnection]); + const markSource = useCallback(async ( accepted: AgentChatAcceptCrossMachineHandoffResult, connection: RemoteRuntimeConnectionStatus, @@ -530,6 +725,7 @@ export function CrossMachineHandoffModal({ if (destinationPreflight.blockingErrors.length) return; if (isInsecureRoute(selectedConnection) && !routeApproved) return; setStage("sending"); + setSendProgress([]); setBusyLabel("Rechecking source branch and chat…"); setError(null); let destinationAcceptanceStarted = false; @@ -539,6 +735,7 @@ export function CrossMachineHandoffModal({ capsule: prepared.capsule, capsuleFingerprint: prepared.capsuleFingerprint, }); + setSendProgress(["validate"]); setBusyLabel("Creating destination lane and chat…"); const requiredRouteKind = requireRemoteRuntimeRouteKind(selectedConnection.route?.kind); destinationAcceptanceStarted = true; @@ -556,6 +753,7 @@ export function CrossMachineHandoffModal({ }, ); const accepted = decodeAcceptCrossMachineHandoffResult(response.result); + setSendProgress(["validate", "accept"]); setResult(accepted); try { await markSource(accepted, selectedConnection); @@ -596,10 +794,66 @@ export function CrossMachineHandoffModal({ if (!open) return null; const hasSourceBlock = sourceCheck.blockingErrors.length > 0; + // Fix buttons share the modal's single busy slot, so they grey out together + // with everything else while an operation is running. + const sourceBlockReasons: BlockedActionReason[] = sourceCheck.blockingErrors.map((reason) => ( + reason.fix ? { ...reason, fix: { ...reason.fix, busy: Boolean(busyLabel) } } : reason + )); + /** + * Everything standing between the user and "Continue". Assembled in one place + * so the button cannot be disabled for a reason the user was never shown — + * the blockers below the checks and the button's own tooltip read from this + * same list. + */ + const continueBlockers: BlockedActionReason[] = [ + ...sourceBlockReasons, + // Only when a plain push can actually resolve it. A diverged branch also + // reports needsPush (ahead > 0), but its upstream already exists and the + // push would be rejected as non-fast-forward — offering Publish there sits + // next to the divergence blocker suggesting a fix that cannot work. + ...(sourceCheck.needsPush && !hasSourceBlock + ? [{ + id: "needs-push", + title: `${sourceCheck.branch ?? "This branch"} hasn't been published`, + detail: "The other machine fetches your work from origin, so the branch has to exist there.", + fix: { label: "Publish branch", onFix: () => void publishBranch(), busy: Boolean(busyLabel) }, + }] + : []), + ...(!selectedConnection + ? [{ + id: "no-machine", + title: "No machine selected", + detail: eligibleConnections.length === 0 + ? "No other ADE machine is connected right now." + : "Pick which computer should continue this chat.", + }] + : []), + ...(turnActive + ? [{ + id: "turn-active", + title: "This chat is still responding", + detail: "Stop the current response, or wait for it to finish.", + fix: { label: "Stop current response", onFix: () => void onStopTurn(), busy: Boolean(busyLabel) }, + }] + : []), + ...(awaitingInput + ? [{ + id: "awaiting-input", + title: "This chat is waiting on you", + detail: "Resolve the pending approval or question in the chat first.", + }] + : []), + ]; // A fork prepared against a destination that can't fork must not send as-is; // the user switches to a brief (one click) or backs out. const forkUnsupportedAtReview = mode === "fork" && forkHandoffSupport != null && !forkHandoffSupport.supported; - const reviewBlocked = Boolean(destinationPreflight?.blockingErrors.length) || forkUnsupportedAtReview; + // A pending fast-forward must gate Send. Preflight reports it as a warning so + // the offer can render, but `acceptCrossMachineHandoff` still requires the + // destination lane to be at the exact source commit — without this the user + // could send and hit a hard failure after acceptance had already started. + const reviewBlocked = Boolean(destinationPreflight?.blockingErrors.length) + || Boolean(destinationPreflight?.laneFastForward) + || forkUnsupportedAtReview; const routeNeedsApproval = isInsecureRoute(selectedConnection); const reviewIsFork = prepared?.capsule.mode === "fork"; const handoffMayStillComplete = error === CROSS_MACHINE_HANDOFF_STILL_COMPLETING_MESSAGE; @@ -732,8 +986,8 @@ export function CrossMachineHandoffModal({ /> )} - {turnActive || awaitingInput ? ( -
-
- -
-
Current turn is not ready
-
- {turnActive ? "Stop the current response, or wait for it to finish." : "Resolve the pending approval or question in the chat."} -
- {turnActive ? ( - - ) : null} -
-
-
- ) : null} - {sourceCheck.needsPush && !hasSourceBlock ? ( - - ) : null} + {/* + Every blocker renders here and only here. Standalone panels for + the active turn and the unpublished branch used to sit + alongside this list, so the same blocker and the same fix button + appeared twice — with different disabled behavior on each copy. + */} + 1 + ? { heading: `${continueBlockers.length} things to fix first` } + : {})} + />
@@ -808,6 +1041,14 @@ export function CrossMachineHandoffModal({
{connection.route?.kind === "ssh" || connection.route?.kind === "tailnet" ? : } {routeLabel(connection)} + {repoReadinessLabel(machineRepoReadiness[connection.target.id]) ? ( + <> + · + + {repoReadinessLabel(machineRepoReadiness[connection.target.id])} + + + ) : null}
{selected ? : null} @@ -828,9 +1069,15 @@ export function CrossMachineHandoffModal({ ) : null} {onModelChange && modelId != null ? ( -
- Model for the new chat -
+
+ The new chat + {/* + Same control row as the composer, so there is nothing new to + learn and each picker keeps its own "can this model do it?" + logic — ReasoningEffortPicker renders nothing for a model + with no tiers, and fast mode only appears where supported. + */} +
+ {onReasoningEffortChange ? ( + + ) : null} + {destinationFastModeSupported && onFastModeChange ? ( + + ) : null} + {destinationPermissionPicker}
{mode === "fork" ? ( - Forked history stays with {providerLabel}; any {providerLabel} model is fine. + Forked history stays with {providerLabel}; any {providerLabel} model is fine. ) : null}
) : null} @@ -891,7 +1161,7 @@ export function CrossMachineHandoffModal({ 0 ? formatBytes(storagePreflight.freeBytes) : "Unavailable"} free · ${formatBytes(storagePreflight.requiredBytes)} minimum`} state={storagePreflight.blockingErrors.some((item) => /space/i.test(item)) ? "error" : storagePreflight.warnings.length ? "warn" : "ok"} />
@@ -951,6 +1221,31 @@ export function CrossMachineHandoffModal({ {destinationPreflight.blockingErrors.map((message) => (
{message}
))} + {destinationPreflight.laneFastForward ? ( + /* + The destination's lane is clean and a strict ancestor of your + commit, so it can catch up without losing anything. Offered + rather than done automatically: this rewrites git state on a + machine the user isn't sitting at. + */ +
+
+ Lane ‘{destinationPreflight.laneFastForward.laneName}’ is {destinationPreflight.laneFastForward.behindBy}{" "} + {destinationPreflight.laneFastForward.behindBy === 1 ? "commit" : "commits"} behind +
+
+ It’s clean, so ADE can fast-forward it to your commit on {selectedConnection?.target.name ?? "that machine"}. Nothing is discarded. +
+ +
+ ) : null}
What gets sent @@ -1006,7 +1301,32 @@ export function CrossMachineHandoffModal({
- {busyLabel ? ( + {stage === "sending" ? ( +
+ {SEND_STEPS.map((step) => { + const done = sendProgress.includes(step.id); + const current = !done && sendProgress.length === SEND_STEPS.findIndex((item) => item.id === step.id); + return ( + + {done ? ( + + ) : current ? ( + + ) : ( + + )} + {step.label} + + ); + })} +
+ ) : busyLabel ? ( {busyLabel} ) : stage === "choose" ? "Nothing is sent until you confirm." : stage === "complete" ? "This chat stays here too." : "Retrying is safe."}
@@ -1030,14 +1350,13 @@ export function CrossMachineHandoffModal({ ) : null} {stage === "choose" ? ( - + ) : null} {stage === "clone" ? ( + ) : null} +
+ ))} +
+
+ ); +} + +export function BlockedActionButton({ + reasons, + busy, + onClick, + children, + className, + type = "button", +}: { + /** + * Every reason the action cannot run. Non-empty disables the button, and the + * reasons become its tooltip and accessible description — which is why this + * replaces a plain `disabled` prop. + */ + reasons: BlockedActionReason[]; + /** In-flight state. Disables without implying the action is blocked. */ + busy?: boolean; + onClick: () => void; + children: ReactNode; + className?: string; + type?: "button" | "submit"; +}): JSX.Element { + const blocked = reasons.length > 0; + const summary = blocked ? describeBlockedReasons(reasons) : undefined; + return ( + + ); +} + +function blockedReasonDomId(id: string): string { + return `blocked-reason-${id}`; +} diff --git a/apps/desktop/src/renderer/components/shared/PermissionModePicker.tsx b/apps/desktop/src/renderer/components/shared/PermissionModePicker.tsx new file mode 100644 index 000000000..61bb786ee --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/PermissionModePicker.tsx @@ -0,0 +1,291 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { + CaretDown, + Check, + Desktop, + GearSix, + Lightning, + PencilSimple, + RocketLaunch, + ShieldCheck, + ShieldWarning, + Strategy, +} from "@phosphor-icons/react"; +import { cn } from "../ui/cn"; + +/** + * The permission-mode pill from the chat composer, lifted into `shared/` so the + * cross-machine handoff modal can render the exact same control. + * + * It used to live inside `AgentChatComposer.tsx` as a private component, which + * is why the handoff modal shipped with a model picker and nothing else — there + * was no permission control it could reach without duplicating one. Anything + * that lets a user choose how a chat starts should render this, not a lookalike. + */ + +const PERMISSION_MODE_MENU_WIDTH = 240; + +/** + * Shared trigger chrome for permission pills. Previously hand-copied into both + * `AgentChatComposer` and `SessionLaunchModelControls`; every surface that shows + * a permission pill reads it from here so they stay one control visually. + */ +export const PERMISSION_TRIGGER_CLASS = cn( + "ade-chat-composer-permission-trigger", + "inline-flex h-6 min-w-0 shrink-0 items-center justify-start gap-1 rounded-md border px-1.5", + // The fallback in the calc() matters. `--chat-font-size` is only defined on a + // chat appearance root, so on the launch and handoff surfaces the bare token + // computes as invalid and the pill silently inherits the ambient size. With + // 14px as the fallback the expression is always valid: chat-scaled inside a + // chat, and the original fixed 10.5px everywhere else. + "font-sans text-[length:calc(var(--chat-font-size,14px)*9/14)] leading-none transition-colors duration-150", + "border-white/[0.06] bg-white/[0.03] text-fg/80", + "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", +); + +export type PermissionModeTone = "green" | "amber" | "blue" | "purple" | "red" | "slate"; +export type PermissionModeIconKind = "manual" | "auto" | "edit" | "plan" | "full" | "config" | "agent" | "agi"; + +export type PermissionModePickerOption = { + value: Value; + label: string; + triggerLabel?: string; + detail: string; + tone: PermissionModeTone; + icon: PermissionModeIconKind; +}; + +const PERMISSION_MODE_TONE_STYLES: Record< + PermissionModeTone, + { + dot: string; + trigger: string; + iconSurface: string; + rowActive: string; + rowHover: string; + } +> = { + green: { + dot: "bg-emerald-400", + trigger: "border-emerald-400/24 bg-emerald-500/[0.08] text-emerald-100", + iconSurface: "border-emerald-300/20 bg-emerald-500/[0.12] text-emerald-200", + rowActive: "bg-emerald-500/[0.12] text-emerald-50", + rowHover: "hover:bg-emerald-500/[0.08] hover:text-emerald-50", + }, + amber: { + dot: "bg-amber-400", + trigger: "border-amber-300/22 bg-amber-500/[0.08] text-amber-100", + iconSurface: "border-amber-300/20 bg-amber-500/[0.12] text-amber-200", + rowActive: "bg-amber-500/[0.12] text-amber-50", + rowHover: "hover:bg-amber-500/[0.08] hover:text-amber-50", + }, + blue: { + dot: "bg-sky-400", + trigger: "border-sky-300/22 bg-sky-500/[0.08] text-sky-100", + iconSurface: "border-sky-300/20 bg-sky-500/[0.12] text-sky-200", + rowActive: "bg-sky-500/[0.12] text-sky-50", + rowHover: "hover:bg-sky-500/[0.08] hover:text-sky-50", + }, + purple: { + dot: "bg-violet-400", + trigger: "border-violet-300/24 bg-violet-500/[0.09] text-violet-100", + iconSurface: "border-violet-300/20 bg-violet-500/[0.14] text-violet-200", + rowActive: "bg-violet-500/[0.14] text-violet-50", + rowHover: "hover:bg-violet-500/[0.08] hover:text-violet-50", + }, + red: { + dot: "bg-red-400", + trigger: "border-red-300/24 bg-red-500/[0.09] text-red-100", + iconSurface: "border-red-300/20 bg-red-500/[0.14] text-red-200", + rowActive: "bg-red-500/[0.14] text-red-50", + rowHover: "hover:bg-red-500/[0.08] hover:text-red-50", + }, + slate: { + dot: "bg-slate-300", + trigger: "border-white/[0.08] bg-white/[0.045] text-fg/80", + iconSurface: "border-white/[0.08] bg-white/[0.06] text-fg/72", + rowActive: "bg-white/[0.08] text-fg/90", + rowHover: "hover:bg-white/[0.055] hover:text-fg/90", + }, +}; + +export function PermissionModeGlyph({ + icon, + size = 11, + className, +}: { + icon: PermissionModeIconKind; + size?: number; + className?: string; +}) { + switch (icon) { + case "manual": + return ; + case "auto": + return ; + case "edit": + return ; + case "plan": + return ; + case "full": + return ; + case "config": + return ; + case "agent": + return ; + case "agi": + return ; + } +} + +export function PermissionModePicker({ + ariaLabel, + selectedValue, + options, + disabled, + onSelect, + title, + menuLayerClassName = "z-[100]", +}: { + ariaLabel: string; + selectedValue: Value; + options: Array>; + disabled?: boolean; + onSelect?: (value: Value) => void; + title?: string; + /** + * Tailwind z-index for the portalled option list. The default sits above the + * composer, but a caller inside a modal must raise it above that modal's + * overlay: the list is portalled to `document.body`, so it does not inherit + * the modal's stacking context and would otherwise render behind the + * backdrop where it cannot be clicked. + */ + menuLayerClassName?: string; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + const selectedOption = options.find((option) => option.value === selectedValue) ?? options[0]; + const selectedTone = PERMISSION_MODE_TONE_STYLES[selectedOption?.tone ?? "slate"]; + + useEffect(() => { + if (!open) return; + const handleClick = (event: MouseEvent) => { + if (ref.current?.contains(event.target as Node)) return; + const target = event.target as Element | null; + if (target?.closest?.("[data-permission-mode-picker-dropdown]")) return; + setOpen(false); + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + window.addEventListener("mousedown", handleClick); + window.addEventListener("keydown", handleKey); + return () => { + window.removeEventListener("mousedown", handleClick); + window.removeEventListener("keydown", handleKey); + }; + }, [open]); + + if (!selectedOption) return null; + + const triggerTitle = title ?? selectedOption.detail; + + return ( +
+ + {open && ref.current ? createPortal( + (() => { + const rect = ref.current.getBoundingClientRect(); + const width = PERMISSION_MODE_MENU_WIDTH; + const left = Math.min(Math.max(8, rect.left), Math.max(8, window.innerWidth - width - 8)); + return ( +
+
    + {options.map((option) => { + const active = option.value === selectedValue; + const tone = PERMISSION_MODE_TONE_STYLES[option.tone]; + return ( +
  • + +
  • + ); + })} +
+
+ ); + })(), + document.body, + ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx b/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx index a7ad0c328..f47d1b0eb 100644 --- a/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx +++ b/apps/desktop/src/renderer/components/shared/SessionLaunchModelControls.tsx @@ -21,17 +21,11 @@ import { ReasoningEffortPicker } from "./ModelPicker/ReasoningEffortPicker"; import { resolveModelDescriptorWithRuntimeCatalog } from "./ModelPicker/modelCatalog"; import { getModelById, resolveProviderGroupForModel } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; +import { PERMISSION_TRIGGER_CLASS } from "./PermissionModePicker"; const COMPOSER_TOOLBAR_PICKER_TRIGGER = "max-w-[min(9.5rem,34vw)] shrink min-w-0"; const COMPOSER_MODEL_TRIGGER = "max-w-[min(9.5rem,34vw)] shrink min-w-[4.5rem]"; -const COMPOSER_PERMISSION_TRIGGER_CLASS = cn( - "inline-flex h-6 min-w-0 shrink-0 items-center justify-start gap-1 rounded-md border px-1.5", - "font-sans text-[10.5px] leading-none transition-colors duration-150", - "border-white/[0.06] bg-white/[0.03] text-fg/80", - "hover:border-violet-400/20 hover:bg-violet-500/[0.06] hover:text-fg", -); - function SessionTypeToggle({ value, onChange, @@ -112,7 +106,7 @@ function LaunchNativePermissionControls({ type="button" disabled={disabled} onClick={() => setClaudeOpen((open) => !open)} - className={cn(COMPOSER_PERMISSION_TRIGGER_CLASS, claudeOpen && "border-violet-400/30 bg-violet-500/[0.08]")} + className={cn(PERMISSION_TRIGGER_CLASS, claudeOpen && "border-violet-400/30 bg-violet-500/[0.08]")} title={selected.detail} > {selected.label} @@ -160,7 +154,7 @@ function LaunchNativePermissionControls({ type="button" disabled={disabled} onClick={() => setCodexOpen((open) => !open)} - className={cn(COMPOSER_PERMISSION_TRIGGER_CLASS, codexOpen && "border-violet-400/30 bg-violet-500/[0.08]")} + className={cn(PERMISSION_TRIGGER_CLASS, codexOpen && "border-violet-400/30 bg-violet-500/[0.08]")} title={activePreset?.detail} > {label} diff --git a/apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx b/apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx index 08368c07c..5adcab5a2 100644 --- a/apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx +++ b/apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx @@ -27,7 +27,7 @@ import { shortenCwd, type ImportAffordance, } from "./affordances"; -import { formatUpdatedAt, sessionHeading } from "./sessionPresentation"; +import { formatUpdatedAt, sessionAnchors, sessionHeading } from "./sessionPresentation"; const PROVIDER_FILTERS: Array<{ id: ExternalSessionProvider | "all"; label: string }> = [ { id: "all", label: "All" }, @@ -183,7 +183,10 @@ export function ImportSessionBrowser({ .filter((s) => (providerFilter === "all" ? true : s.provider === providerFilter)) .filter((s) => q - ? [s.title, s.preview, s.cwd, s.id] + // Search the whole thread sample, not just the title: the words you + // remember from a conversation are usually in the conversation, and + // provider titles are frequently absent entirely. + ? [s.title, s.preview, s.cwd, s.id, ...(s.messages ?? []).map((m) => m.text)] .some((value) => value?.toLowerCase().includes(q)) : true, ) @@ -269,7 +272,9 @@ export function ImportSessionBrowser({ title="Import session" icon={DownloadSimple} widthClassName="w-[min(980px,calc(100vw-4rem))]" - heightClassName="h-[min(860px,calc(100dvh-4rem))]" + // Content-driven, not pinned: the details stage is far shorter than the + // list stage, and a fixed height left ~400px of dead space under it. + heightClassName="max-h-[min(860px,calc(100dvh-4rem))]" busy={Boolean(importing)} >
@@ -458,6 +463,7 @@ function ImportSessionRow({ }) { const heading = sessionHeading(summary); const preview = summary.preview?.trim(); + const anchors = sessionAnchors(summary); return (
  • - {preview && preview !== heading ? ( + {anchors.started || anchors.latest ? ( + /* + Two anchors beat one snippet: what the thread started as, and where + it left off. Picking the right session out of a long list is almost + always a question of "which task was this", which the opening ask + answers, plus "how far did it get", which the last turn answers. + */ +
    + {anchors.started ? ( +

    + started + {anchors.started} +

    + ) : null} + {anchors.latest ? ( +

    + latest + {anchors.latest.text} +

    + ) : null} +
    + ) : preview && preview !== heading ? ( + // Older hosts predate the anchors; fall back to the single snippet.

    {preview}

    ) : null}
  • @@ -560,6 +588,7 @@ function ImportSessionDetail({ const unavailable = allAffordances.filter((action) => !action.enabled); const heading = sessionHeading(summary); const preview = summary.preview?.trim(); + const messages = summary.messages ?? []; const choose = (affordance: ImportAffordance) => { if ( @@ -604,7 +633,54 @@ function ImportSessionDetail({ - {preview ? ( + {messages.length > 0 ? ( + /* + The thread is what you are actually identifying, so it gets the room + the old fixed-height dialog was wasting below the actions. Bounded + and scrollable so long samples never push the action grid off-screen. + */ +
    +
    + Last {messages.length} {messages.length === 1 ? "message" : "messages"} + {summary.id} +
    + {/* Focusable so keyboard-only users can scroll a transcript that + overflows; nothing inside it is otherwise focusable. */} +
    + {messages.map((message, index) => ( +
    0 && "border-t border-white/[0.035]", + )} + > + + {message.role === "user" ? "you" : "ADE"} + + + {message.text} + +
    + ))} +
    +
    + ) : preview ? (
    {preview}
    diff --git a/apps/desktop/src/renderer/components/terminals/importSessions/affordances.test.ts b/apps/desktop/src/renderer/components/terminals/importSessions/affordances.test.ts index 7b2298fae..c43c3dfd6 100644 --- a/apps/desktop/src/renderer/components/terminals/importSessions/affordances.test.ts +++ b/apps/desktop/src/renderer/components/terminals/importSessions/affordances.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { importAffordancesFor, shortenCwd } from "./affordances"; -import { sessionHeading } from "./sessionPresentation"; +import { sessionAnchors, sessionHeading } from "./sessionPresentation"; import type { ExternalSessionCapabilities, ExternalSessionSummary } from "./contract"; const NO_CAPS: ExternalSessionCapabilities = { @@ -246,12 +246,87 @@ describe("shortenCwd", () => { }); describe("sessionHeading", () => { - it("keeps an untitled session's prompt as preview instead of duplicating it as the title", () => { + /** + * Behavior change: this used to fall back to the folder name so the prompt was + * not printed twice. Folder + time turned out to say nothing about the thread + * ("ADE · 9m ago"), so the heading now leads with the opening prompt and the + * duplicate is prevented by suppression instead — `sessionAnchors` drops + * `started` when it matches the heading, and the row hides a preview equal to + * it. The no-duplication invariant is preserved; the heading is just useful now. + */ + it("uses an untitled session's opening prompt as the heading", () => { expect(sessionHeading(session({ title: null, preview: "this is a test message", cwd: "/Users/dev/ADE", updatedAt: null, + }))).toBe("this is a test message"); + }); + + it("still falls back to the folder when there is no prompt to show", () => { + expect(sessionHeading(session({ + title: null, + preview: null, + cwd: "/Users/dev/ADE", + updatedAt: null, }))).toBe("ADE"); }); + + it("prefers a real provider title over the prompt", () => { + expect(sessionHeading(session({ title: "Ship the relay fix", preview: "anything" }))) + .toBe("Ship the relay fix"); + }); + + it("collapses whitespace and clips a very long opening prompt", () => { + const heading = sessionHeading(session({ title: null, preview: `${"word ".repeat(60)}end` })); + expect(heading.length).toBeLessThanOrEqual(72); + expect(heading.endsWith("\u2026")).toBe(true); + expect(heading).not.toMatch(/\s{2,}/); + }); +}); + +describe("sessionAnchors", () => { + it("returns the opening ask and the latest message", () => { + const anchors = sessionAnchors(session({ + title: "Mobile chat truncation", + preview: "text is cut mid-word", + messages: [ + { role: "user", text: "text is cut mid-word", at: 1 }, + { role: "assistant", text: "Found it \u2014 byte-offset split.", at: 2 }, + { role: "user", text: "now shrink the logo", at: 3 }, + ], + })); + expect(anchors.started).toBe("text is cut mid-word"); + expect(anchors.latest?.text).toBe("now shrink the logo"); + }); + + it("suppresses an anchor that would repeat the heading", () => { + // Untitled single-message thread: heading === preview === that message, so + // printing it again below reads as a rendering bug. + const anchors = sessionAnchors(session({ + title: null, + preview: "only one thing was ever said", + messages: [{ role: "user", text: "only one thing was ever said", at: 1 }], + })); + expect(anchors.started).toBeNull(); + expect(anchors.latest).toBeNull(); + }); + + it("suppresses a latest anchor that would repeat the started anchor", () => { + // A titled single-message thread: heading is the title, so `latest` clears + // the heading check but still duplicates `started`. + const anchors = sessionAnchors(session({ + title: "Mobile chat truncation", + preview: "text is cut mid-word", + messages: [{ role: "user", text: "text is cut mid-word", at: 1 }], + })); + expect(anchors.started).toBe("text is cut mid-word"); + expect(anchors.latest).toBeNull(); + }); + + it("has no latest anchor on an older host that sends no messages", () => { + const anchors = sessionAnchors(session({ title: "Something", preview: "a preview" })); + expect(anchors.started).toBe("a preview"); + expect(anchors.latest).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/importSessions/contract.ts b/apps/desktop/src/renderer/components/terminals/importSessions/contract.ts index 96e34f769..b2a3aea27 100644 --- a/apps/desktop/src/renderer/components/terminals/importSessions/contract.ts +++ b/apps/desktop/src/renderer/components/terminals/importSessions/contract.ts @@ -11,6 +11,7 @@ import type { TerminalToolType } from "../../../../shared/types"; export type { ExternalSessionProvider, ExternalSessionCapabilities, + ExternalSessionMessage, ExternalSessionSummary, ExternalSessionListArgs, ExternalSessionImportArgs, diff --git a/apps/desktop/src/renderer/components/terminals/importSessions/sessionPresentation.ts b/apps/desktop/src/renderer/components/terminals/importSessions/sessionPresentation.ts index 19c71f02b..70d0982bf 100644 --- a/apps/desktop/src/renderer/components/terminals/importSessions/sessionPresentation.ts +++ b/apps/desktop/src/renderer/components/terminals/importSessions/sessionPresentation.ts @@ -1,5 +1,5 @@ import { relativeWhen } from "../../../lib/format"; -import type { ExternalSessionSummary } from "./contract"; +import type { ExternalSessionMessage, ExternalSessionSummary } from "./contract"; import { shortenCwd } from "./affordances"; export function formatUpdatedAt(ms: number | null | undefined): string { @@ -13,10 +13,54 @@ function lastPathSegment(cwd: string | null | undefined): string | null { return segments.at(-1) ?? null; } +/** Collapses a prompt to one line so it can stand in as a heading. */ +function asHeadingText(value: string | null | undefined): string | null { + const collapsed = value?.replace(/\s+/gu, " ").trim(); + if (!collapsed) return null; + return collapsed.length > 72 ? `${collapsed.slice(0, 71).trimEnd()}…` : collapsed; +} + +/** + * Rows lead with a real provider-persisted title when there is one. Most Claude + * CLI transcripts have none, which is why every such row used to degrade to + * "ADE · 9m ago" — the folder name and a timestamp, telling you nothing about + * the thread. The opening prompt (`preview`) is a far better name for the work, + * so it comes next, and path+time stays as the last resort. + */ export function sessionHeading(summary: ExternalSessionSummary): string { const title = summary.title?.trim(); if (title) return title; + const opening = asHeadingText(summary.preview); + if (opening) return opening; const where = lastPathSegment(summary.cwd) ?? shortenCwd(summary.cwd); const when = formatUpdatedAt(summary.updatedAt); return when ? `${where} · ${when}` : where; } + +/** + * The two anchors a row shows: what the thread started as, and where it left + * off. Either may be absent — an older host predates both fields, and a thread + * whose only human text was a slash command has no recoverable prompt. + * + * `started` is suppressed when the heading is already showing it, so a row never + * prints the same sentence twice. + */ +export function sessionAnchors(summary: ExternalSessionSummary): { + started: string | null; + latest: ExternalSessionMessage | null; +} { + const heading = sessionHeading(summary); + const started = asHeadingText(summary.preview); + const messages = summary.messages ?? []; + const latest = messages.length > 0 ? messages[messages.length - 1]! : null; + const latestText = latest ? asHeadingText(latest.text) : null; + const startedText = started && started !== heading ? started : null; + return { + started: startedText, + // `latest` is checked against both anchors. Against the heading because an + // untitled single-message thread has heading === preview === that message, + // and against `started` because a *titled* one has started === latest. Either + // collision prints the same sentence twice, which reads as a rendering bug. + latest: latest && latestText !== heading && latestText !== startedText ? latest : null, + }; +} diff --git a/apps/desktop/src/shared/crossMachineHandoff.ts b/apps/desktop/src/shared/crossMachineHandoff.ts index bd421f3aa..c9e2c37b4 100644 --- a/apps/desktop/src/shared/crossMachineHandoff.ts +++ b/apps/desktop/src/shared/crossMachineHandoff.ts @@ -117,6 +117,18 @@ export function decodeCrossMachineDestinationPreflightResult( : {}), }; } + // Also absent on older destinations, and absent whenever a fast-forward would + // not be safe there. Its presence is the destination's own assertion that the + // lane is clean and a strict ancestor — the source never infers it. + let laneFastForward: AgentChatCrossMachineDestinationPreflightResult["laneFastForward"]; + if (record.laneFastForward != null) { + const candidate = requireRecord(record.laneFastForward, "Destination lane fast-forward"); + laneFastForward = { + laneId: requireString(candidate.laneId, "Destination fast-forward lane identifier"), + laneName: requireString(candidate.laneName, "Destination fast-forward lane name"), + behindBy: requirePositiveInteger(candidate.behindBy, "Destination fast-forward distance"), + }; + } return { providerAuthorized: requireBoolean(record.providerAuthorized, "Destination provider authorization"), modelAvailable: requireBoolean(record.modelAvailable, "Destination model availability"), @@ -125,9 +137,22 @@ export function decodeCrossMachineDestinationPreflightResult( blockingErrors: requireStringList(record.blockingErrors, "Destination handoff errors"), warnings: requireStringList(record.warnings, "Destination handoff warnings"), ...(forkHandoffSupport ? { forkHandoffSupport } : {}), + ...(laneFastForward ? { laneFastForward } : {}), }; } +// A zero-distance fast-forward is not a thing the destination can honor — it +// refuses "already at the expected source commit" — so reject it at the door +// rather than rendering an offer that cannot succeed. +function requirePositiveInteger(value: unknown, label: string): number { + // isSafeInteger, not isInteger: Number.isInteger(1e30) is true, and a value + // past 2^53 has already lost precision by the time it gets here. + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { + throw new Error(`${label} must be a positive integer.`); + } + return value; +} + export function decodeAcceptCrossMachineHandoffResult( value: unknown, ): AgentChatAcceptCrossMachineHandoffResult { diff --git a/apps/desktop/src/shared/types/chat.test.ts b/apps/desktop/src/shared/types/chat.test.ts index 21d46f443..836da79c2 100644 --- a/apps/desktop/src/shared/types/chat.test.ts +++ b/apps/desktop/src/shared/types/chat.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest"; import { inferAttachmentType, mergeAttachments, + providerSupportsCrossMachineHandoffFork, + providerSupportsHandoffFork, type AgentChatFileRef, type AgentChatModelsArgs, } from "./chat"; @@ -13,6 +15,18 @@ describe("AgentChatModelsArgs", () => { }); }); +describe("handoff fork provider support", () => { + it("keeps local Droid forks enabled while refusing cross-machine Droid forks", () => { + expect(providerSupportsHandoffFork("droid")).toBe(true); + expect(providerSupportsCrossMachineHandoffFork("droid")).toBe(false); + expect(providerSupportsCrossMachineHandoffFork("claude")).toBe(true); + expect(providerSupportsCrossMachineHandoffFork("codex")).toBe(true); + expect(providerSupportsCrossMachineHandoffFork("opencode")).toBe(true); + expect(providerSupportsCrossMachineHandoffFork("cursor")).toBe(false); + expect(providerSupportsCrossMachineHandoffFork("unknown-provider")).toBe(false); + }); +}); + describe("inferAttachmentType", () => { it("returns 'image' for image MIME types", () => { expect(inferAttachmentType("file.bin", "image/png")).toBe("image"); diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 29e7c52cd..d6a9432d3 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -1970,6 +1970,20 @@ export function providerSupportsHandoffFork(provider: AgentChatProvider | null | return provider != null && (HANDOFF_FORK_PROVIDERS as readonly string[]).includes(provider); } +/** + * Droid can fork locally, but its session index is machine-local, so the + * relocated-file resume path is not portable across ADE machines yet. Derived + * from the local set rather than restated, so adding a provider to one list + * cannot silently leave the other behind. + */ +export const CROSS_MACHINE_HANDOFF_FORK_PROVIDERS = HANDOFF_FORK_PROVIDERS + .filter((provider) => provider !== "droid"); + +export function providerSupportsCrossMachineHandoffFork(provider: string | null | undefined): boolean { + return provider != null + && (CROSS_MACHINE_HANDOFF_FORK_PROVIDERS as readonly string[]).includes(provider); +} + export type AgentChatHandoffArgs = { sourceSessionId: string; targetModelId: ModelId; @@ -2134,6 +2148,16 @@ export type AgentChatCrossMachineDestinationPreflightResult = { supported: boolean; reason?: string; }; + /** + * Present when the destination's existing lane is clean and a strict ancestor of the + * source commit, so ADE can safely fast-forward it instead of blocking. Absent on older + * destinations and whenever a fast-forward would not be safe. + */ + laneFastForward?: { + laneId: string; + laneName: string; + behindBy: number; + }; }; export type AgentChatAcceptCrossMachineHandoffArgs = { diff --git a/apps/desktop/src/shared/types/externalSessions.ts b/apps/desktop/src/shared/types/externalSessions.ts index 68542b46a..63b02ee05 100644 --- a/apps/desktop/src/shared/types/externalSessions.ts +++ b/apps/desktop/src/shared/types/externalSessions.ts @@ -8,12 +8,29 @@ export interface ExternalSessionCapabilities { importToChat: boolean; } +/** One human/assistant turn sampled from a provider transcript for preview purposes. */ +export interface ExternalSessionMessage { + role: "user" | "assistant"; + text: string; + at: number | null; +} + export interface ExternalSessionSummary { provider: ExternalSessionProvider; id: string; cwd: string | null; title: string | null; preview: string | null; + /** + * Recent user/assistant exchanges, oldest to newest, so a row can show where the thread + * left off and a detail view can render a readable slice of it. `preview` already + * carries the opening prompt, so there is no separate first-prompt field. + * + * Additive and optional on purpose: the iOS mirror decodes every field with + * `decodeIfPresent`, and a decode failure drops the whole row silently. Never re-type an + * existing field here — only add new nullable ones. + */ + messages?: ExternalSessionMessage[] | null; createdAt: number | null; updatedAt: number | null; messageCount: number | null; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index d22b698e4..34fc91868 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1480,6 +1480,7 @@ export type SyncRemoteCommandAction = | "chat.prepareCrossMachineHandoff" | "chat.validateCrossMachineSource" | "chat.preflightCrossMachineDestination" + | "chat.fastForwardCrossMachineHandoffLane" | "chat.acceptCrossMachineHandoff" | "chat.markCrossMachineHandoff" | "chat.getContextUsage" diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index a26614088..c148ce851 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -4364,12 +4364,45 @@ struct ExternalSessionImportedRef: Codable, Equatable { var sessionId: String } +struct ExternalSessionMessage: Codable, Equatable { + var role: String + var text: String + var at: Double? + + private enum CodingKeys: String, CodingKey { + case role + case text + case at + } + + init(role: String, text: String, at: Double? = nil) { + self.role = role + self.text = text + self.at = at + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + role = try container.decode(String.self, forKey: .role) + guard role == "user" || role == "assistant" else { + throw DecodingError.dataCorruptedError( + forKey: .role, + in: container, + debugDescription: "External session message role must be user or assistant." + ) + } + text = try container.decode(String.self, forKey: .text) + at = try container.decodeIfPresent(Double.self, forKey: .at) + } +} + struct ExternalSessionSummary: Codable, Identifiable, Equatable { var provider: String var id: String var cwd: String? var title: String? var preview: String? + var messages: [ExternalSessionMessage]? var createdAt: Double? var updatedAt: Double? var messageCount: Int? @@ -4385,6 +4418,7 @@ struct ExternalSessionSummary: Codable, Identifiable, Equatable { case cwd case title case preview + case messages case createdAt case updatedAt case messageCount @@ -4401,6 +4435,7 @@ struct ExternalSessionSummary: Codable, Identifiable, Equatable { cwd: String? = nil, title: String? = nil, preview: String? = nil, + messages: [ExternalSessionMessage]? = nil, createdAt: Double? = nil, updatedAt: Double? = nil, messageCount: Int? = nil, @@ -4415,6 +4450,7 @@ struct ExternalSessionSummary: Codable, Identifiable, Equatable { self.cwd = cwd self.title = title self.preview = preview + self.messages = messages self.createdAt = createdAt self.updatedAt = updatedAt self.messageCount = messageCount @@ -4432,6 +4468,14 @@ struct ExternalSessionSummary: Codable, Identifiable, Equatable { cwd = try container.decodeIfPresent(String.self, forKey: .cwd) title = try container.decodeIfPresent(String.self, forKey: .title) preview = try container.decodeIfPresent(String.self, forKey: .preview) + if let decodedMessages = try? container.decodeIfPresent( + ADELossyArray.self, + forKey: .messages + ) { + messages = decodedMessages.wrappedValue + } else { + messages = nil + } createdAt = try container.decodeIfPresent(Double.self, forKey: .createdAt) updatedAt = try container.decodeIfPresent(Double.self, forKey: .updatedAt) messageCount = try container.decodeIfPresent(Int.self, forKey: .messageCount) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index d616569f3..454922324 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7979,7 +7979,8 @@ final class SyncService: ObservableObject { laneId: String? = nil, cwd: String? = nil, scope: String = "project", - limit: Int? = nil + limit: Int? = nil, + sessionId: String? = nil ) async throws -> [ExternalSessionSummary] { var args: [String: Any] = ["scope": scope] if let providers, !providers.isEmpty { @@ -7994,6 +7995,9 @@ final class SyncService: ObservableObject { if let limit, limit > 0 { args["limit"] = limit } + if let sessionId, !sessionId.isEmpty { + args["sessionId"] = sessionId + } let result = try await sendDecodableCommand( action: "work.listExternalSessions", args: args, diff --git a/apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift b/apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift index 1abe2b246..576775fc2 100644 --- a/apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift +++ b/apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift @@ -56,6 +56,8 @@ func workExternalSessionActions(for session: ExternalSessionSummary) -> [WorkExt var result: [WorkExternalSessionAction] = [] let caps = session.capabilities let cwdMatchesLane = session.cwdMatchesRequestedLane == true + let provider = workExternalSessionProviderName(session.provider) + let continueCliDetail = "Continue the same CLI session in this lane. This takes over the session — don't run it elsewhere at the same time." if caps.importToChat { if cwdMatchesLane || caps.resumeInDifferentCwd { @@ -88,7 +90,7 @@ func workExternalSessionActions(for session: ExternalSessionSummary) -> [WorkExt result.append(WorkExternalSessionAction( id: "resume-here", title: "Continue as CLI", - detail: "Continue the same CLI session in this lane.", + detail: continueCliDetail, systemImage: "terminal.fill", tint: ADEColor.textPrimary, target: "cli", @@ -110,7 +112,7 @@ func workExternalSessionActions(for session: ExternalSessionSummary) -> [WorkExt result.append(WorkExternalSessionAction( id: "resume-here", title: "Continue as CLI", - detail: "Continue the same CLI session in this lane.", + detail: continueCliDetail, systemImage: "terminal.fill", tint: ADEColor.textPrimary, target: "cli", @@ -140,12 +142,10 @@ func workExternalSessionActions(for session: ExternalSessionSummary) -> [WorkExt )) } if caps.resumeInPlace { - let folder = session.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let displayFolder = folder.isEmpty ? "its original folder" : folder result.append(WorkExternalSessionAction( id: "resume-in-place", title: "Continue in original folder", - detail: "Continue in \(displayFolder), not the selected lane.", + detail: "Continue in \(session.cwdDisplayName), not the selected lane.", systemImage: "terminal.fill", tint: ADEColor.textPrimary, target: "cli", @@ -157,8 +157,8 @@ func workExternalSessionActions(for session: ExternalSessionSummary) -> [WorkExt id: "resume-here", title: "Continue as CLI", detail: session.cwd == nil - ? "The original folder could not be recovered, so this session cannot be continued safely." - : "This provider cannot continue a session across folders.", + ? "The original folder could not be recovered, so \(provider) cannot safely continue this session." + : "This session lives in another folder, and \(provider) can't resume across folders.", systemImage: "terminal.fill", tint: ADEColor.textMuted, target: "cli", @@ -189,3 +189,19 @@ func workExternalSessionActions(for session: ExternalSessionSummary) -> [WorkExt } return result } + +/// One provider-label map for the import feature. This file and +/// `WorkImportSessionScreen` each had their own, five identical cases apart — +/// and iOS already carries two more elsewhere. Consumers use this one. +func workExternalSessionProviderName(_ provider: String) -> String { + switch provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "claude": return "Claude" + case "codex": return "Codex" + case "cursor": return "Cursor" + case "droid", "factory": return "Droid" + case "opencode": return "OpenCode" + default: + let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "Unknown" : trimmed + } +} diff --git a/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift b/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift index 4e6c22e65..4df1101cb 100644 --- a/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift @@ -446,13 +446,6 @@ private struct WorkImportSessionSummaryRow: View { .foregroundStyle(ADEColor.textPrimary) .lineLimit(2) - if let preview = session.previewSnippet { - Text(preview) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(2) - } - HStack(spacing: 5) { Text(providerDisplayName(session.provider)) if let count = session.messageCount { @@ -465,6 +458,22 @@ private struct WorkImportSessionSummaryRow: View { } .font(.caption2) .foregroundStyle(ADEColor.textMuted) + + if let started = session.startedAnchorSnippet, + let latest = session.latestAnchorMessage { + WorkImportSessionAnchorBlock(started: started, latest: latest.text) + } else if let started = session.startedAnchorSnippet { + WorkImportSessionAnchorBlock(started: started, latest: nil) + } else if let latest = session.latestAnchorMessage { + WorkImportSessionAnchorBlock(started: nil, latest: latest.text) + } else if !session.hasConversationAnchorData, + let preview = session.previewSnippet, + !session.previewDuplicatesHeading { + Text(preview) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + } } Spacer(minLength: 4) @@ -482,6 +491,41 @@ private struct WorkImportSessionSummaryRow: View { } } +private struct WorkImportSessionAnchorBlock: View { + let started: String? + let latest: String? + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + if let started { + anchor(label: "started", text: started, lineLimit: 1) + } + if let latest { + anchor(label: "latest", text: latest, lineLimit: 2) + } + } + .padding(.leading, 9) + .overlay(alignment: .leading) { + Rectangle() + .fill(ADEColor.glassBorder) + .frame(width: 1) + } + .padding(.top, 2) + } + + private func anchor(label: String, text: String, lineLimit: Int) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(label) + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + Text(text) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(lineLimit) + } + } +} + private struct WorkImportSessionRow: View { let session: ExternalSessionSummary let actions: [WorkExternalSessionAction] @@ -602,26 +646,52 @@ private struct WorkImportSessionRow: View { @ViewBuilder private var previewDisclosure: some View { - if let preview = session.previewSnippet { - VStack(alignment: .leading, spacing: 6) { - Button { - withAnimation(.easeInOut(duration: 0.18)) { - previewExpanded.toggle() - } - } label: { - HStack(spacing: 4) { - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .bold)) - .rotationEffect(.degrees(previewExpanded ? 90 : 0)) - Text("Preview") - .font(.caption.weight(.semibold)) - } - .foregroundStyle(ADEColor.textMuted) - .contentShape(Rectangle()) + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + previewExpanded.toggle() + } + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .bold)) + .rotationEffect(.degrees(previewExpanded ? 90 : 0)) + Text(session.conversationMessages.isEmpty + ? "Preview" + : "Last \(session.conversationMessages.count) \(session.conversationMessages.count == 1 ? "message" : "messages")") + .font(.caption.weight(.semibold)) } - .buttonStyle(.plain) + .foregroundStyle(ADEColor.textMuted) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) - if previewExpanded { + if previewExpanded { + if !session.conversationMessages.isEmpty { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(session.conversationMessages.enumerated()), id: \.offset) { index, message in + WorkImportConversationMessageRow(message: message) + if index < session.conversationMessages.count - 1 { + Divider() + .overlay(ADEColor.glassBorder.opacity(0.45)) + } + } + } + } + .frame( + height: min( + 260, + max(92, CGFloat(session.conversationMessages.count) * 72) + ) + ) + .background(ADEColor.textPrimary.opacity(0.025), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.glassBorder.opacity(0.55), lineWidth: 0.6) + } + } else if let preview = session.previewSnippet, + !session.previewDuplicatesHeading { Text(preview) .font(.caption) .foregroundStyle(ADEColor.textSecondary) @@ -634,6 +704,11 @@ private struct WorkImportSessionRow: View { RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(ADEColor.glassBorder.opacity(0.55), lineWidth: 0.6) } + } else { + Text("No conversational preview was recoverable for this session.") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) } } } @@ -669,6 +744,33 @@ private struct WorkImportSessionRow: View { } } +private struct WorkImportConversationMessageRow: View { + let message: ExternalSessionMessage + + private var isUser: Bool { + message.role == "user" + } + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Text(isUser ? "YOU" : "ADE") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(isUser ? ADEColor.purpleAccent : ADEColor.success) + .frame(width: 30, alignment: .leading) + .padding(.top, 2) + + Text(message.text) + .font(.caption) + .foregroundStyle(isUser ? ADEColor.textPrimary : ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .background(isUser ? ADEColor.purpleAccent.opacity(0.025) : Color.clear) + } +} + private struct WorkImportBadge: View { let text: String let tint: Color @@ -758,13 +860,14 @@ private struct WorkImportActionButton: View { } } -private extension ExternalSessionSummary { +extension ExternalSessionSummary { var importIdentity: String { "\(provider):\(id)" } var rowHeading: String { if let realTitle { return realTitle } + if let openingPromptHeading { return openingPromptHeading } let whereText = cwdLastPathSegment ?? cwdDisplayName guard !relativeUpdatedAt.isEmpty else { return whereText } return "\(whereText) · \(relativeUpdatedAt)" @@ -779,11 +882,54 @@ private extension ExternalSessionSummary { return trimmedTitle.isEmpty ? nil : trimmedTitle } + /// The opening ask, used as a heading when the provider persisted no title. + var openingPromptHeading: String? { + workImportHeadingText(previewSnippet) + } + + var startedAnchorSnippet: String? { + guard let openingPromptHeading, + workImportHeadingText(openingPromptHeading) != workImportHeadingText(rowHeading) else { + return nil + } + return openingPromptHeading + } + + var latestAnchorMessage: ExternalSessionMessage? { + guard let latest = conversationMessages.last else { return nil } + // Normalize both sides before comparing, and check the started anchor too: + // a titled single-message thread clears the heading check yet still repeats + // the opening prompt, which reads as a rendering bug. + let normalizedLatest = workImportHeadingText(latest.text) + guard normalizedLatest != workImportHeadingText(rowHeading) else { return nil } + if let started = startedAnchorSnippet, normalizedLatest == workImportHeadingText(started) { + return nil + } + return latest + } + + var hasConversationAnchorData: Bool { + openingPromptHeading != nil || !conversationMessages.isEmpty + } + + var conversationMessages: [ExternalSessionMessage] { + (messages ?? []).compactMap { message in + let text = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + return ExternalSessionMessage(role: message.role, text: text, at: message.at) + } + } + var previewSnippet: String? { let trimmedPreview = preview?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmedPreview.isEmpty ? nil : trimmedPreview } + var previewDuplicatesHeading: Bool { + guard let previewSnippet else { return false } + return workImportHeadingText(previewSnippet) == workImportHeadingText(rowHeading) + } + var trimmedCwd: String? { let trimmed = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return trimmed.isEmpty ? nil : trimmed @@ -806,7 +952,7 @@ private extension ExternalSessionSummary { } let segments = display.split(separator: "/").map(String.init) guard segments.count > 3 else { return display } - return ".../" + segments.suffix(3).joined(separator: "/") + return "…/" + segments.suffix(3).joined(separator: "/") } var relativeUpdatedAt: String { @@ -816,6 +962,15 @@ private extension ExternalSessionSummary { } } +private func workImportHeadingText(_ value: String?) -> String? { + let collapsed = value? + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !collapsed.isEmpty else { return nil } + guard collapsed.count > 72 else { return collapsed } + return String(collapsed.prefix(71)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" +} + private enum WorkImportSessionFormatters { static let relative: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() @@ -825,16 +980,7 @@ private enum WorkImportSessionFormatters { } private func providerDisplayName(_ provider: String) -> String { - switch provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "claude": return "Claude" - case "codex": return "Codex" - case "cursor": return "Cursor" - case "droid": return "Droid" - case "opencode": return "OpenCode" - default: - let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? "Unknown" : trimmed - } + workExternalSessionProviderName(provider) } private func workImportToolType(provider: String) -> String { diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index c29f148cb..82aa5522d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -199,6 +199,7 @@ final class ADETests: XCTestCase { XCTAssertFalse(summary.alreadyImported) XCTAssertFalse(summary.possiblyActive) XCTAssertEqual(summary.capabilities, ExternalSessionCapabilities()) + XCTAssertNil(summary.messages) let resultJson = #"{"kind":"cli","sessionId":"ade-session-1","ptyId":"pty-1","laneId":"lane-1"}"# let result = try JSONDecoder().decode(ExternalSessionImportResult.self, from: Data(resultJson.utf8)) @@ -209,6 +210,55 @@ final class ADETests: XCTestCase { XCTAssertNil(result.chatSummary) } + func testExternalSessionSummaryDecodesFirstPromptAndMessages() throws { + let json = """ + { + "provider": "codex", + "id": "external-2", + "messages": [ + {"role": "user", "text": "Bring the import screen to parity", "at": 1785142800000}, + {"role": "assistant", "text": "I will inspect the DTO first.", "at": null} + ] + } + """ + + let summary = try JSONDecoder().decode(ExternalSessionSummary.self, from: Data(json.utf8)) + + XCTAssertEqual(summary.messages, [ + ExternalSessionMessage( + role: "user", + text: "Bring the import screen to parity", + at: 1_785_142_800_000 + ), + ExternalSessionMessage( + role: "assistant", + text: "I will inspect the DTO first.", + at: nil + ), + ]) + } + + func testExternalSessionSummaryDropsMalformedMessageWithoutDroppingSummary() throws { + let json = """ + { + "provider": "claude", + "id": "external-lossy", + "title": "Still decodes", + "messages": [ + {"role": "user", "text": "Keep me", "at": 1785142800000}, + {"role": "assistant", "text": 42, "at": 1785142860000}, + {"role": "assistant", "text": "Keep me too", "at": 1785142920000} + ] + } + """ + + let summary = try JSONDecoder().decode(ExternalSessionSummary.self, from: Data(json.utf8)) + + XCTAssertEqual(summary.id, "external-lossy") + XCTAssertEqual(summary.title, "Still decodes") + XCTAssertEqual(summary.messages?.map(\.text), ["Keep me", "Keep me too"]) + } + func testExternalSessionActionsHonorCrossFolderCapabilities() { let summary = ExternalSessionSummary( provider: "claude", @@ -254,6 +304,43 @@ final class ADETests: XCTestCase { XCTAssertFalse(actions.contains(where: { $0.mode == "resume" })) } + func testExternalSessionActionsKeepProviderAndTakeoverSafetyContext() { + let sameFolder = ExternalSessionSummary( + provider: "codex", + id: "external-same-folder", + cwdMatchesRequestedLane: true, + capabilities: ExternalSessionCapabilities(resumeInPlace: true) + ) + let continueAction = workExternalSessionActions(for: sameFolder) + .first(where: { $0.id == "resume-here" }) + XCTAssertTrue(continueAction?.detail.contains("takes over the session") == true) + XCTAssertTrue(continueAction?.detail.contains("don't run it elsewhere") == true) + + let crossFolder = ExternalSessionSummary( + provider: "claude", + id: "external-cross-folder", + cwd: "/Users/dev/Projects/client/feature/repository", + cwdMatchesRequestedLane: false, + capabilities: ExternalSessionCapabilities(resumeInPlace: true) + ) + let crossFolderActions = workExternalSessionActions(for: crossFolder) + XCTAssertTrue( + crossFolderActions.first(where: { $0.id == "resume-in-place" })?.detail + .contains("…/client/feature/repository") == true + ) + + let disabled = ExternalSessionSummary( + provider: "claude", + id: "external-disabled", + cwd: "/tmp/elsewhere", + cwdMatchesRequestedLane: false + ) + XCTAssertTrue( + workExternalSessionActions(for: disabled).first?.detail + .contains("Claude can't resume across folders") == true + ) + } + func testSyncPreprocessRejectsCompressedPayloadAboveLimit() throws { let encodedPayload = "H4sIAAAAAAAAE6tWKkhMScnMS1eyUkqkECjVAgB1YfDxTgAAAA==" let envelope = """ diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 843396ac8..9d4ebcf31 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -19,8 +19,8 @@ for its separate RPC, sync, storage, and UI contracts. | Path | Role | |---|---| -| `apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx` | **Send to machine** workflow in the Handoff tab: source Git readiness, eligible connected-machine selection, brief or full-history fork selection, optional continuation note, destination project matching or confirmed clone, storage/auth/model/commit/lane checks, transport disclosure, route-pinned final send, and recoverable source-marker completion. Cross-machine fork transports provider-native history for Claude, Codex, and OpenCode; Cursor and Droid use brief mode because their histories are not portable between machines yet. A fork that can't be completed always degrades to a one-click brief rather than a dead end: an older destination that omits `forkHandoffSupport`, a history over the transport cap, or an unforkable provider file (e.g. a Codex `.zst` rollout) each surface a plain-language reason and a **send as brief** action that re-runs prepare + preflight in brief mode. The insecure-route consent line is fork-aware — a fork discloses that the full chat history is sent exactly as recorded, while a brief states only the summary is sent, never secrets. See [Cross-machine session handoff](../sync-and-multi-device/cross-machine-session-handoff.md). | -| `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`) + `providerSupportsHandoffFork()`, `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` only when present. | +| `apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | **Send to machine** workflow in the Handoff tab: source Git readiness, eligible connected-machine selection, brief or full-history fork selection, the destination chat's model / reasoning effort / fast mode / permission mode (the shared `PermissionModePicker` and `ReasoningEffortPicker`, each self-hiding when the chosen model can't honor it), optional continuation note, destination project matching or confirmed clone, storage/auth/model/commit/lane checks, a **Fetch & fast-forward there** offer when the destination lane is clean and a strict ancestor of the source commit, transport disclosure, route-pinned final send, and recoverable source-marker completion. Source blockers are `BlockedActionReason` values rendered next to a `BlockedActionButton`, so no blocker can hide behind a disabled control. `crossMachineHandoffPresentation.tsx` holds the pure half — stage/mode types, `SourceCheck`, branch/route/readiness copy, permission tone and icon maps, and `CheckRow` — so the copy and lookups that shipped wrong are directly testable. Cross-machine fork transports provider-native history for Claude, Codex, and OpenCode; Cursor and Droid use brief mode because their histories are not portable between machines yet. A fork that can't be completed always degrades to a one-click brief rather than a dead end: an older destination that omits `forkHandoffSupport`, a history over the transport cap, or an unforkable provider file (e.g. a Codex `.zst` rollout) each surface a plain-language reason and a **send as brief** action that re-runs prepare + preflight in brief mode. The insecure-route consent line is fork-aware — a fork discloses that the full chat history is sent exactly as recorded, while a brief states only the summary is sent, never secrets. See [Cross-machine session handoff](../sync-and-multi-device/cross-machine-session-handoff.md). | +| `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`) + `providerSupportsHandoffFork()`, `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). Cross-machine fork has its own narrower list: `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS` + `providerSupportsCrossMachineHandoffFork()`, derived from `HANDOFF_FORK_PROVIDERS` by filtering Droid out (its session index is machine-local) so the two lists cannot drift. The preflight also carries an optional `laneFastForward` (`laneId`, `laneName`, `behindBy`) — the destination's own assertion that its existing lane is clean and a strict ancestor of the source commit. `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` and `laneFastForward` only when present, and rejects a `behindBy` that is not a positive integer because the destination refuses a zero-distance fast-forward. | | `apps/desktop/src/main/services/chat/crossMachineForkTransport.ts` | Node-only fork-transport plumbing shared by the source packaging and destination materialization paths. Owns the uncompressed limits (18 MiB provider main session file, 4 MiB total Claude sidecars, 3 MiB ADE transcript envelopes), the independent base64 bounds that reject oversized input before decoding, and `CROSS_MACHINE_FORK_ENCODED_BUDGET_BYTES` (20 MiB) — a whole-capsule encoded budget kept under the 25 MiB sync-envelope/WebSocket payload caps. `gzipToBase64` / `gunzipFromBase64` (the latter enforces a max output length) do the compression; `enforceCrossMachineForkEncodedBudget` drops the sidecar group first and only throws a "too large, send a brief" error when the main file plus transcript alone blow the budget; `crossMachineForkOversizeError` returns the typed `CROSS_MACHINE_FORK_OVERSIZE` failure; `runCliCapture` buffers `opencode export` / `import` stdout/stderr with a timeout; and `validateForkTransport` re-validates a received capsule's transport (provider match, kind allowlist, base64 shape, path-traversal-safe side-file paths, per-file and total size caps) before any decode. | | `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. `buildAgentRuntimeEnv(managed)` stamps every SDK-backed provider process with `ADE_CHAT_SESSION_ID`, `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for a lead), `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT`; the persistent guidance also names the concrete `--session ` argument for status commands so shared SDK servers do not depend on process-global env inheritance. `dismissPendingInputForSettlement` is the provider-neutral quieting boundary used by **Dismiss & settle**: it interrupts live Claude/Codex/OpenCode/Cursor/Droid turns best-effort, cancels local/provider waiters, removes Codex plan follow-ups, emits pending-input resolution, and persists an idle session before settle is written. When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so only interrupt, reset/dispose, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind` (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)); spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | | `apps/desktop/src/main/services/chat/providerResumeClassifier.ts` | Classifies Codex resume failures without conflating missing threads with MCP/provider-environment or transient transport failures; rollout-file evidence keeps a locally known thread from being declared missing. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index bd122a20b..f6df0fff1 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -16,9 +16,9 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout` (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Pure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. `AgentChatPane` writes these jobs into the root store while `TerminalsPage` passes matching jobs into the Work session sidebar. The local handoff surface offers a brief summarized handoff or a full-history fork whenever the source provider is fork-capable (`providerSupportsHandoffFork`: Claude, Codex, OpenCode, Droid); Cursor is brief-only. Fork keeps the new chat on the same provider and lane while allowing the target model to change; Claude forks the SDK session pointer, Codex the app-server thread (`thread/fork`), OpenCode `session.fork`, and Droid `forkSession()`. | | `apps/desktop/src/renderer/lib/aiDiscoveryCache.ts` | Project-scoped AI integration-status and provider-model cache shared across renderer surfaces. `getAiStatusCached` uses a 10-second freshness window and deduplicates concurrent `ade.ai.getStatus` requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops. | -| `CrossMachineHandoffModal.tsx` | Modal state and user flow for **Send to machine**. It verifies a local source lane, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), handles existing-project versus confirmed-clone setup, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no `forkHandoffSupport`, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click **send as brief** that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent). | +| `CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | Modal state and user flow for **Send to machine**. It verifies a local source lane, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), lets the user set the destination chat's model, reasoning effort, fast mode, and permission mode with the same shared pills the composer uses, handles existing-project versus confirmed-clone setup, offers a destination-run fast-forward when the target lane is clean and strictly behind the source commit, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Source blockers render through `BlockedReasons` / `BlockedActionButton` instead of silently disabling Continue. The pure half — stage/mode types, `SourceCheck`, branch/route/repo-readiness copy, permission tone and icon maps, send-step labels, `CheckRow` — lives in `crossMachineHandoffPresentation.tsx` so it is assertable without mounting the stateful modal. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no `forkHandoffSupport`, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click **send as brief** that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent). | | `AgentChatMessageList.tsx` | Virtualized message list. The virtualizer is **hand-rolled**, not `@tanstack/react-virtual`: a `measuredHeights` row-key → height `Map` feeds top/bottom spacer divs around the rendered window, and each rendered row is wrapped in `MeasuredEventRow`, whose `ResizeObserver` reports its real height through `handleMeasure` → `reconcileMeasuredScrollTop` so a height correction above the viewport does not shift what the reader is looking at. Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundFinishChip` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`; terminal provider capacity/usage-limit errors render `ProviderFailureRecoveryCard` with same-thread retry and model-selection actions. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details, and a handoff-brief user row shows a small brief chip. When a fork seeds pre-fork history into the new chat, the envelopes carry the `handoff_fork` provider origin and the list draws a single `Forked from the previous chat — full history above` divider (`computeForkHistoryDividerRowKey` pins it to the first live row after the seeded history) instead of one marker per seeded row. | -| `AgentChatComposer.tsx`, `ComposerSmartLinkMenu.tsx`, `smartLinkChipMark.ts` | Text input, attachments, model selector, compact title-only permission controls, slash commands, smart-link chips (`smartLinkChipMark.ts` returns the inline `currentColor` SVG brand mark each chip renders), pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. Completed URLs are non-editable inline chips whose `data-composer-chip-text` preserves the literal URL during serialization; clicking or keyboard-activating a chip opens the Copy link / Remove link menu, and character deletion removes the whole URL token. Every chip also carries a kind-naming `data-composer-chip` attribute, and a scoped `selectionchange` effect marks intersecting chips with `data-composer-chip-selected` so the native selection paints continuously across them (overlay styling lives in `apps/desktop/src/renderer/index.css`). During an active Claude turn, its split Send control selects without dispatching among inline, after-turn, and interrupt delivery; the primary button and Enter execute the selected mode. Staged rows expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | +| `AgentChatComposer.tsx`, `ComposerSmartLinkMenu.tsx`, `smartLinkChipMark.ts` | Text input, attachments, model selector, compact title-only permission controls (per-provider `PermissionModePickerOption` tables fed into the shared `components/shared/PermissionModePicker`, which the composer owns the option data for but not the control), slash commands, smart-link chips (`smartLinkChipMark.ts` returns the inline `currentColor` SVG brand mark each chip renders), pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. Completed URLs are non-editable inline chips whose `data-composer-chip-text` preserves the literal URL during serialization; clicking or keyboard-activating a chip opens the Copy link / Remove link menu, and character deletion removes the whole URL token. Every chip also carries a kind-naming `data-composer-chip` attribute, and a scoped `selectionchange` effect marks intersecting chips with `data-composer-chip-selected` so the native selection paints continuously across them (overlay styling lives in `apps/desktop/src/renderer/index.css`). During an active Claude turn, its split Send control selects without dispatching among inline, after-turn, and interrupt delivery; the primary button and Enter execute the selected mode. Staged rows expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | | `ProviderFailureRecoveryCard.tsx` | Friendly recovery surface for terminal provider capacity and usage-limit failures. Shows human-readable error identity and guidance, then offers **Retry turn** and **Choose model** only after the failed turn has released the composer. | | `chatTurnState.ts` | Pure turn-state helpers shared by live and hydration paths. Terminal transcript evidence beats a stale active session summary, and failed-turn retry resolves the associated non-steer user message even when the optimistic row has no provider turn id. | | `ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Chat Actions tab shell plus Codex Sources view. The source derivation deduplicates files, web queries/results, MCP apps/tools, and external resource URLs from transcript events; safe web rows open in ADE's browser. | @@ -65,6 +65,8 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Prompt formatting for visual/tool context from attachments, iOS Simulator, App Control, and built-in browser selections. | | `apps/desktop/src/shared/types/chat.ts` | Shared composer/session DTOs, including `PARALLEL_CHAT_MAX_ATTACHMENTS`, parallel launch state types, the `AgentChatModelCatalog*` set, `AgentChatModelCatalogRefreshProvider` (`opencode` / `cursor` / `droid` / `lmstudio` / `ollama`), and `AgentChatModelCatalogArgs` (`mode`, `refreshProvider`). | | `apps/desktop/src/renderer/components/shared/ModelPicker/` | Modular ModelPicker (see [ModelPicker structure](#modelpicker-structure)): `ModelPicker.tsx`, `ModelPickerContent.tsx`, `ModelPickerRail.tsx`, `ModelListRow.tsx`, `ReasoningEffortPicker.tsx` (draggable/snapping gradient slider that stays open on selection), `modelCatalog.ts`, `modelOrdering.ts`, `modelPickerSearch.ts`, `providerEmptyState.tsx`, `runtimeCatalogCache.ts`, plus the `useProviderAuthStatus` / `useAuthOnlyFilter` / `useModelFavorites` / `useModelRecents` / `usePerSurfaceModelDefaults` / `useReasoningByFamily` hooks. | +| `apps/desktop/src/renderer/components/shared/PermissionModePicker.tsx` | The permission-mode pill itself, shared by every surface that lets a user choose how a chat starts: the composer's per-provider controls, `SessionLaunchModelControls`, and the cross-machine handoff modal. Exports the generic `PermissionModePicker`, `PermissionModeGlyph`, the tone/icon enums the provider option tables map into, and `PERMISSION_TRIGGER_CLASS` — the one definition of the trigger chrome, previously hand-copied per surface. That class scales with `calc(var(--chat-font-size,14px)*9/14)`; the fallback is load-bearing, because `--chat-font-size` only exists on a chat appearance root and a bare token would leave the launch and handoff pills inheriting the ambient size. Anything offering permission modes renders this, not a lookalike. | +| `apps/desktop/src/renderer/components/shared/BlockedAction.tsx` | The blocked-action primitive: `BlockedActionReason` (id, title, detail, and the optional fix that clears it), `BlockedReasons` to render them inline, `describeBlockedReasons` for tooltip/a11y text, and `BlockedActionButton`, which takes the reasons themselves rather than a `disabled` boolean so a caller cannot disable a control without handing over the explanation. Exists because ADE keeps regrowing the same bug — a surface computes blockers, disables the primary button, and renders none of them. | ## Pane layout diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 2d48781f2..cee8cc787 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -459,7 +459,21 @@ Cross-machine Work chat handoff: - `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` and `CrossMachineHandoffModal.tsx` — Handoff-tab entry point and the staged - source/destination/clone/review/completion UI. + source/destination/clone/review/completion UI, including the destination + chat's model / reasoning / fast-mode / permission controls and the + fast-forward offer for a clean-but-behind destination lane. +- `apps/desktop/src/renderer/components/chat/crossMachineHandoffPresentation.tsx` + — the modal's pure presentation half: stage/mode types, the `SourceCheck` + shape, the branch-row and route/repo-readiness copy, permission tone and icon + lookups, send-step labels, and the `CheckRow` component. Split out because + these are exactly the pieces that shipped wrong (a tone map that rendered + every permission pill grey, a branch row that called a two-commits-behind + branch "pushed") and were unreachable from a test inside the stateful modal. +- `apps/desktop/src/renderer/components/shared/BlockedAction.tsx` and + `apps/desktop/src/renderer/components/shared/PermissionModePicker.tsx` — + cross-surface primitives the modal reuses rather than reimplementing: the + reason-carrying blocked-action button/list, and the composer's permission + pill. - `apps/desktop/src/main/services/chat/agentChatService.ts` — authoritative source readiness, capsule creation and validation, destination preflight, deterministic lane/chat acceptance, durable replay record, and source notice. @@ -698,8 +712,12 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `work.importExternalSession`, `chat.recoverCodexTurn`, `modelPicker.*`, …). The cross-machine handoff family (`chat.prepareCrossMachineHandoff`, `chat.validateCrossMachineSource`, `chat.preflightCrossMachineDestination`, + `chat.fastForwardCrossMachineHandoffLane`, `chat.acceptCrossMachineHandoff`, and `chat.markCrossMachineHandoff`) keeps - source and destination work inside their owning project runtimes. Final + source and destination work inside their owning project runtimes. The + fast-forward command lets a destination catch an existing clean lane up to the + source commit with a `--ff-only` merge it re-validates itself, so a shared + branch such as `main` is not an automatic dead end. Final acceptance is not queueable; destination idempotency is keyed by the capsule's handoff id and fingerprint instead of relying on command replay. Desktop **Send to machine** reaches the destination through multi-project diff --git a/docs/features/sync-and-multi-device/cross-machine-session-handoff.md b/docs/features/sync-and-multi-device/cross-machine-session-handoff.md index 78dfebdb0..3d84592cc 100644 --- a/docs/features/sync-and-multi-device/cross-machine-session-handoff.md +++ b/docs/features/sync-and-multi-device/cross-machine-session-handoff.md @@ -9,14 +9,17 @@ This document defines the v1 product, transport, recovery, and security contract The action lives in the chat actions drawer under **Handoff** as **Send to machine**. 1. ADE checks the source chat and Git lane. -2. The user selects an eligible connected machine and may add a continuation note. -3. ADE finds the same repository in the destination project registry. -4. If the repository is missing, ADE shows the destination path, free-space result, route warning, and an explicit clone confirmation. -5. ADE checks destination provider/model access, the published branch commit, and any existing destination lane. -6. The user reviews the bounded-context and transport disclosures, then confirms. -7. ADE rechecks the source chat, clean worktree, upstream, remote branch, and exact commit, then pins the transfer to the route shown in the review. -8. The destination recreates or reuses the lane, starts the chat, and either dispatches the first continuation turn or completes a fork whose default continuation needs no new turn. -9. Only after the destination runtime acknowledges a requested turn, or the no-turn fork reaches its durable dispatched checkpoint, does ADE mark the source chat as handed off. +2. The user selects an eligible connected machine and may add a continuation note. The picker reports each machine's repository presence while it is open, resolved from the same `listProjects` call the prepare step consumes, so the hint costs no extra round trip. +3. In the same setup step the user sets the destination chat's model, reasoning effort, fast mode, and permission mode. These are the composer's own control pills — the shared `PermissionModePicker` and `ReasoningEffortPicker`, not lookalikes — and each hides itself when the chosen model cannot honor it. The capsule carries these fields and the destination applies them, so this picker is the only place they are decided; nothing is inherited from the local handoff drawer. In fork mode the model picker stays constrained to the source provider. +4. ADE finds the same repository in the destination project registry. +5. If the repository is missing, ADE shows the destination path, free-space result, route warning, and an explicit clone confirmation. +6. ADE checks destination provider/model access, the published branch commit, and any existing destination lane. +7. The user reviews the bounded-context and transport disclosures, then confirms. +8. ADE rechecks the source chat, clean worktree, upstream, remote branch, and exact commit, then pins the transfer to the route shown in the review. +9. The destination recreates or reuses the lane, starts the chat, and either dispatches the first continuation turn or completes a fork whose default continuation needs no new turn. +10. Only after the destination runtime acknowledges a requested turn, or the no-turn fork reaches its durable dispatched checkpoint, does ADE mark the source chat as handed off. + +While the transfer is in flight the modal reports the real durable checkpoints it has passed rather than an indeterminate spinner. The setup modal can be opened while a turn is active. It explains the block and offers to stop the current response. Pending approvals or questions must be resolved in the source chat. @@ -37,7 +40,9 @@ V1 intentionally requires a clean, published Git branch: - local `HEAD`, the upstream ref, and `git ls-remote origin refs/heads/` must resolve to the same commit; and - an origin remote must exist. -The renderer provides a **Publish branch** action when a normal push is sufficient. Behind or diverged branches block the handoff instead of choosing a reconciliation strategy for the user. +The renderer provides a **Publish branch** action when a normal push is sufficient, and an **Update branch** action when the branch is strictly behind. Diverged branches still block: ADE does not pick a reconciliation strategy for the user. + +Every one of these source blockers must render. They are modeled as `BlockedActionReason` values from `apps/desktop/src/renderer/components/shared/BlockedAction.tsx` — a title, a detail, and the action that clears it — rendered by `BlockedReasons`, and the primary button is a `BlockedActionButton` that takes those reasons instead of a `disabled` flag, so a surface cannot disable the action without also surfacing why. This is a deliberate guard against a bug ADE keeps regrowing: a blocking-error list that only ever feeds a `disabled` prop turns "update the source branch" into three green checks above a dead control. If a surface computes a blocker, that blocker must reach the user; a disabled control may never be the only signal. Dirty patches, stashes, Git bundles, untracked files, and worktree metadata are not transferred. Git bundles alone do not encode the working tree, index, stash, hooks, configuration, or ADE lane metadata, so treating them as a complete handoff would be misleading. A future dirty-state protocol must define those semantics separately. @@ -83,6 +88,8 @@ If the 18 MiB main-history limit is exceeded, ADE returns a typed “too large Cross-machine fork requires the same provider and a usable destination runtime or CLI. Claude, Codex, and OpenCode support it. Cursor is brief-only because it has no native fork surface. Droid is also brief-only across machines: local Droid fork remains supported, but its machine-local session index and relocated-file resume behavior are not yet proven portable. +That distinction is encoded as `providerSupportsCrossMachineHandoffFork`, separate from the local-fork `providerSupportsHandoffFork`. Its backing list, `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS`, is derived from the local `HANDOFF_FORK_PROVIDERS` by filtering Droid out rather than being restated, so adding a provider to one list cannot silently leave the other behind. The UI must gate its fork affordance on the cross-machine helper; gating on the local one leaves Droid's fork option selectable and guaranteed to throw at confirm time. The destination applies the same helper when it computes `forkHandoffSupport`, so a refused provider is named with a plain reason instead of failing late. + ## Destination contract Eligible machines must be connected, support multi-project RPC, and advertise the handoff storage-preflight capability. Fork destinations additionally advertise provider-specific fork support; an older destination that omits that field is never allowed to silently downgrade a fork into a brief. Older runtimes remain connected for compatible features but are excluded from the picker with an update message. @@ -104,9 +111,27 @@ Once the project exists, the destination: - verifies the remote branch still points at the source commit; - fetches that exact branch; - reuses an existing lane only when it is clean, not rebasing, and at the exact commit; +- reports `laneFastForward` when that lane is instead clean and a *strict ancestor* of the source commit, so the source can offer **Fetch & fast-forward there** rather than dead-ending. The destination re-validates independently and only ever runs `git merge --ff-only`; it never resets and never touches a dirty or diverged lane. This is what makes handing off a shared branch such as `main` workable, since two machines' `main` are rarely at the same commit; - blocks when a different local branch with the same name exists; or - imports a new lane from the fetched remote branch. +The fast-forward offer is a separate destination call — the `chat` action-domain +action `fastForwardCrossMachineHandoffLane` (`{ laneId, expectedHead }`), also +registered as the sync remote command `chat.fastForwardCrossMachineHandoffLane` +— run before the handoff itself, with the source's preflight refreshed +afterwards. `laneFastForward` is reported as a warning so the offer can render, +but an unresolved one still gates Send: acceptance requires the destination lane +to be at the exact source commit, so sending first would fail hard after +destination work had already begun. + +Nothing in the offer is trusted at execution time: the destination +re-fetches the branch, re-checks that `origin/` still points at the +expected commit, re-reads lane status for rebase and uncommitted changes, +re-verifies strict ancestry, and refuses a lane already at the expected commit. +The source-side decoder correspondingly rejects a `behindBy` that is not a +positive integer, so a zero-distance offer the destination would refuse can +never render. + ## Idempotency and recovery The destination owns the transaction record because it is the authority on what was created. Records are keyed by `handoffId` and bind that identifier to the capsule SHA-256 fingerprint. @@ -172,6 +197,9 @@ Tests should cover: - exact branch/commit mismatch; - existing clean destination lane and conflicting local branch; - destination provider/model auth failure; +- a behind source branch rendering its reason and its pull action, and a diverged branch rendering a blocker with no one-click fix; +- Droid cross-machine fork refused up front while local Droid fork still works; +- destination lane clean-and-behind offering a fast-forward, and dirty/diverged/non-ancestor lanes refusing one; - incompatible destination runtime; - timeout or disconnect after destination acceptance starts, including an unsuccessful reconnect, with unknown-outcome copy and no automatic replay; diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 8c4181023..53b503d3a 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1342,6 +1342,24 @@ run on the paired host through `work.listExternalSessions` and results include the persisted chat or terminal summary, which Work caches before navigating so replication latency cannot produce a blank destination screen. +Rows are identified by two anchors rather than one snippet: **started**, the +thread's opening prompt (`preview`), and **latest**, the last of the bounded +`messages` sample the host attaches. Either may be missing — an older host +predates `messages`, and a thread whose only human text was a slash command has +no recoverable prompt — so the screen falls back to the single preview, and it +suppresses an anchor the row heading is already showing. `ExternalSessionSummary` +decodes `messages` through `ADELossyArray`, and `ExternalSessionMessage` rejects +an unknown `role`, so one malformed element costs that element rather than the +whole summary; the surrounding `try?` would otherwise turn a decode failure into +a silent empty "No sessions found". + +Chat imports also choose how the resulting ADE chat starts — model, reasoning +effort, fast mode where the model supports it, and permission mode — seeded from +and written back to `WorkComposerPreferences` so the phone's composer and its +imports stay consistent. Those arguments are sent only for `target: "chat"`; a +CLI import sends none of them so the resumed session keeps its provider state. +See [External session import](../terminals-and-sessions/external-session-import.md). + ### Settled lifecycle and attention parity iOS mirrors `apps/desktop/src/shared/sessionCanonicalState.ts` in diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 114fe4e4e..85cdb4b91 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -276,7 +276,8 @@ that cannot encode a JSON null (iOS) must still be able to express "clear". - `launch`, `getSlashCommands`, `resolveSmartLinkPreview`, `getContextUsage`, `warmupModel`, `getParallelLaunchState`, `setParallelLaunchState`, `handoff`, `prepareCrossMachineHandoff`, `validateCrossMachineSource`, - `preflightCrossMachineDestination`, `acceptCrossMachineHandoff`, + `preflightCrossMachineDestination`, + `fastForwardCrossMachineHandoffLane`, `acceptCrossMachineHandoff`, `markCrossMachineHandoff`, `rewindFiles`, `getTurnFileDiff`, `saveTempAttachment`, `getImageDataUrl` diff --git a/docs/features/terminals-and-sessions/external-session-import.md b/docs/features/terminals-and-sessions/external-session-import.md index b099e3048..e5f476713 100644 --- a/docs/features/terminals-and-sessions/external-session-import.md +++ b/docs/features/terminals-and-sessions/external-session-import.md @@ -31,7 +31,7 @@ continuation metadata is recorded as soon as ADE knows the provider target. | Path | Role | |---|---| | `apps/desktop/src/main/services/externalSessions/externalSessionsService.ts` | Service entry point. Runs provider discovery, applies the capabilities matrix, filters project/all scope, detects already-imported sessions, validates import ids, enforces optional lane cwd scope, builds CLI resume/fork commands, delegates chat import, and creates tracked PTYs. | -| `apps/desktop/src/main/services/externalSessions/discoveryUtils.ts` | Shared discovery helpers: safe stat/read, top-N mtime sorting, JSONL prefix/suffix scans, semantic user-prompt extraction/counting, provider-wrapper cleanup, title cleanup, cwd slug helpers, shell quoting, and path-inside checks. | +| `apps/desktop/src/main/services/externalSessions/discoveryUtils.ts` | Shared discovery helpers: safe stat/read, top-N mtime sorting, JSONL prefix/suffix scans, one record classifier shared by prompt extraction and the recent-`messages` sampler, provider-wrapper cleanup, the preview-only markup-density gate, word-boundary clipping, title cleanup, cwd slug helpers, shell quoting, and path-inside checks. | | `apps/desktop/src/main/services/externalSessions/discoverClaude.ts` | Discovers resumable Claude CLI JSONL transcripts under `CLAUDE_CONFIG_DIR` or `~/.claude/projects//.jsonl`; reads `ai-title`/custom titles and excludes SDK entrypoints. | | `apps/desktop/src/main/services/externalSessions/discoverCodex.ts` | Discovers interactive Codex rollout JSONL files under `CODEX_HOME/sessions/YYYY/MM/DD/` (default `~/.codex`), enriches them from `session_index.jsonl`, and maintains the rebuildable cwd/importability index used by project-scoped discovery. | | `apps/desktop/src/main/services/externalSessions/discoverCursor.ts` | Discovers current Cursor sessions from `~/.cursor/chats///store.db`, merges legacy transcript previews from `~/.cursor/projects/.../agent-transcripts`, uses `.workspace-trusted` for exact cwd recovery, and excludes SDK `agent-` sessions. | @@ -50,17 +50,17 @@ continuation metadata is recorded as soon as ADE knows the provider target. | `apps/ade-cli/src/adeRpcServer.ts` | Authorizes `run_ade_action` calls. Non-CTO callers are lane-scoped for `external-sessions`; CTO callers can use the domain unscoped. | | `apps/ade-cli/src/services/sync/syncRemoteCommandService.ts`, `apps/desktop/src/main/services/sync/syncRemoteCommandService.ts` | Registers `work.listExternalSessions` and `work.importExternalSession` for paired controllers. The desktop file is a re-export of the ade-cli implementation. | | `apps/desktop/src/shared/types/sync.ts` | Sync command DTO aliases for external-session list/import payloads and results. | -| `apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx` | Desktop two-stage browser/details flow: provider filters, search, project/all scope, progressive scans, full details, target lane selection, imported/active badges, Open-in-ADE, and safe action dispatch. | +| `apps/desktop/src/renderer/components/terminals/importSessions/ImportSessionBrowser.tsx` | Desktop two-stage browser/details flow: provider filters, search (which spans the sampled `messages` text, not just titles), project/all scope, progressive scans, full details with a bounded scrollable message sample, target lane selection, imported/active badges, Open-in-ADE, and safe action dispatch. The dialog height is content-driven rather than pinned, because the details stage is far shorter than the list stage. | | `apps/desktop/src/shared/externalSessionAffordances.ts`, `apps/desktop/src/renderer/components/terminals/importSessions/affordances.ts` | Canonical capability-to-action mapper for the 2x2 Continue/Copy x ADE-chat/CLI-session policy, plus the renderer compatibility export. Shared directly with the TUI. | -| `apps/desktop/src/renderer/components/terminals/importSessions/sessionPresentation.ts` | Pure desktop heading/time helpers. Provider titles win; untitled rows use cwd + relative time and never reuse the prompt preview as a heading. | +| `apps/desktop/src/renderer/components/terminals/importSessions/sessionPresentation.ts` | Pure desktop heading/time/anchor helpers. Provider titles win, then the opening prompt (`preview`), then cwd + relative time. `sessionAnchors` returns the row's "started"/"latest" pair and drops whichever one the heading is already showing, so a row never prints the same sentence twice. | | `apps/desktop/src/renderer/components/terminals/importSessions/contract.ts` | Renderer bridge/types/display helpers for external sessions. | | `apps/desktop/src/renderer/components/terminals/useWorkSessions.ts` | Adopts import results into the Work surface and focuses existing imported sessions without re-importing. | | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx`, `apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx`, `apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx` | Wires the import browser into the Work draft/new-session surface and routes imported or already-imported sessions to the selected Work tab. | | `apps/ade-cli/src/tuiClient/externalSessionBrowser.ts`, `apps/ade-cli/src/tuiClient/components/RightPane.tsx` | ADE Code TUI helpers and right-pane rendering for the same external-session DTOs and affordance mapper. | -| `apps/ios/ADE/Models/RemoteModels.swift` | iOS Codable mirrors for `ExternalSessionSummary`, capabilities, imported refs, and import results. | -| `apps/ios/ADE/Services/SyncService.swift` | iOS client methods for `work.listExternalSessions` and `work.importExternalSession`. | +| `apps/ios/ADE/Models/RemoteModels.swift` | iOS Codable mirrors for `ExternalSessionSummary`, `ExternalSessionMessage`, capabilities, imported refs, and import results. `messages` decodes through `ADELossyArray`, and `ExternalSessionMessage` rejects any role other than `user`/`assistant`, so a bad element is dropped instead of taking the whole summary down. A new key must be added in all three places inside the summary struct — `CodingKeys`, the memberwise `init`, and `init(from:)` — or it silently decodes as nil. | +| `apps/ios/ADE/Services/SyncService.swift` | iOS client methods for `work.listExternalSessions` (including the exact `sessionId` lookup) and `work.importExternalSession` (model, permission mode, reasoning effort, and fast mode). | | `apps/ios/ADE/Views/Work/WorkNewChatScreen.swift` | Adds the Import session affordance when a concrete lane is selected. | -| `apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift`, `apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift` | iOS two-stage browser/details flow plus pure action policy. Mirrors the capability model, target lane selection, project/all scope, provider chips/logos, Open-in-ADE, imported/active badges, and persisted-summary navigation after import. | +| `apps/ios/ADE/Views/Work/WorkImportSessionScreen.swift`, `apps/ios/ADE/Views/Work/WorkExternalSessionAffordances.swift` | iOS two-stage browser/details flow plus pure action policy. Mirrors the capability model, target lane selection, project/all scope, provider chips/logos, Open-in-ADE, imported/active badges, started/latest row anchors, the model + reasoning + fast-mode + permission controls shown before a chat import, and persisted-summary navigation after import. `workExternalSessionProviderName` is the one provider-label map both files use. | | `apps/ios/ADE/Views/Work/WorkRootComponents.swift`, `apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift`, `apps/ios/ADE/Views/Components/ADEDesignSystem.swift` | Shared iOS provider logos, fallback symbols, and provider accent colors consumed by the import screen. | ## Architecture @@ -95,14 +95,58 @@ Titles and previews are deliberately separate: - `title` is a real provider-persisted title, or `null`. Discovery must not use the first user message as a title. -- `preview` is a distinct snippet, usually the first user message after ADE - guidance and provider transport-wrapper stripping. Synthetic Claude +- `preview` is the thread's **opening prompt** — the first real human message, + after ADE guidance and provider transport-wrapper stripping. It is what the + row heading falls back to when the provider persisted no title, which is the + common case for Claude CLI transcripts. Synthetic Claude ``, ``, ``, and - Codex environment/AGENTS payloads must never become previews. - -The desktop and iOS rows use the real title when present. When title is null, -they fall back to a path/time heading so the heading never duplicates the -preview. Placeholder titles such as "New Session" are normalized to null. + Codex environment/AGENTS payloads must never become previews. There is no + separate first-prompt field: an alias of `preview` is one more thing to keep + in sync across the DTO, the sync command, the iOS mirror, and two renderers, + for no information the summary did not already carry. +- `messages` is a bounded sample of recent user/assistant exchanges, oldest to + newest, capped by `EXTERNAL_SESSION_MESSAGES_MAX_COUNT` (8) and clipped per + message by `EXTERNAL_SESSION_MESSAGE_MAX_LENGTH` (320). Claude derives it from + the tail of its existing prefix + suffix scan at no extra I/O cost; Codex + reads a bounded rollout suffix (64 KiB while browsing, 128 KiB for an exact + `sessionId` lookup) and skips `.jsonl.zst` rollouts entirely. Cursor, Droid, + and OpenCode leave it absent rather than paying for new I/O — OpenCode in + particular must not gain a per-session `opencode export` fan-out. + +`messages` is **optional and nullable**, and must stay that way. The iOS mirror +decodes every field with `decodeIfPresent`, and a decode failure there drops the +entire summary through a swallowing `try?`, so the import screen would show an +empty "No sessions found" state with no error. `messages` additionally decodes +through a lossy array wrapper, so one malformed element costs that element, not +the session. Add new optional keys; never re-type an existing one. + +Two rules keep previews honest: + +- **Wrapper rejection is not an allow-list.** The named noise-tag list still + exists, but it cannot be complete — a `` blob shipped to + users as a preview precisely because it was not on it. Preview selection + therefore also rejects text that is predominantly markup + (`EXTERNAL_SESSION_MARKUP_TEXT_MIN_RATIO`), and wrapper stripping handles tags + whose closing half fell outside the read window. + + That density gate is **preview-only**. It runs where a preview or a `messages` + sample is chosen, never inside `cleanExternalSessionUserText`, because that + cleaner also feeds `externalChatHistoryImport` and therefore the imported chat + transcript. Rejecting markup-heavy or very short turns there would silently + delete real user messages from someone's history — a pasted JSX snippet, or a + reply as ordinary as "ok". Message counting must not use it either. +- **`preview` may only come from *prefix* records.** Claude's scan array is + prefix ++ tail, so a loop that simply took the first record yielding text + would fall through into the tail and surface a background-task receipt as the + opening prompt. Recent `messages` are the only thing sourced from the tail. + +Clipping snaps to a word boundary and never ends inside a tag; a hard `slice` +produces visibly bisected markup such as `