From 074f3934e6df1ef2f3178d5264011b596c4c7ace Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:42:37 -0400 Subject: [PATCH] fix(work): unify multi-machine project UX --- apps/desktop/src/main/main.ts | 20 +- .../src/main/services/ipc/registerIpc.ts | 19 +- .../main/services/ipc/runtimeBridge.test.ts | 66 +++ .../src/main/services/ipc/runtimeBridge.ts | 25 +- .../recentProjectRuntimeAuthorization.test.ts | 55 +++ .../recentProjectRuntimeAuthorization.ts | 26 ++ .../projects/recentProjectSummary.test.ts | 5 +- .../services/projects/recentProjectSummary.ts | 3 + .../projects/startupProjectResolver.ts | 13 +- .../main/services/state/globalState.test.ts | 27 +- .../src/main/services/state/globalState.ts | 20 + apps/desktop/src/preload/global.d.ts | 22 +- apps/desktop/src/preload/preload.test.ts | 44 ++ apps/desktop/src/preload/preload.ts | 161 ++++---- apps/desktop/src/renderer/browserMock.ts | 3 +- .../src/renderer/components/app/TopBar.tsx | 99 ++++- .../components/app/projectTabGrouping.test.ts | 152 +++++++ .../components/app/projectTabGrouping.ts | 269 ++++++++++++- .../chat/AgentChatComposer.test.tsx | 7 +- .../components/chat/AgentChatComposer.tsx | 43 +- .../components/chat/AgentChatPane.test.tsx | 97 +++-- .../components/chat/AgentChatPane.tsx | 287 +++++-------- .../components/chat/useDraftMachineRouting.ts | 377 ++++++++++++++++++ .../components/lanes/LaneContextMenu.tsx | 12 +- .../renderer/components/lanes/LanesPage.tsx | 11 +- .../components/lanes/laneMachines.test.ts | 58 +++ .../renderer/components/lanes/laneMachines.ts | 38 +- .../components/lanes/useLaneWorkSessions.ts | 1 + .../projects/ProjectWelcomePage.test.tsx | 38 ++ .../projects/ProjectWelcomePage.tsx | 121 ++++-- .../terminals/ForeignLaneContextMenu.tsx | 171 ++++++++ .../components/terminals/LaneCombobox.tsx | 50 ++- .../components/terminals/SessionCard.tsx | 36 +- .../terminals/SessionContextMenu.test.tsx | 36 +- .../terminals/SessionContextMenu.tsx | 75 +++- .../terminals/SessionListPane.test.tsx | 47 ++- .../components/terminals/SessionListPane.tsx | 160 ++++---- .../terminals/SessionSnoozeControl.tsx | 10 +- .../terminals/TerminalsPage.test.tsx | 92 ++++- .../components/terminals/TerminalsPage.tsx | 149 +++++-- .../terminals/WorkStartSurface.test.tsx | 69 +++- .../components/terminals/WorkStartSurface.tsx | 92 ++++- .../components/terminals/WorkViewArea.tsx | 6 + .../laneComboboxMachineGroups.test.tsx | 66 ++- .../terminals/sessionLifecycleActions.ts | 48 ++- .../terminals/useWorkLaneContextMenu.test.tsx | 75 +++- .../terminals/useWorkLaneContextMenu.tsx | 126 +++++- .../terminals/useWorkSessions.test.ts | 12 + .../components/terminals/useWorkSessions.ts | 12 + .../src/renderer/hooks/useStartChatInLane.ts | 10 +- .../src/renderer/lib/workDraft.test.ts | 1 + apps/desktop/src/renderer/lib/workDraft.ts | 8 +- apps/desktop/src/renderer/state/appStore.ts | 4 + .../src/renderer/state/crossMachineLanes.ts | 6 +- apps/desktop/src/shared/types/core.ts | 6 + 55 files changed, 2870 insertions(+), 616 deletions(-) create mode 100644 apps/desktop/src/main/services/projects/recentProjectRuntimeAuthorization.test.ts create mode 100644 apps/desktop/src/main/services/projects/recentProjectRuntimeAuthorization.ts create mode 100644 apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts create mode 100644 apps/desktop/src/renderer/components/terminals/ForeignLaneContextMenu.tsx diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c8e0ebb7f..9434e7cd1 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -49,10 +49,10 @@ import { createRegisteredSyncPeerGate } from "./services/state/syncPeerCompactio import { ensureAdeDirs } from "./services/state/projectState"; import { persistableRemoteProjectIconDataUrl, + persistableRemoteProjectBinding, readGlobalState, type RecentProject, upsertRecentProject, - withPersistableRemoteProjectIcon, writeGlobalState, } from "./services/state/globalState"; import { createLaneService, type LaneDeleteTeardownDeps } from "./services/lanes/laneService"; @@ -113,13 +113,18 @@ import { toProjectInfo, upsertProjectRow, } from "./services/projects/projectService"; -import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary"; +import { + inspectRecentProject, + readGitOriginUrl, + type RecentProjectInspection, +} from "./services/projects/recentProjectSummary"; import { browseProjectDirectories } from "./services/projects/projectBrowserService"; import { resolveMobileProjectIconDataUrl } from "./services/projects/projectIconThumbnail"; import { normalizeStartupProjectState, resolveStartupProject } from "./services/projects/startupProjectResolver"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; +import { remoteProjectBindingKey } from "../shared/projectIdentity"; import { resolveAdeLayout } from "../shared/adeLayout"; import { mobileProjectRepositoryIdentityFromGitOrigin } from "../shared/syncMobileProjectIdentity"; import type { @@ -1178,13 +1183,16 @@ app.whenReady().then(async () => { } return { kind: "remote", - key: readString(record, "key") ?? `remote:${targetId}:${projectId}`, + key: readString(record, "key") ?? remoteProjectBindingKey(targetId, projectId), targetId, runtimeName: readString(record, "runtimeName") ?? "Remote", ...(hostname ? { hostname } : {}), projectId, rootPath, displayName: readString(record, "displayName") ?? path.basename(rootPath), + ...(readString(record, "gitOriginUrl") + ? { gitOriginUrl: readString(record, "gitOriginUrl") } + : {}), // Restore the cached project logo so the tab shows it immediately on a // cold start, before the remote reconnects and refreshes the icon. iconDataUrl: remoteProjectIconDataUrlForPersistence( @@ -1576,6 +1584,7 @@ app.whenReady().then(async () => { key: `local:${project.rootPath}`, rootPath: project.rootPath, displayName: project.displayName, + gitOriginUrl: readGitOriginUrl(project.rootPath), } : null; @@ -1791,7 +1800,7 @@ app.whenReady().then(async () => { ): void => { const state = readGlobalState(globalStatePath); const iconDataUrl = remoteProjectIconDataUrlForPersistence(binding.iconDataUrl); - const persistedBinding = withPersistableRemoteProjectIcon({ + const persistedBinding = persistableRemoteProjectBinding({ ...binding, iconDataUrl, }); @@ -1808,6 +1817,9 @@ app.whenReady().then(async () => { projectId: binding.projectId, runtimeName: binding.runtimeName, hostname: binding.hostname || binding.runtimeName, + ...(binding.gitOriginUrl + ? { gitOriginUrl: binding.gitOriginUrl } + : {}), ...(iconDataUrl ? { iconDataUrl } : {}), }, }, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 135ecf3a3..8d3458425 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -85,7 +85,11 @@ import type { ProjectSecretSummary, ProjectSecretValueResult, } from "../../../shared/types"; -import { toShallowRecentProjectSummary } from "../projects/recentProjectSummary"; +import { + readGitOriginUrl, + toShallowRecentProjectSummary, +} from "../projects/recentProjectSummary"; +import { authorizeRecentProjectRuntimeRoot } from "../projects/recentProjectRuntimeAuthorization"; import type { ApplyConflictProposalArgs, BatchAssessmentResult, @@ -4109,6 +4113,19 @@ export function registerIpc({ const runtimeBridge = registerRuntimeBridge({ appVersion: app.getVersion(), + authorizeLocalRuntimeRoot: (session, requestedRootPath) => { + const binding = session?.binding; + const activeOrigin = binding?.gitOriginUrl + ?? (binding?.kind === "local" ? readGitOriginUrl(binding.rootPath) : null) + ?? (session?.project?.rootPath + ? readGitOriginUrl(session.project.rootPath) + : null); + return authorizeRecentProjectRuntimeRoot({ + requestedRootPath, + activeGitOriginUrl: activeOrigin, + localRecentProjects: listLocalRecentProjectSummaries(), + }); + }, bindRemoteProject, getGitHubTokenForRemoteClone: async () => { try { diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index fcbd3e719..cb27bcd58 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -420,6 +420,70 @@ describe("registerRuntimeBridge", () => { ); }); + it("allows a caller-authorized unopened checkout without expanding path authority", async () => { + const localRuntimeConnectionPool = { + callActionForRoot: vi.fn(async () => ({ + ok: true, + domain: "lane", + action: "list", + result: [], + statusHints: {}, + })), + }; + const authorizeLocalRuntimeRoot = vi.fn((_session, requestedRootPath: string) => + requestedRootPath === "/same-repo" ? requestedRootPath : null + ); + registerRuntimeBridge({ + appVersion: "1.0.0", + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + authorizeLocalRuntimeRoot, + getWindowSession: () => ({ + windowId: 7, + project: null, + binding: { + kind: "remote", + key: "remote:studio:ade", + targetId: "studio", + projectId: "ade", + rootPath: "/Users/arul/ADE", + displayName: "ADE", + runtimeName: "Studio", + hostname: "studio.local", + }, + }), + }); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallAction)?.( + eventForSender(sender(101)), + { + rootPath: "/same-repo", + request: { domain: "lane", action: "list", args: {} }, + }, + ), + ).resolves.toMatchObject({ result: [] }); + + expect(authorizeLocalRuntimeRoot).toHaveBeenCalledWith( + expect.objectContaining({ windowId: 7 }), + "/same-repo", + ); + expect(localRuntimeConnectionPool.callActionForRoot).toHaveBeenCalledWith( + "/same-repo", + expect.objectContaining({ domain: "lane", action: "list" }), + ); + + await expect( + ipcHandlers.get(IPC.localRuntimeCallAction)?.( + eventForSender(sender(101)), + { + rootPath: "/different-repo", + request: { domain: "lane", action: "list", args: {} }, + }, + ), + ).rejects.toThrow(/not available/i); + }); + it("rejects explicit local runtime roots that are not bound to the window session", async () => { const localRuntimeConnectionPool = { callActionForRoot: vi.fn(), @@ -978,6 +1042,7 @@ describe("registerRuntimeBridge", () => { projectId: "project-1", rootPath: "/srv/ade", displayName: "ADE", + gitOriginUrl: "git@github.com:example/ade.git", iconDataUrl: null, }); @@ -994,6 +1059,7 @@ describe("registerRuntimeBridge", () => { projectId: "project-1", rootPath: "/srv/ade", displayName: "ADE", + gitOriginUrl: "git@github.com:example/ade.git", iconDataUrl: null, }); }); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.ts index 9dcc9a4e1..2c28d3459 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.ts @@ -3,6 +3,7 @@ import { randomUUID as nodeRandomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { IPC } from "../../../shared/ipc"; +import { remoteProjectBindingKey } from "../../../shared/projectIdentity"; import type { CloneProjectInput, CreateProjectInput, @@ -88,6 +89,10 @@ type RuntimeBridgeArgs = { openProjectTabs?: ProjectInfo[]; pendingLocalProjectRoots?: string[]; }; + authorizeLocalRuntimeRoot?: ( + session: WindowRuntimeSession | null | undefined, + requestedRootPath: string, + ) => string | null; bindRemoteProject?: ( windowId: number | null, binding: OpenProjectBinding & { kind: "remote" }, @@ -242,11 +247,19 @@ function collectAuthorizedLocalRuntimeRoots( function resolveAuthorizedLocalRuntimeRootPath( session: WindowRuntimeSession | null | undefined, requestedRootPath: string | null | undefined, + authorizeLocalRuntimeRoot?: RuntimeBridgeArgs["authorizeLocalRuntimeRoot"], ): string | null { const roots = collectAuthorizedLocalRuntimeRoots(session); const requested = normalizeLocalRuntimeRootPath(requestedRootPath); if (requested) { - return roots.get(localRuntimeRootKey(requested)) ?? null; + const alreadyAuthorized = roots.get(localRuntimeRootKey(requested)); + if (alreadyAuthorized) return alreadyAuthorized; + const additionallyAuthorized = authorizeLocalRuntimeRoot?.(session, requested); + const normalizedAdditional = normalizeLocalRuntimeRootPath(additionallyAuthorized); + return normalizedAdditional && + localRuntimeRootKey(normalizedAdditional) === localRuntimeRootKey(requested) + ? normalizedAdditional + : null; } const fallbackRoot = @@ -333,6 +346,7 @@ export function getOrCreateLocalAccountMachineIdentity(args: { export function registerRuntimeBridge({ appVersion, + authorizeLocalRuntimeRoot, bindRemoteProject, getGitHubTokenForRemoteClone, getLocalMachineIdentity, @@ -939,13 +953,14 @@ export function registerRuntimeBridge({ const binding: OpenProjectBinding & { kind: "remote" } = { kind: "remote", - key: `remote:${target.id}:${project.projectId}`, + key: remoteProjectBindingKey(target.id, project.projectId), targetId: target.id, runtimeName: target.name, hostname: target.hostname, projectId: project.projectId, rootPath: project.rootPath, displayName: project.displayName || path.basename(project.rootPath), + gitOriginUrl: project.gitOriginUrl, iconDataUrl: project.icon?.dataUrl ?? null, }; if ( @@ -1103,6 +1118,7 @@ export function registerRuntimeBridge({ const rootPath = resolveAuthorizedLocalRuntimeRootPath( session, arg?.rootPath, + authorizeLocalRuntimeRoot, ); if (!rootPath) { throw new Error( @@ -1140,6 +1156,7 @@ export function registerRuntimeBridge({ const rootPath = resolveAuthorizedLocalRuntimeRootPath( session, arg?.rootPath, + authorizeLocalRuntimeRoot, ); if (!rootPath) { throw new Error( @@ -1178,6 +1195,7 @@ export function registerRuntimeBridge({ const rootPath = resolveAuthorizedLocalRuntimeRootPath( session, arg?.rootPath, + authorizeLocalRuntimeRoot, ); if (!rootPath) { throw new Error( @@ -1208,6 +1226,7 @@ export function registerRuntimeBridge({ const rootPath = resolveAuthorizedLocalRuntimeRootPath( session, arg?.rootPath, + authorizeLocalRuntimeRoot, ); if (!rootPath) { throw new Error( @@ -1280,7 +1299,7 @@ export function registerRuntimeBridge({ const target = remoteConnectionService.getTarget(id); if (!target) throw new Error("Remote target was not found."); const request = normalizeRuntimeStreamEventsRequest(arg?.request); - const bindingKey = `remote:${target.id}:${projectId}`; + const bindingKey = remoteProjectBindingKey(target.id, projectId); const requestKey = `${bindingKey}:${request.category ?? "*"}:${request.replay === false ? "live" : "replay"}`; const subscribe = ( onEvent: (event: RemoteRuntimeBufferedEvent, eventEpoch?: string | null) => void, diff --git a/apps/desktop/src/main/services/projects/recentProjectRuntimeAuthorization.test.ts b/apps/desktop/src/main/services/projects/recentProjectRuntimeAuthorization.test.ts new file mode 100644 index 000000000..c722b3e6c --- /dev/null +++ b/apps/desktop/src/main/services/projects/recentProjectRuntimeAuthorization.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { authorizeRecentProjectRuntimeRoot } from "./recentProjectRuntimeAuthorization"; + +describe("authorizeRecentProjectRuntimeRoot", () => { + it("authorizes an existing recent checkout of the active repository", () => { + expect(authorizeRecentProjectRuntimeRoot({ + requestedRootPath: "/Users/admin/ADE", + activeGitOriginUrl: "git@github.com:arul28/ADE.git", + localRecentProjects: [{ + rootPath: "/Users/admin/ADE", + displayName: "ADE", + lastOpenedAt: "2026-07-28T00:00:00.000Z", + exists: true, + kind: "local", + gitOriginUrl: "https://github.com/arul28/ade.git", + }], + })).toBe("/Users/admin/ADE"); + }); + + it("rejects a recent checkout from a different repository", () => { + expect(authorizeRecentProjectRuntimeRoot({ + requestedRootPath: "/Users/admin/Versic", + activeGitOriginUrl: "git@github.com:arul28/ADE.git", + localRecentProjects: [{ + rootPath: "/Users/admin/Versic", + displayName: "Versic", + lastOpenedAt: "2026-07-28T00:00:00.000Z", + exists: true, + kind: "local", + gitOriginUrl: "git@github.com:arul28/Versic.git", + }], + })).toBeNull(); + }); + + it("rejects missing, origin-less, and non-recent paths", () => { + const localRecentProjects = [{ + rootPath: "/Users/admin/ADE", + displayName: "ADE", + lastOpenedAt: "2026-07-28T00:00:00.000Z", + exists: false, + kind: "local" as const, + gitOriginUrl: "git@github.com:arul28/ADE.git", + }]; + expect(authorizeRecentProjectRuntimeRoot({ + requestedRootPath: "/Users/admin/ADE", + activeGitOriginUrl: "git@github.com:arul28/ADE.git", + localRecentProjects, + })).toBeNull(); + expect(authorizeRecentProjectRuntimeRoot({ + requestedRootPath: "/Users/admin/Other", + activeGitOriginUrl: "git@github.com:arul28/ADE.git", + localRecentProjects, + })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/projects/recentProjectRuntimeAuthorization.ts b/apps/desktop/src/main/services/projects/recentProjectRuntimeAuthorization.ts new file mode 100644 index 000000000..77cdc55fd --- /dev/null +++ b/apps/desktop/src/main/services/projects/recentProjectRuntimeAuthorization.ts @@ -0,0 +1,26 @@ +import path from "node:path"; +import { normalizeGitRemoteIdentity } from "../../../shared/crossMachineHandoff"; +import type { RecentProjectSummary } from "../../../shared/types"; + +function runtimeRootKey(rootPath: string): string { + const resolved = path.resolve(rootPath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +export function authorizeRecentProjectRuntimeRoot(args: { + requestedRootPath: string; + activeGitOriginUrl: string | null | undefined; + localRecentProjects: RecentProjectSummary[]; +}): string | null { + const requestedKey = runtimeRootKey(args.requestedRootPath); + const recent = args.localRecentProjects.find((entry) => + entry.kind !== "remote" && + entry.exists && + runtimeRootKey(entry.rootPath) === requestedKey + ); + const requestedIdentity = normalizeGitRemoteIdentity(recent?.gitOriginUrl); + const activeIdentity = normalizeGitRemoteIdentity(args.activeGitOriginUrl); + return recent && requestedIdentity && requestedIdentity === activeIdentity + ? recent.rootPath + : null; +} diff --git a/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts b/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts index 5ecbf07df..23424acef 100644 --- a/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts +++ b/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts @@ -256,7 +256,7 @@ describe("toShallowRecentProjectSummary", () => { expect(summary.gitOriginUrl).toBeUndefined(); }); - it("leaves remote recents alone", () => { + it("carries a persisted remote origin without touching the remote filesystem", () => { const summary = toShallowRecentProjectSummary({ rootPath: "/srv/ade/app", displayName: "App", @@ -266,10 +266,11 @@ describe("toShallowRecentProjectSummary", () => { projectId: "project-1", runtimeName: "Mac Studio", hostname: "studio.local", + gitOriginUrl: "git@github.com:arul28/ADE.git", }, }); expect(summary.kind).toBe("remote"); - expect(summary.gitOriginUrl).toBeUndefined(); + expect(summary.gitOriginUrl).toBe("git@github.com:arul28/ADE.git"); }); }); diff --git a/apps/desktop/src/main/services/projects/recentProjectSummary.ts b/apps/desktop/src/main/services/projects/recentProjectSummary.ts index 76c162687..91917d46b 100644 --- a/apps/desktop/src/main/services/projects/recentProjectSummary.ts +++ b/apps/desktop/src/main/services/projects/recentProjectSummary.ts @@ -262,6 +262,9 @@ function remoteRecentSummary(entry: RecentProjectEntry): RecentProjectSummary { exists: true, kind: "remote", remote: entry.remote, + ...(entry.remote?.gitOriginUrl + ? { gitOriginUrl: entry.remote.gitOriginUrl } + : {}), ...(entry.pinned ? { pinned: true } : {}), }; } diff --git a/apps/desktop/src/main/services/projects/startupProjectResolver.ts b/apps/desktop/src/main/services/projects/startupProjectResolver.ts index b66c699a9..5426b9474 100644 --- a/apps/desktop/src/main/services/projects/startupProjectResolver.ts +++ b/apps/desktop/src/main/services/projects/startupProjectResolver.ts @@ -1,9 +1,9 @@ import path from "node:path"; import type { GlobalState, RecentProject } from "../state/globalState"; import { + persistableRemoteProjectBinding, persistableRecentProjectRemote, recentProjectKey, - withPersistableRemoteProjectIcon, } from "../state/globalState"; // Keep more than the visible window so pinned-but-stale projects don't crowd @@ -39,6 +39,7 @@ function recentProjectEntryChanged( (savedRemote?.projectId ?? null) !== (projectRemote?.projectId ?? null) || (savedRemote?.runtimeName ?? null) !== (projectRemote?.runtimeName ?? null) || (savedRemote?.hostname ?? null) !== (projectRemote?.hostname ?? null) || + (savedRemote?.gitOriginUrl ?? null) !== (projectRemote?.gitOriginUrl ?? null) || (savedRemote?.iconDataUrl ?? null) !== (projectRemote?.iconDataUrl ?? null); } @@ -55,7 +56,7 @@ export function normalizeStartupProjectState(args: { ? args.saved.lastRemoteProjectBinding : null; const lastRemoteProjectBinding = savedLastRemoteProjectBinding - ? withPersistableRemoteProjectIcon(savedLastRemoteProjectBinding) + ? persistableRemoteProjectBinding(savedLastRemoteProjectBinding) : null; const candidateRecentProjects = [ ...savedRecentProjects, @@ -141,8 +142,12 @@ export function normalizeStartupProjectState(args: { ); const lastRemoteProjectBindingChanged = savedLastRemoteProjectBinding !== null && - (savedLastRemoteProjectBinding.iconDataUrl ?? null) !== - (lastRemoteProjectBinding?.iconDataUrl ?? null); + ( + (savedLastRemoteProjectBinding.iconDataUrl ?? null) !== + (lastRemoteProjectBinding?.iconDataUrl ?? null) + || (savedLastRemoteProjectBinding.gitOriginUrl ?? null) !== + (lastRemoteProjectBinding?.gitOriginUrl ?? null) + ); const lastProjectRootChanged = args.saved.lastProjectRoot !== undefined; const stateWithoutLastProject: GlobalState = { diff --git a/apps/desktop/src/main/services/state/globalState.test.ts b/apps/desktop/src/main/services/state/globalState.test.ts index b6f8aa84e..2de584d14 100644 --- a/apps/desktop/src/main/services/state/globalState.test.ts +++ b/apps/desktop/src/main/services/state/globalState.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + persistableRemoteProjectBinding, persistableRemoteProjectIconDataUrl, readGlobalState, recentProjectKey, @@ -80,6 +81,7 @@ describe("upsertRecentProject", () => { runtimeName: "mac-mini", hostname: "mac-mini.local", iconDataUrl: "data:image/png;base64,remote-icon", + gitOriginUrl: "https://token:secret@github.com/arul28/ADE.git?token=secret#fragment", }; const next = upsertRecentProject( {}, @@ -87,7 +89,10 @@ describe("upsertRecentProject", () => { ); expect(next.recentProjects).toHaveLength(1); - expect(next.recentProjects?.[0]?.remote).toEqual(remote); + expect(next.recentProjects?.[0]?.remote).toEqual({ + ...remote, + gitOriginUrl: "https://github.com/arul28/ADE.git", + }); expect(recentProjectKey(next.recentProjects![0]!)).toBe("remote:t1:p1"); }); @@ -184,6 +189,26 @@ describe("persistableRemoteProjectIconDataUrl", () => { }); }); +describe("persistableRemoteProjectBinding", () => { + it("removes HTTP credentials and transient URL data before persistence", () => { + const binding = persistableRemoteProjectBinding({ + kind: "remote" as const, + key: "remote:studio:ade", + targetId: "studio", + projectId: "ade", + rootPath: "/Users/arul/ADE", + displayName: "ADE", + runtimeName: "Studio", + hostname: "studio.local", + gitOriginUrl: "https://token:secret@github.com/arul28/ADE.git?token=secret#fragment", + }); + + expect(binding.gitOriginUrl).toBe("https://github.com/arul28/ADE.git"); + expect(JSON.stringify(binding)).not.toContain("secret"); + expect(JSON.stringify(binding)).not.toContain("token"); + }); +}); + describe("setRecentProjectPinned", () => { it("toggles pinned on the matching entry by key", () => { const remote = { targetId: "t1", projectId: "p1", runtimeName: "box", hostname: "box" }; diff --git a/apps/desktop/src/main/services/state/globalState.ts b/apps/desktop/src/main/services/state/globalState.ts index e16c99bf6..6e980839b 100644 --- a/apps/desktop/src/main/services/state/globalState.ts +++ b/apps/desktop/src/main/services/state/globalState.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import type { AppWelcomeVideoState, OpenProjectBinding, RecentProjectRemoteRef, RecentlyInstalledUpdate } from "../../../shared/types"; import { projectRefStateKey } from "../../../shared/projectIdentity"; +import { sanitizePortableGitRemote } from "../../../shared/crossMachineHandoff"; export type RecentProjectRemote = RecentProjectRemoteRef; @@ -145,15 +146,34 @@ export function withPersistableRemoteProjectIcon, +>(value: T): T { + const next = withPersistableRemoteProjectIcon(value); + const gitOriginUrl = typeof value.gitOriginUrl === "string" && value.gitOriginUrl.trim() + ? sanitizePortableGitRemote(value.gitOriginUrl) + : null; + if (gitOriginUrl) { + next.gitOriginUrl = gitOriginUrl; + } else { + delete next.gitOriginUrl; + } + return next; +} + export function persistableRecentProjectRemote( remote: RecentProjectRemote, ): RecentProjectRemote { const iconDataUrl = persistableRemoteProjectIconDataUrl(remote.iconDataUrl); + const gitOriginUrl = typeof remote.gitOriginUrl === "string" && remote.gitOriginUrl.trim() + ? sanitizePortableGitRemote(remote.gitOriginUrl) + : null; return { targetId: remote.targetId, projectId: remote.projectId, runtimeName: remote.runtimeName, hostname: remote.hostname, + ...(gitOriginUrl ? { gitOriginUrl } : {}), ...(iconDataUrl ? { iconDataUrl } : {}), }; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ed3fe4891..d82c2739a 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1427,24 +1427,34 @@ declare global { sessionId: string, pin?: OpenProjectBinding | null, ) => Promise; - delete: (args: DeleteSessionArgs) => Promise; + delete: ( + args: DeleteSessionArgs, + pin?: OpenProjectBinding | null, + ) => Promise; updateMeta: ( args: UpdateSessionMetaArgs, + pin?: OpenProjectBinding | null, ) => Promise; settle: ( sessionId: string, opts?: { outcome?: string; dismissPendingInput?: boolean }, + pin?: OpenProjectBinding | null, + ) => Promise; + unsettle: ( + sessionId: string, + pin?: OpenProjectBinding | null, ) => Promise; - unsettle: (sessionId: string) => Promise; settleMany: (sessionIds: string[]) => Promise; unsettleMany: (sessionIds: string[]) => Promise; - snoozeSession: ( + snoozeSession: ( sessionId: string, untilIso: string, + pin?: OpenProjectBinding | null, ) => Promise; wakeSession: ( sessionId: string, reason?: SessionWakeReason, + pin?: OpenProjectBinding | null, ) => Promise; snoozeSessions: ( sessionIds: string[], @@ -1457,8 +1467,12 @@ declare global { setSettleOverride: ( sessionId: string, override: SessionSettleOverride | null, + pin?: OpenProjectBinding | null, + ) => Promise; + clearWokeMarker: ( + sessionId: string, + pin?: OpenProjectBinding | null, ) => Promise; - clearWokeMarker: (sessionId: string) => Promise; getLifecycleSettings: () => Promise; updateLifecycleSettings: ( settings: SessionLifecycleSettings, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 84e4ff191..5fc21a6f9 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -6443,6 +6443,50 @@ describe("per-chat runtime routing", () => { }); }); + it("routes pinned session-card mutations to the owning machine", async () => { + const { bridge, invoke } = await mountBridge(machineA); + + await bridge.sessions.updateMeta( + { sessionId: "session-b", title: "Renamed", manuallyNamed: true }, + machineB, + ); + await bridge.sessions.snoozeSession( + "session-b", + "2026-07-28T20:00:00.000Z", + machineB, + ); + await bridge.sessions.wakeSession("session-b", "manual", machineB); + await bridge.sessions.settle("session-b", undefined, machineB); + await bridge.sessions.unsettle("session-b", machineB); + await bridge.sessions.setSettleOverride("session-b", "active", machineB); + await bridge.sessions.clearWokeMarker("session-b", machineB); + await bridge.sessions.delete({ sessionId: "session-b" }, machineB); + + const remoteActions = invoke.mock.calls + .filter(([channel]) => channel === IPC.remoteRuntimeCallAction) + .map(([, payload]) => ( + payload as { request: { action: string } } + ).request.action); + expect(remoteActions).toEqual([ + "updateMeta", + "snoozeSession", + "wakeSession", + "settleSession", + "unsettleSelfSession", + "setSettleOverride", + "clearWokeMarker", + "deleteSession", + ]); + expect(invoke).not.toHaveBeenCalledWith( + IPC.localRuntimeCallAction, + expect.objectContaining({ + request: expect.objectContaining({ + args: expect.objectContaining({ sessionId: "session-b" }), + }), + }), + ); + }); + it("streams a pinned This Mac chat while the window is remote-bound", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 87a538c2a..d48f9be73 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1545,11 +1545,10 @@ async function callPinnedRuntimeAction( return response.result as T; } -// Per-chat runtime routing: a chat inherits its machine from its lane, so a -// chat whose lane lives on another open machine carries an explicit pin and -// must reach THAT runtime without rebinding this window's project tab. When no -// pin is supplied the call behaves exactly as before — same bound path, same -// IPC fallback, no extra await. +// Per-session runtime routing: a chat, CLI, or shell inherits its machine from +// its lane, so a session on another machine carries an explicit pin and must +// reach THAT runtime without rebinding this window's project tab. Without a pin +// the call uses the active bound runtime and the existing local IPC fallback. function callPinnedOrBoundRuntimeActionOr( pin: OpenProjectBinding | null | undefined, domain: string, @@ -5453,56 +5452,66 @@ contextBridge.exposeInMainWorld("ade", { ? runtime.result : ipcRenderer.invoke(IPC.sessionsGet, { sessionId }); }, - delete: async (args: DeleteSessionArgs): Promise => { + delete: async ( + args: DeleteSessionArgs, + pin?: OpenProjectBinding | null, + ): Promise => { sessionDeltaCache.clear(); - const runtime = await callProjectRuntimeActionIfBound( + await callPinnedOrBoundRuntimeActionOr( + pin, "session", "deleteSession", - { arg: args.sessionId }, + { + arg: args.sessionId, + }, + () => ipcRenderer.invoke(IPC.sessionsDelete, args), ); - if (!runtime.handled) await ipcRenderer.invoke(IPC.sessionsDelete, args); sessionDeltaCache.clear(); }, updateMeta: async ( args: UpdateSessionMetaArgs, + pin?: OpenProjectBinding | null, ): Promise => { sessionDeltaCache.clear(); - const runtime = - await callProjectRuntimeActionIfBound( - "session", - "updateMeta", - { args }, - ); - const updated = runtime.handled - ? runtime.result - : await ipcRenderer.invoke(IPC.sessionsUpdateMeta, args); + const updated = await callPinnedOrBoundRuntimeActionOr( + pin, + "session", + "updateMeta", + { args }, + () => ipcRenderer.invoke(IPC.sessionsUpdateMeta, args), + ); sessionDeltaCache.clear(); - return updated as TerminalSessionSummary | null; + return updated; }, settle: async ( sessionId: string, opts?: { outcome?: string; dismissPendingInput?: boolean }, + pin?: OpenProjectBinding | null, ): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + const args = { + sessionId, + ...(opts?.outcome ? { outcome: opts.outcome } : {}), + ...(opts?.dismissPendingInput ? { dismissPendingInput: true } : {}), + }; + await callPinnedOrBoundRuntimeActionOr( + pin, "session", "settleSession", - { - args: { - sessionId, - ...(opts?.outcome ? { outcome: opts.outcome } : {}), - ...(opts?.dismissPendingInput ? { dismissPendingInput: true } : {}), - }, - }, + { args }, + () => ipcRenderer.invoke(IPC.sessionsSettle, { sessionId, opts }), ); - if (!runtime.handled) await ipcRenderer.invoke(IPC.sessionsSettle, { sessionId, opts }); }, - unsettle: async (sessionId: string): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + unsettle: async ( + sessionId: string, + pin?: OpenProjectBinding | null, + ): Promise => { + await callPinnedOrBoundRuntimeActionOr( + pin, "session", "unsettleSelfSession", { args: { sessionId } }, + () => ipcRenderer.invoke(IPC.sessionsUnsettle, { sessionId }), ); - if (!runtime.handled) await ipcRenderer.invoke(IPC.sessionsUnsettle, { sessionId }); }, settleMany: async (sessionIds: string[]): Promise => { const runtime = await callProjectRuntimeActionIfBound( @@ -5522,28 +5531,34 @@ contextBridge.exposeInMainWorld("ade", { ); if (!runtime.handled) await ipcRenderer.invoke(IPC.sessionsUnsettleMany, { sessionIds }); }, - snoozeSession: async (sessionId: string, untilIso: string): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + snoozeSession: async ( + sessionId: string, + untilIso: string, + pin?: OpenProjectBinding | null, + ): Promise => { + const result = await callPinnedOrBoundRuntimeActionOr( + pin, "session", "snoozeSession", { args: { sessionId, untilIso } }, + () => ipcRenderer.invoke(IPC.sessionsSnooze, { sessionId, untilIso }), ); - return runtime.handled - ? sessionLifecycleApplied(runtime.result) - : ipcRenderer.invoke(IPC.sessionsSnooze, { sessionId, untilIso }); + return sessionLifecycleApplied(result); }, wakeSession: async ( sessionId: string, reason?: SessionWakeReason, + pin?: OpenProjectBinding | null, ): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + const args = { sessionId, ...(reason ? { reason } : {}) }; + const result = await callPinnedOrBoundRuntimeActionOr( + pin, "session", "wakeSession", - { args: { sessionId, ...(reason ? { reason } : {}) } }, + { args }, + () => ipcRenderer.invoke(IPC.sessionsWake, args), ); - return runtime.handled - ? sessionLifecycleApplied(runtime.result) - : ipcRenderer.invoke(IPC.sessionsWake, { sessionId, ...(reason ? { reason } : {}) }); + return sessionLifecycleApplied(result); }, snoozeSessions: async ( sessionIds: string[], @@ -5574,25 +5589,31 @@ contextBridge.exposeInMainWorld("ade", { setSettleOverride: async ( sessionId: string, override: SessionSettleOverride | null, + pin?: OpenProjectBinding | null, ): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + const args = { sessionId, override }; + const result = await callPinnedOrBoundRuntimeActionOr( + pin, "session", "setSettleOverride", - { args: { sessionId, override } }, + { args }, + () => ipcRenderer.invoke(IPC.sessionsSetSettleOverride, args), ); - return runtime.handled - ? sessionLifecycleApplied(runtime.result) - : ipcRenderer.invoke(IPC.sessionsSetSettleOverride, { sessionId, override }); + return sessionLifecycleApplied(result); }, - clearWokeMarker: async (sessionId: string): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + clearWokeMarker: async ( + sessionId: string, + pin?: OpenProjectBinding | null, + ): Promise => { + const args = { sessionId }; + const result = await callPinnedOrBoundRuntimeActionOr( + pin, "session", "clearWokeMarker", - { args: { sessionId } }, + { args }, + () => ipcRenderer.invoke(IPC.sessionsClearWokeMarker, args), ); - return runtime.handled - ? sessionLifecycleApplied(runtime.result) - : ipcRenderer.invoke(IPC.sessionsClearWokeMarker, { sessionId }); + return sessionLifecycleApplied(result); }, getLifecycleSettings: async (): Promise => { const runtime = await callProjectRuntimeActionIfBound( @@ -6044,16 +6065,13 @@ contextBridge.exposeInMainWorld("ade", { }, delete: async (args: AgentChatDeleteArgs, pin?: OpenProjectBinding | null): Promise => { agentChatSummaryCache.clear(); - if (pin) { - await callPinnedRuntimeAction(pin, "chat", "deleteSession", { args }); - } else { - const runtime = await callProjectRuntimeActionIfBound( - "chat", - "deleteSession", - { args }, - ); - if (!runtime.handled) await ipcRenderer.invoke(IPC.agentChatDelete, args); - } + await callPinnedOrBoundRuntimeActionOr( + pin, + "chat", + "deleteSession", + { args }, + () => ipcRenderer.invoke(IPC.agentChatDelete, args), + ); agentChatSummaryCache.clear(); }, updateSession: async ( @@ -6061,26 +6079,15 @@ contextBridge.exposeInMainWorld("ade", { pin?: OpenProjectBinding | null, ): Promise => { agentChatSummaryCache.clear(); - if (pin) { - const pinned = await callPinnedRuntimeAction( - pin, - "chat", - "updateSession", - { args }, - ); - agentChatSummaryCache.clear(); - return pinned; - } - const runtime = await callProjectRuntimeActionIfBound( + const session = await callPinnedOrBoundRuntimeActionOr( + pin, "chat", "updateSession", { args }, + () => ipcRenderer.invoke(IPC.agentChatUpdateSession, args), ); - const session = runtime.handled - ? runtime.result - : await ipcRenderer.invoke(IPC.agentChatUpdateSession, args); agentChatSummaryCache.clear(); - return session as AgentChatSession; + return session; }, createScheduledWork: async ( args: AgentChatCreateScheduledWorkArgs, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index d3f75107e..517ecfabe 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -33,6 +33,7 @@ import { getDefaultModelDescriptor } from "../shared/modelRegistry"; import { deriveSmartLinkPreview } from "../shared/smartLinks"; +import { remoteProjectBindingKey } from "../shared/projectIdentity"; import { isAdeUsageRangePreset, type AdeUsageRangePreset, @@ -3575,7 +3576,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { listMyGitHubRepos: resolvedArg2({ repos: [] }), openProject: async (id: string, projectId: string) => ({ kind: "remote" as const, - key: `remote:${id}:${projectId}`, + key: remoteProjectBindingKey(id, projectId), targetId: id, runtimeName: "Mock remote", projectId, diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index ed8805901..ff534485b 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -27,6 +27,7 @@ import * as Dialog from "@radix-ui/react-dialog"; import { useAppStore } from "../../state/appStore"; import { useGithubProjectRemote } from "../../lib/useGithubProjectRemote"; import { isWebClientMode } from "../../lib/webClientMode"; +import { remoteProjectBindingKey } from "../../../shared/projectIdentity"; import { ZOOM_LEVEL_KEY, MIN_ZOOM_LEVEL, @@ -1015,6 +1016,13 @@ export function TopBar({ const evictProjectState = useAppStore((s) => s.evictProjectState); const openProjectTabRootsRef = useRef(openProjectTabRoots); const openRemoteProjectTabsRef = useRef(openRemoteProjectTabs); + // A logical repo tab remembers its chosen checkout even while another repo + // is active. This is deliberately per TopBar/window instance: opening a + // local counterpart must not make an inactive remote tab fall back to the + // first (local) machine in its group. + const [preferredBindingKeyByGroup, setPreferredBindingKeyByGroup] = useState< + Record + >({}); const [dragIdx, setDragIdx] = useState(null); const [dropIdx, setDropIdx] = useState(null); const [windowId, setWindowId] = useState(null); @@ -1219,12 +1227,50 @@ export function TopBar({ ) ?? null; byKey[tab.key] = connection?.projects.find((entry) => entry.projectId === tab.projectId) - ?.gitOriginUrl ?? null; + ?.gitOriginUrl ?? tab.gitOriginUrl ?? null; } return byKey; }, [openRemoteProjectTabs, remoteSnapshot]); const activeTabBindingKey = remoteBinding?.key ?? project?.rootPath ?? null; + const knownRemoteProjectTabs = useMemo(() => { + const byKey = new Map(); + const add = (binding: RemoteProjectTab) => { + byKey.set(binding.key, binding); + }; + for (const recent of recentProjects) { + if (recent.kind !== "remote" || !recent.remote) continue; + add({ + kind: "remote", + key: remoteProjectBindingKey(recent.remote.targetId, recent.remote.projectId), + targetId: recent.remote.targetId, + runtimeName: recent.remote.runtimeName, + hostname: recent.remote.hostname, + projectId: recent.remote.projectId, + rootPath: recent.rootPath, + displayName: recent.displayName, + gitOriginUrl: recent.gitOriginUrl ?? recent.remote.gitOriginUrl ?? null, + iconDataUrl: recent.remote.iconDataUrl ?? null, + }); + } + for (const connection of remoteSnapshot?.connections ?? []) { + for (const remoteProject of connection.projects ?? []) { + add({ + kind: "remote", + key: remoteProjectBindingKey(connection.target.id, remoteProject.projectId), + targetId: connection.target.id, + runtimeName: connection.target.name, + hostname: connection.target.hostname, + projectId: remoteProject.projectId, + rootPath: remoteProject.rootPath, + displayName: remoteProject.displayName, + gitOriginUrl: remoteProject.gitOriginUrl, + iconDataUrl: remoteProject.icon?.dataUrl ?? null, + }); + } + } + return [...byKey.values()]; + }, [recentProjects, remoteSnapshot]); // One tab per repository. Local and remote checkouts of the same repo collapse // into a single group whose machines are switchable from the tab's menu. @@ -1233,12 +1279,34 @@ export function TopBar({ groupProjectTabs({ localTabs: projectTabs, remoteTabs: openRemoteProjectTabs, + knownLocalTabs: localRecentProjects, + knownRemoteTabs: knownRemoteProjectTabs, remoteOriginByKey, activeBindingKey: activeTabBindingKey, + preferredBindingKeyByGroup, }), - [activeTabBindingKey, openRemoteProjectTabs, projectTabs, remoteOriginByKey], + [ + activeTabBindingKey, + knownRemoteProjectTabs, + localRecentProjects, + openRemoteProjectTabs, + preferredBindingKeyByGroup, + projectTabs, + remoteOriginByKey, + ], ); + useEffect(() => { + if (!activeTabBindingKey) return; + const activeGroup = tabGroups.find((group) => + group.machines.some((machine) => machine.bindingKey === activeTabBindingKey)); + if (!activeGroup) return; + setPreferredBindingKeyByGroup((current) => + current[activeGroup.id] === activeTabBindingKey + ? current + : { ...current, [activeGroup.id]: activeTabBindingKey }); + }, [activeTabBindingKey, tabGroups]); + const [machineMenu, setMachineMenu] = useState<{ groupId: string; left: number; @@ -1255,9 +1323,9 @@ export function TopBar({ const machineStatusLabel = useCallback( (machine: ProjectTabMachine): string | null => { if (!machine.isLocal) { - const binding = openRemoteProjectTabs.find( - (entry) => entry.key === machine.bindingKey, - ); + const binding = machine.binding?.kind === "remote" + ? machine.binding + : openRemoteProjectTabs.find((entry) => entry.key === machine.bindingKey); const state = binding ? remoteConnectionState(binding.targetId) : "idle"; if (state === "connecting") return "Reconnecting"; if (state !== "connected") return "Offline"; @@ -1544,16 +1612,26 @@ export function TopBar({ // reach the others. Both branches go through the existing switch paths. const handleSelectGroupMachine = useCallback( (machine: ProjectTabMachine) => { + const group = tabGroups.find((candidate) => + candidate.machines.some((entry) => entry.bindingKey === machine.bindingKey)); + if (group) { + setPreferredBindingKeyByGroup((current) => ({ + ...current, + [group.id]: machine.bindingKey, + })); + } if (machine.isLocal) { handleSwitchProject(machine.rootPath); return; } - const binding = openRemoteProjectTabsRef.current.find( - (entry) => entry.key === machine.bindingKey, - ); + const binding = machine.binding?.kind === "remote" + ? machine.binding + : openRemoteProjectTabsRef.current.find( + (entry) => entry.key === machine.bindingKey, + ); if (binding) handleSwitchRemoteProject(binding); }, - [handleSwitchProject, handleSwitchRemoteProject], + [handleSwitchProject, handleSwitchRemoteProject, tabGroups], ); const handleRemoveTab = useCallback( @@ -2152,7 +2230,8 @@ export function TopBar({ const remoteTab = openRemoteProjectTabs.find( (entry) => entry.key === machine.bindingKey, - ) ?? null; + ) + ?? (machine.binding?.kind === "remote" ? machine.binding : null); if (!remoteTab) return null; const isCurrentRemote = remoteBinding?.key === remoteTab.key; const remoteTabState = remoteConnectionState(remoteTab.targetId); diff --git a/apps/desktop/src/renderer/components/app/projectTabGrouping.test.ts b/apps/desktop/src/renderer/components/app/projectTabGrouping.test.ts index a348f9bf1..5d282ded7 100644 --- a/apps/desktop/src/renderer/components/app/projectTabGrouping.test.ts +++ b/apps/desktop/src/renderer/components/app/projectTabGrouping.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { activeMachineForGroup, + groupRecentProjects, groupProjectTabs, isMultiMachine, LOCAL_MACHINE_NAME, @@ -31,6 +32,30 @@ function remote(targetId: string, projectId: string, runtimeName: string): Remot } as RemoteProjectTabBinding; } +function remoteRecent( + targetId: string, + projectId: string, + runtimeName: string, + gitOriginUrl: string, + lastOpenedAt: string, +): RecentProjectSummary { + return { + rootPath: `/Users/other/${projectId}`, + displayName: projectId, + lastOpenedAt, + exists: true, + kind: "remote", + gitOriginUrl, + remote: { + targetId, + projectId, + runtimeName, + hostname: runtimeName, + gitOriginUrl, + }, + }; +} + describe("groupProjectTabs", () => { it("merges a local and a remote checkout of the same repo into one tab", () => { const groups = groupProjectTabs({ @@ -131,4 +156,131 @@ describe("groupProjectTabs", () => { }); expect(groups).toHaveLength(2); }); + + it("attaches a known unopened checkout without creating another tab", () => { + const origin = "git@github.com:arul28/ADE.git"; + const groups = groupProjectTabs({ + localTabs: [], + remoteTabs: [{ ...remote("t1", "p1", "Mac Studio"), gitOriginUrl: origin }], + knownLocalTabs: [local("/Users/me/ADE", origin)], + knownRemoteTabs: [remote("t2", "p2", "MacBook Pro")], + remoteOriginByKey: { "remote:t2:p2": "git@github.com:arul28/other.git" }, + }); + + expect(groups).toHaveLength(1); + expect(groups[0].machines.map((machine) => machine.machineName)).toEqual([ + "Mac Studio", + LOCAL_MACHINE_NAME, + ]); + }); + + it("keeps an inactive repo on its preferred machine when a local counterpart appears", () => { + const origin = "git@github.com:arul28/Versic.git"; + const remoteBinding = { ...remote("studio", "versic", "Mac Studio"), gitOriginUrl: origin }; + const groups = groupProjectTabs({ + localTabs: [local("/Users/me/Other", "git@github.com:arul28/Other.git")], + remoteTabs: [remoteBinding], + knownLocalTabs: [local("/Users/me/Versic", origin)], + preferredBindingKeyByGroup: { + "origin:github.com/arul28/versic": remoteBinding.key, + }, + }); + const versic = groups.find((group) => group.machines.some( + (machine) => machine.bindingKey === remoteBinding.key, + )); + + expect(activeMachineForGroup(versic!)?.machineName).toBe("Mac Studio"); + }); +}); + +describe("groupRecentProjects", () => { + it("renders one recent card for two machine checkouts of the same origin", () => { + const groups = groupRecentProjects({ + recentProjects: [ + local("/Users/me/ADE", "git@github.com:arul28/ADE.git"), + remoteRecent( + "studio", + "ade", + "Mac Studio", + "https://github.com/arul28/ADE", + "2026-07-28T12:00:00.000Z", + ), + ], + remoteSnapshot: { + connectedCount: 1, + updatedAt: 1, + connections: [{ + target: { id: "studio", name: "Mac Studio", hostname: "studio.local" }, + state: "connected", + projects: [], + }], + } as never, + }); + + expect(groups).toHaveLength(1); + expect(groups[0].locations.map((location) => location.machineName)).toEqual([ + "Mac Studio", + LOCAL_MACHINE_NAME, + ]); + }); + + it("uses the newest reachable checkout and fails over when the newest machine is offline", () => { + const localRecent = { + ...local("/Users/me/Versic", "git@github.com:arul28/Versic.git"), + lastOpenedAt: "2026-07-28T11:00:00.000Z", + }; + const groups = groupRecentProjects({ + recentProjects: [ + localRecent, + remoteRecent( + "studio", + "versic", + "Mac Studio", + "git@github.com:arul28/Versic.git", + "2026-07-28T12:00:00.000Z", + ), + ], + remoteSnapshot: { + connectedCount: 0, + updatedAt: 1, + connections: [{ + target: { id: "studio", name: "Mac Studio", hostname: "studio.local" }, + state: "idle", + projects: [], + }], + } as never, + }); + + expect(groups[0].primary.machineId).toBe("this-mac"); + }); + + it("auto-binds a never-opened connected catalog checkout by strict origin", () => { + const groups = groupRecentProjects({ + recentProjects: [{ + ...local("/Users/me/ADE", "git@github.com:arul28/ADE.git"), + lastOpenedAt: "2026-07-28T10:00:00.000Z", + }], + remoteSnapshot: { + connectedCount: 1, + updatedAt: 1, + connections: [{ + target: { id: "studio", name: "Mac Studio", hostname: "studio.local" }, + state: "connected", + projects: [{ + projectId: "ade", + rootPath: "/Users/studio/ADE", + displayName: "ADE", + gitOriginUrl: "https://github.com/arul28/ADE.git", + lastOpenedAt: 123, + icon: null, + }], + }], + } as never, + }); + + expect(groups).toHaveLength(1); + expect(groups[0].locations).toHaveLength(2); + expect(groups[0].locations[1].recentKey).toBeNull(); + expect(groups[0].locations[1].summary.remote?.projectId).toBe("ade"); + }); }); diff --git a/apps/desktop/src/renderer/components/app/projectTabGrouping.ts b/apps/desktop/src/renderer/components/app/projectTabGrouping.ts index f10409473..6214f81f8 100644 --- a/apps/desktop/src/renderer/components/app/projectTabGrouping.ts +++ b/apps/desktop/src/renderer/components/app/projectTabGrouping.ts @@ -1,12 +1,18 @@ // Origin normalization is a URL/regex parse and the tab list re-derives on // every connection-snapshot tick, so it goes through the shared per-URL cache. import { cachedGitRemoteIdentity } from "../lanes/laneMachines"; -import type { OpenProjectBinding, RecentProjectSummary } from "../../../shared/types"; +import type { + OpenProjectBinding, + RecentProjectSummary, + RemoteRuntimeConnectionSnapshot, + RemoteRuntimeConnectionState, +} from "../../../shared/types"; import { THIS_MACHINE_ID as LOCAL_MACHINE_ID, THIS_MACHINE_NAME as LOCAL_MACHINE_NAME, } from "../../../shared/machineIdentity"; +import { remoteProjectBindingKey } from "../../../shared/projectIdentity"; export { LOCAL_MACHINE_ID, LOCAL_MACHINE_NAME }; export type RemoteProjectTabBinding = Extract; @@ -23,6 +29,8 @@ export type ProjectTabMachine = { displayName: string; laneCount?: number; iconDataUrl?: string | null; + /** Full binding when the location can be opened without first becoming a tab. */ + binding?: OpenProjectBinding; }; /** One repo — the thing that gets a tab. */ @@ -43,6 +51,13 @@ function localMachine(tab: RecentProjectSummary): ProjectTabMachine { rootPath: tab.rootPath, displayName: tab.displayName, laneCount: tab.laneCount, + binding: { + kind: "local", + key: `local:${tab.rootPath}`, + rootPath: tab.rootPath, + displayName: tab.displayName, + gitOriginUrl: tab.gitOriginUrl, + }, }; } @@ -55,6 +70,7 @@ function remoteMachine(binding: RemoteProjectTabBinding): ProjectTabMachine { rootPath: binding.rootPath, displayName: binding.displayName, iconDataUrl: binding.iconDataUrl, + binding, }; } @@ -75,21 +91,48 @@ function remoteMachine(binding: RemoteProjectTabBinding): ProjectTabMachine { export function groupProjectTabs(args: { localTabs: readonly RecentProjectSummary[]; remoteTabs: readonly RemoteProjectTabBinding[]; + /** Known-but-not-open locations. They are attached to an existing logical + * tab, but never create a tab by themselves. */ + knownLocalTabs?: readonly RecentProjectSummary[]; + knownRemoteTabs?: readonly RemoteProjectTabBinding[]; /** Normalized origin per remote binding key, from the live connection * snapshots the renderer already holds. */ remoteOriginByKey?: Readonly>; activeBindingKey?: string | null; + preferredBindingKeyByGroup?: Readonly>; }): ProjectTabGroup[] { - const { localTabs, remoteTabs, remoteOriginByKey = {}, activeBindingKey = null } = args; + const { + localTabs, + remoteTabs, + knownLocalTabs = [], + knownRemoteTabs = [], + remoteOriginByKey = {}, + activeBindingKey = null, + preferredBindingKeyByGroup = {}, + } = args; - const entries: { machine: ProjectTabMachine; origin: string | null }[] = [ + const openEntries: { machine: ProjectTabMachine; origin: string | null }[] = [ ...localTabs.map((tab) => ({ machine: localMachine(tab), origin: cachedGitRemoteIdentity(tab.gitOriginUrl), })), ...remoteTabs.map((binding) => ({ machine: remoteMachine(binding), - origin: cachedGitRemoteIdentity(remoteOriginByKey[binding.key] ?? null), + origin: cachedGitRemoteIdentity( + remoteOriginByKey[binding.key] ?? binding.gitOriginUrl ?? null, + ), + })), + ]; + const knownEntries: { machine: ProjectTabMachine; origin: string | null }[] = [ + ...knownLocalTabs.map((tab) => ({ + machine: localMachine(tab), + origin: cachedGitRemoteIdentity(tab.gitOriginUrl), + })), + ...knownRemoteTabs.map((binding) => ({ + machine: remoteMachine(binding), + origin: cachedGitRemoteIdentity( + remoteOriginByKey[binding.key] ?? binding.gitOriginUrl ?? null, + ), })), ]; @@ -104,7 +147,7 @@ export function groupProjectTabs(args: { activeBindingKey: machine.bindingKey === activeBindingKey ? machine.bindingKey : null, }); - for (const { machine, origin } of entries) { + for (const { machine, origin } of openEntries) { if (!origin) { groups.push(standalone(machine)); continue; @@ -131,6 +174,33 @@ export function groupProjectTabs(args: { } } + // Attach discovered counterparts only after open entries establish the tab + // set. This is what lets an unopened local checkout appear in the machine + // switcher without turning every recent project into an open tab. + for (const { machine, origin } of knownEntries) { + if (!origin) continue; + const existing = groupByOrigin.get(origin); + if (!existing) continue; + if (existing.machines.some((candidate) => candidate.bindingKey === machine.bindingKey)) { + continue; + } + const claimed = claimedMachines.get(origin)!; + if (claimed.has(machine.machineId)) continue; + claimed.add(machine.machineId); + existing.machines.push(machine); + if (machine.bindingKey === activeBindingKey) { + existing.activeBindingKey = machine.bindingKey; + } + } + + for (const group of groups) { + if (group.activeBindingKey) continue; + const preferred = preferredBindingKeyByGroup[group.id] ?? null; + if (preferred && group.machines.some((machine) => machine.bindingKey === preferred)) { + group.activeBindingKey = preferred; + } + } + return groups; } @@ -147,3 +217,192 @@ export function activeMachineForGroup(group: ProjectTabGroup): ProjectTabMachine export function isMultiMachine(group: ProjectTabGroup): boolean { return group.machines.length > 1; } + +export type RecentProjectLocation = { + summary: RecentProjectSummary; + recentKey: string | null; + machineId: string; + machineName: string; + connectionState: RemoteRuntimeConnectionState | null; + reachable: boolean; +}; + +export type RecentProjectGroup = { + id: string; + displayName: string; + locations: RecentProjectLocation[]; + primary: RecentProjectLocation; + recentKeys: string[]; + pinned: boolean; + lastOpenedAt: string; +}; + +export function recentProjectLocationKey(project: RecentProjectSummary): string { + return project.kind === "remote" && project.remote + ? remoteProjectBindingKey(project.remote.targetId, project.remote.projectId) + : project.rootPath; +} + +function isoTimestamp(value: string | null | undefined): number { + const parsed = Date.parse(value ?? ""); + return Number.isFinite(parsed) ? parsed : 0; +} + +function recentTimestamp(project: RecentProjectSummary): number { + return isoTimestamp(project.lastOpenedAt); +} + +/** + * One recent card per repository, enriched with matching machine-catalog + * locations. Pure live catalog entries attach to a recent group but never + * create a card on their own. + */ +export function groupRecentProjects(args: { + recentProjects: readonly RecentProjectSummary[]; + remoteSnapshot?: RemoteRuntimeConnectionSnapshot | null; +}): RecentProjectGroup[] { + const { recentProjects, remoteSnapshot = null } = args; + const connections = remoteSnapshot?.connections ?? []; + const liveProjectByKey = new Map(); + for (const connection of connections) { + for (const project of connection.projects ?? []) { + liveProjectByKey.set( + remoteProjectBindingKey(connection.target.id, project.projectId), + { connection, project }, + ); + } + } + + type MutableGroup = { + id: string; + displayName: string; + locations: RecentProjectLocation[]; + recentKeys: string[]; + pinned: boolean; + lastOpenedAt: string; + claimedMachines: Set; + }; + const groups: MutableGroup[] = []; + const byOrigin = new Map(); + + const toLocation = ( + summary: RecentProjectSummary, + recentKey: string | null, + ): RecentProjectLocation => { + if (summary.kind === "remote" && summary.remote) { + const connection = connections.find( + (candidate) => candidate.target.id === summary.remote?.targetId, + ); + const connectionState = connection?.state ?? "idle"; + return { + summary, + recentKey, + machineId: summary.remote.targetId, + machineName: summary.remote.runtimeName, + connectionState, + reachable: connectionState === "connected", + }; + } + return { + summary, + recentKey, + machineId: LOCAL_MACHINE_ID, + machineName: LOCAL_MACHINE_NAME, + connectionState: null, + reachable: summary.exists !== false, + }; + }; + + for (const summary of recentProjects) { + const key = recentProjectLocationKey(summary); + const live = summary.kind === "remote" ? liveProjectByKey.get(key) : null; + const origin = cachedGitRemoteIdentity( + summary.gitOriginUrl ?? live?.project.gitOriginUrl ?? null, + ); + const location = toLocation( + live && !summary.gitOriginUrl + ? { ...summary, gitOriginUrl: live.project.gitOriginUrl } + : summary, + key, + ); + let group = origin ? byOrigin.get(origin) : undefined; + if (!group || group.claimedMachines.has(location.machineId)) { + group = { + id: origin && !byOrigin.has(origin) ? origin : key, + displayName: summary.displayName, + locations: [], + recentKeys: [], + pinned: false, + lastOpenedAt: summary.lastOpenedAt, + claimedMachines: new Set(), + }; + groups.push(group); + if (origin && !byOrigin.has(origin)) byOrigin.set(origin, group); + } + group.locations.push(location); + group.claimedMachines.add(location.machineId); + group.recentKeys.push(key); + group.pinned ||= Boolean(summary.pinned); + if (recentTimestamp(summary) > isoTimestamp(group.lastOpenedAt)) { + group.lastOpenedAt = summary.lastOpenedAt; + group.displayName = summary.displayName; + } + } + + // A connected machine may know a checkout that has never been opened from + // this desktop. Attach it by strict origin identity so the card can say + // "Also on …" and the tab switcher can open it immediately. + for (const connection of connections) { + for (const project of connection.projects ?? []) { + const origin = cachedGitRemoteIdentity(project.gitOriginUrl); + if (!origin) continue; + const group = byOrigin.get(origin); + if (!group || group.claimedMachines.has(connection.target.id)) continue; + const summary: RecentProjectSummary = { + rootPath: project.rootPath, + displayName: project.displayName, + lastOpenedAt: project.lastOpenedAt + ? new Date(project.lastOpenedAt).toISOString() + : "", + exists: true, + kind: "remote", + gitOriginUrl: project.gitOriginUrl, + remote: { + targetId: connection.target.id, + projectId: project.projectId, + runtimeName: connection.target.name, + hostname: connection.target.hostname, + gitOriginUrl: project.gitOriginUrl, + iconDataUrl: project.icon?.dataUrl ?? null, + }, + }; + group.locations.push(toLocation(summary, null)); + group.claimedMachines.add(connection.target.id); + } + } + + return groups + .map((group): RecentProjectGroup => { + const locations = [...group.locations].sort( + (left, right) => recentTimestamp(right.summary) - recentTimestamp(left.summary), + ); + const primary = locations.find((location) => location.reachable) ?? locations[0]!; + return { + id: group.id, + displayName: group.displayName, + locations, + primary, + recentKeys: group.recentKeys, + pinned: group.pinned, + lastOpenedAt: group.lastOpenedAt, + }; + }) + .sort((left, right) => { + const pinDelta = Number(right.pinned) - Number(left.pinned); + if (pinDelta !== 0) return pinDelta; + return isoTimestamp(right.lastOpenedAt) - isoTimestamp(left.lastOpenedAt); + }); +} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index e4c13b18c..2c3a4641b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -2168,8 +2168,11 @@ describe("AgentChatComposer", () => { onMachineChange, }); - const trigger = screen.getByRole("button", { name: "Run on This Mac" }); - expect(trigger.textContent).toContain("Run on: This Mac"); + const trigger = screen.getByRole("button", { + name: "Choose machine, currently This Mac", + }); + expect(trigger.textContent).toContain("This Mac"); + expect(trigger.textContent).not.toContain("Run on"); fireEvent.click(trigger); fireEvent.click(screen.getByRole("menuitem", { name: /MacBook Pro \(97\)/ })); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 31a6efb45..89881eb2a 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, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; +import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DesktopTower, 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, @@ -579,12 +579,14 @@ const EMPTY_PROJECT_TAB_ROOTS: string[] = []; * only the affordance changes. */ function ComposerMachineChip({ + machineId, machineName, selectable, options, onChange, disabled = false, }: { + machineId: string; machineName: string; selectable: boolean; options: ComposerMachineOption[]; @@ -614,19 +616,19 @@ function ComposerMachineChip({ - + {machineName} @@ -640,19 +642,19 @@ function ComposerMachineChip({ data-chat-composer-machine-chip="picker" aria-haspopup="menu" aria-expanded={menuOpen} - aria-label={`Run on ${machineName}`} + aria-label={`Choose machine, currently ${machineName}`} disabled={disabled} onMouseDown={(event) => event.stopPropagation()} onClick={() => setMenuOpen((current) => !current)} className={cn( - "inline-flex h-6 shrink-0 items-center gap-1 rounded-md border border-accent/45 bg-accent/[0.08] px-1.5", - "font-sans text-[9px] font-medium text-accent transition-colors", - "hover:bg-accent/[0.14] disabled:cursor-not-allowed disabled:opacity-45", + "inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1", + "font-sans text-[9px] font-medium text-muted-fg/70 transition-colors", + "hover:bg-amber-400/[0.08] hover:text-fg/85 disabled:cursor-not-allowed disabled:opacity-45", )} style={{ whiteSpace: "nowrap" }} > - - Run on: {machineName} + + {machineName} {menuOpen ? ( @@ -673,13 +675,13 @@ function ComposerMachineChip({ }} className={cn( "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-left font-sans text-[10px] transition-colors", - option.name === machineName ? "text-fg" : "text-fg/65 hover:bg-white/[0.06]", + option.id === machineId ? "text-fg" : "text-fg/65 hover:bg-white/[0.06]", )} style={{ whiteSpace: "nowrap" }} > - + {option.name} - {option.name === machineName ? : null} + {option.id === machineId ? : null} ))} @@ -1310,6 +1312,8 @@ export function AgentChatComposer({ appControlOpen = false, onToggleAppControl, laneSelectionId = null, + machineSelectable, + machineId: machineIdOverride, machineName: machineNameOverride, machineOptions: machineOptionsOverride, onMachineChange, @@ -1508,6 +1512,10 @@ export function AgentChatComposer({ * degrades to a read-only statement of fact. */ laneSelectionId?: string | null; + /** Explicitly controls the draft-only picker affordance. */ + machineSelectable?: boolean; + /** Current machine id, used to mark the selected menu row. */ + machineId?: string; /** Overrides the machine name derived from the active project binding. */ machineName?: string; /** Overrides the machine list derived from the open project tabs. */ @@ -1642,6 +1650,8 @@ export function AgentChatComposer({ const machineName = machineNameOverride ?? (projectBinding?.kind === "remote" ? projectBinding.runtimeName : COMPOSER_LOCAL_MACHINE_NAME); + const machineId = machineIdOverride + ?? (projectBinding?.kind === "remote" ? projectBinding.targetId : COMPOSER_LOCAL_MACHINE_ID); const machineOptions = useMemo(() => { if (machineOptionsOverride) return machineOptionsOverride; const options: ComposerMachineOption[] = [ @@ -1655,7 +1665,7 @@ export function AgentChatComposer({ }, [machineOptionsOverride, openRemoteProjectTabs]); // Opt-in: an unknown lane selection is treated as settled, so the chip only // claims to be a control when the host says the lane is still auto-create. - const machineChipSelectable = isAutoCreateLaneOptionId(laneSelectionId); + const machineChipSelectable = machineSelectable ?? isAutoCreateLaneOptionId(laneSelectionId); const [machineSwitchError, setMachineSwitchError] = useState(null); const handleMachineChange = useCallback( (machineId: string) => { @@ -4601,6 +4611,7 @@ export function AgentChatComposer({ ) : null} {!hideModelControls ? ( { expect(onLaneChange).toHaveBeenCalledWith("lane-worktree"); }); - it("blocks every draft launch path until a cross-machine auto-create switch settles", async () => { + it("auto-creates on This Mac from a remote-bound tab without rebinding the project", async () => { const { create } = installAdeMocks({ sessions: [] }); const localBinding = { kind: "local" as const, key: "local:/tmp/project-under-test", rootPath: "/tmp/project-under-test", displayName: "project-under-test", + gitOriginUrl: "git@github.com:acme/project-under-test.git", }; const remoteBinding = { kind: "remote" as const, @@ -5133,34 +5137,59 @@ describe("AgentChatPane submit recovery", () => { projectId: "project-a", rootPath: "/Volumes/work/project-under-test", displayName: "project-under-test", + gitOriginUrl: "https://github.com/acme/project-under-test", }; - let finishSwitch!: () => void; - const switchRemoteProject = vi.fn(() => { - useAppStore.setState({ projectTransition: { phase: "switching" } as any }); - return new Promise((resolve) => { - finishSwitch = () => { - useAppStore.setState({ - project: { - rootPath: remoteBinding.rootPath, - displayName: remoteBinding.displayName, - } as any, - projectBinding: remoteBinding, - projectTransition: null, - }); - resolve(remoteBinding); - }; - }); - }); + const switchProjectToPath = vi.fn(); + const switchRemoteProject = vi.fn(); + const remoteLanes = [{ + // Primary lane ids are intentionally duplicated across machines. The + // machine-qualified picker value must still route creation to This Mac. + id: "primary", + name: "Primary", + laneType: "primary", + branchRef: "refs/heads/main", + worktreePath: remoteBinding.rootPath, + }]; + const localLanes = [{ + id: "primary", + name: "Primary", + laneType: "primary", + branchRef: "refs/heads/main", + worktreePath: localBinding.rootPath, + }]; useAppStore.setState({ project: { - rootPath: localBinding.rootPath, - displayName: localBinding.displayName, + rootPath: remoteBinding.rootPath, + displayName: remoteBinding.displayName, } as any, - projectBinding: localBinding, + projectBinding: remoteBinding, openProjectTabRoots: [localBinding.rootPath], openRemoteProjectTabs: [remoteBinding], + crossMachineLanesByMachineId: { + "this-mac": { + machineId: "this-mac", + machineName: "This Mac", + targetId: null, + projectId: null, + binding: localBinding, + online: true, + lanes: localLanes as any, + sessions: [], + lastSyncedAtMs: Date.now(), + error: null, + }, + }, + switchProjectToPath, switchRemoteProject, }); + (window.ade.project.listRecent as ReturnType).mockResolvedValue([{ + rootPath: localBinding.rootPath, + displayName: localBinding.displayName, + lastOpenedAt: "2026-07-28T12:00:00.000Z", + exists: true, + kind: "local", + gitOriginUrl: localBinding.gitOriginUrl, + }]); window.ade.remoteRuntime = { getConnectionSnapshot: vi.fn().mockResolvedValue({ connections: [{ @@ -5174,7 +5203,7 @@ describe("AgentChatPane submit recovery", () => { projectId: remoteBinding.projectId, rootPath: remoteBinding.rootPath, displayName: remoteBinding.displayName, - gitOriginUrl: null, + gitOriginUrl: remoteBinding.gitOriginUrl, }], }], connectedCount: 1, @@ -5183,9 +5212,9 @@ describe("AgentChatPane submit recovery", () => { onConnectionSnapshotChanged: vi.fn(() => () => {}), } as any; - renderAutoCreateDraftPane(); + renderAutoCreateDraftPane({ lanes: remoteLanes }); const textbox = await screen.findByRole("textbox"); - fireEvent.change(textbox, { target: { value: "Create this on the studio." } }); + fireEvent.change(textbox, { target: { value: "Create this on my MacBook." } }); const modelTrigger = await screen.findByRole("button", { name: /^Select model/ }); const codexLabel = getModelById("openai/gpt-5.4")?.displayName ?? "GPT-5.4"; fireEvent.pointerDown(modelTrigger, { button: 0 }); @@ -5196,20 +5225,24 @@ describe("AgentChatPane submit recovery", () => { const machineRows = await screen.findAllByText("Auto-create lane here"); fireEvent.click(machineRows.at(-1)!); - expect(switchRemoteProject).toHaveBeenCalledWith("target-studio", "project-a"); - fireEvent.click(screen.getByRole("button", { name: "Send" })); - fireEvent.click(screen.getByRole("button", { name: "Auto-create in background" })); - expect(create).not.toHaveBeenCalled(); - - finishSwitch(); - await waitFor(() => expect(useAppStore.getState().projectBinding).toEqual(remoteBinding)); + expect(switchProjectToPath).not.toHaveBeenCalled(); + expect(switchRemoteProject).not.toHaveBeenCalled(); + expect(useAppStore.getState().projectBinding).toEqual(remoteBinding); + expect(await screen.findByRole("button", { + name: "Choose machine, currently This Mac", + })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Send" })); await waitFor(() => { + expect(window.ade.lanes.create).toHaveBeenCalledWith( + expect.objectContaining({ name: expect.any(String) }), + localBinding, + ); expect(create).toHaveBeenCalledWith( expect.objectContaining({ laneId: "lane-created" }), - remoteBinding, + localBinding, ); }); + expect(screen.queryByText(/Open this repository on This Mac first/i)).toBeNull(); }); it("keeps orchestrator lead mode on the first Claude draft send", async () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c8a10bb06..4170ea892 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -48,7 +48,6 @@ import { type LaneLinearIssue, type AiSettingsStatus, type OpenProjectBinding, - type RemoteRuntimeConnectionSnapshot, type TerminalSessionDetail, type TerminalToolType, } from "../../../shared/types"; @@ -186,17 +185,13 @@ import { } from "../lanes/newLaneBaseSource"; import { LaneCombobox, - AUTO_CREATE_LANE_OPTION_ID, isAutoCreateLaneOptionId, - machineIdFromAutoCreateLaneOptionId, - type LaneComboboxMachine, } from "../terminals/LaneCombobox"; import { - canCreateLaneOnMachine, - deriveLaneMachineOptions, - type LaneMachineOption, - type LaneMachineProjectRef, -} from "../lanes/laneMachines"; + AUTO_CREATE_DRAFT_LANE_OPTION as AUTO_CREATE_LANE_OPTION, + useDraftMachineRouting, + type RoutedDraftLane, +} from "./useDraftMachineRouting"; import { buildTrackedCliLaunchCommand, LAUNCH_PROFILE_TITLE, @@ -273,13 +268,6 @@ const chatToolbarActionBase = const chatToolbarActionIdle = "border-white/[0.06] bg-white/[0.02] text-muted-fg/40 hover:border-white/[0.10] hover:text-fg/65"; -const AUTO_CREATE_LANE_OPTION = { - id: AUTO_CREATE_LANE_OPTION_ID, - name: "Auto-create lane", - color: null, - branchRef: null, -}; - function handoffProviderDisplayName(provider: string | null | undefined): string { return providerDisplayLabel(provider, "this provider"); } @@ -3265,6 +3253,8 @@ export function AgentChatPane({ onOpenExistingImportedSession, availableLanes, onLaneChange, + initialDraftMachineId = null, + onDraftMachineChange, onToggleSessionsPane, sessionsPaneCollapsed, sessionsPaneCount, @@ -3321,7 +3311,10 @@ export function AgentChatPane({ */ orchestratorEnabled?: boolean; onLaunchCliSession?: (args: WorkPtyLaunchArgs) => Promise; - onOpenShellSession?: (laneId: string) => void | Promise; + onOpenShellSession?: ( + laneId: string, + pin?: OpenProjectBinding | null, + ) => void | Promise; /** * Work draft surface: route the result of importing an external CLI session. * Presence of this callback is what enables the "Import session" affordance. @@ -3336,6 +3329,10 @@ export function AgentChatPane({ availableLanes?: Array<{ id: string; name: string; color?: string | null; branchRef?: string | null; laneType?: string | null }>; /** Callback when lane selection changes in empty state */ onLaneChange?: (laneId: string) => void; + /** Machine owning the persisted Work draft lane; null means the bound machine. */ + initialDraftMachineId?: string | null; + /** Persists machine selection independently from the raw lane id. */ + onDraftMachineChange?: (machineId: string | null) => void; /** Work tab: far-left session-list expander rendered in this chat's header. */ onToggleSessionsPane?: () => void; sessionsPaneCollapsed?: boolean; @@ -3356,12 +3353,11 @@ export function AgentChatPane({ // work can keep targeting the project that started it after the user switches // to another project. const projectBinding = useAppStore((s) => s.projectBinding); - // Lane creation is routed by the tab's binding, so the lane picker's machine - // groups need both the machine list and the way to switch between them. + // Draft launches are routed independently from the global project tab. The + // lane picker needs the known checkout set, but choosing a machine here must + // never rebind the rest of ADE. const openProjectTabRoots = useAppStore((s) => s.openProjectTabRoots) ?? EMPTY_PROJECT_TAB_ROOTS; const openRemoteProjectTabs = useAppStore((s) => s.openRemoteProjectTabs) ?? EMPTY_REMOTE_PROJECT_TABS; - const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); - const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); const agentTurnCompletionSound = useAppStore((s) => s.agentTurnCompletionSound); const agentTurnCompletionSoundVolume = useAppStore((s) => s.agentTurnCompletionSoundVolume); const agentTurnCompletionSoundQuietWhenFocused = useAppStore((s) => s.agentTurnCompletionSoundQuietWhenFocused); @@ -3939,6 +3935,9 @@ export function AgentChatPane({ const fastModeUpdateCounterRef = useRef(0); const pendingFastModeUpdateRef = useRef<{ sessionId: string; updateId: number; promise: Promise } | null>(null); const pendingEventQueueRef = useRef([]); + const draftExecutionLanesRef = useRef([]); + const draftExecutionBindingRef = useRef(null); + const draftMachineUnavailableRef = useRef(false); const eventsBySessionRef = useRef>({}); const turnActiveBySessionRef = useRef>({}); const detachedHistorySessionsRef = useRef>(new Set()); @@ -3960,7 +3959,6 @@ export function AgentChatPane({ const cursorWarmupKeyRef = useRef(null); const draftLaunchConfigHydratedRef = useRef(null); const draftLaunchConfigTouchedKeyRef = useRef(null); - const preserveDraftAcrossMachineSwitchRef = useRef(false); const recoveredParallelLaunchKeyRef = useRef(null); const paneMountedRef = useRef(true); const selectedSession = useMemo( @@ -7995,12 +7993,6 @@ export function AgentChatPane({ draftsPerSessionRef.current.set(prevDraftKeyRef.current, composerDraftTextRef.current); } prevDraftKeyRef.current = companionStateKey; - if (preserveDraftAcrossMachineSwitchRef.current) { - preserveDraftAcrossMachineSwitchRef.current = false; - draftLaunchConfigTouchedKeyRef.current = draftLaunchConfigScopeKey; - draftLaunchConfigHydratedRef.current = `${draftLaunchConfigScopeKey}:machine-switch`; - return; - } const saved = readLatestComposerDraftSnapshot(composerDraftStorageKeyValues, initialNativeControls); composerDraftHydratingRef.current = true; composerDraftHydratingTextRef.current = saved?.text ?? null; @@ -8198,10 +8190,14 @@ export function AgentChatPane({ const draftLaunchTargetIsAutoCreate = isAutoCreateLaneOptionId(draftLaunchTargetId); const launchShellForDraftLane = useCallback(async () => { if (!laneId || draftLaunchTargetIsAutoCreate || !onOpenShellSession || shellLaunchBusy) return; + if (draftMachineUnavailableRef.current) { + setError("The selected machine is not currently available."); + return; + } setShellLaunchBusy(true); setError(null); try { - await onOpenShellSession(laneId); + await onOpenShellSession(laneId, draftExecutionBindingRef.current); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -8709,8 +8705,9 @@ export function AgentChatPane({ ): Promise => { if (draftLaunchTargetIsAutoCreate) { if (!laneId) throw new Error("Select a lane before auto-creating a new lane."); - const primaryLane = availableLanes?.find((candidate) => candidate.laneType === "primary") - ?? availableLanes?.find((candidate) => candidate.name.trim().toLowerCase() === "primary") + const executionLanes = draftExecutionLanesRef.current; + const primaryLane = executionLanes.find((candidate) => candidate.laneType === "primary") + ?? executionLanes.find((candidate) => candidate.name.trim().toLowerCase() === "primary") ?? null; if (!primaryLane) throw new Error("Auto-create requires a primary lane."); const namingSeed = buildDraftLaunchNamingSeed(snapshot); @@ -8736,8 +8733,8 @@ export function AgentChatPane({ ? window.ade.git.listBranches({ laneId: primaryLane.id }, pin) : window.ade.git.listBranches({ laneId: primaryLane.id }), }); - const primaryLaneSummary = lanes.find((candidate) => candidate.id === primaryLane.id) ?? null; - const primaryBaseRef = primaryLaneSummary?.baseRef ?? (branchNameFromRef(primaryLane.branchRef) || "main"); + const primaryBaseRef = primaryLane.baseRef + ?? (branchNameFromRef(primaryLane.branchRef) || "main"); const selectedBaseBranch = selectDefaultNewLaneBaseRef({ branches, source: baseSource, @@ -8789,15 +8786,25 @@ export function AgentChatPane({ }; } if (!laneId) throw new Error("Select a lane before launching."); - const launchLane = lanes.find((lane) => lane.id === laneId); - const laneName = availableLanes?.find((lane) => lane.id === laneId)?.name ?? launchLane?.name ?? laneDisplayLabel ?? laneId; + const launchLane = draftExecutionLanesRef.current.find((candidate) => candidate.id === laneId) + ?? lanes.find((candidate) => candidate.id === laneId); + const laneName = launchLane?.name ?? laneDisplayLabel ?? laneId; return { laneId, laneName, worktreePath: launchLane?.worktreePath ?? projectRoot ?? null, autoCreated: false, }; - }, [availableLanes, canRefreshPinnedProject, draftLaunchTargetIsAutoCreate, laneDisplayLabel, laneId, lanes, projectRoot, refreshLanesStore, startBackgroundLaneNaming]); + }, [ + canRefreshPinnedProject, + draftLaunchTargetIsAutoCreate, + laneDisplayLabel, + laneId, + lanes, + projectRoot, + refreshLanesStore, + startBackgroundLaneNaming, + ]); const clearDraftLaunchComposer = useCallback((snapshot: DraftLaunchSnapshot) => { setDraft((current) => { @@ -9006,6 +9013,10 @@ export function AgentChatPane({ ) { return; } + if (draftMachineUnavailableRef.current) { + setError("The selected machine is not currently available."); + return; + } if (kind === "chat" && (selectedSessionId || workDraftKind !== "chat")) return; if (kind === "cli" && (!isWorkCliLaunchDraft || !onLaunchCliSession)) return; if (!modelId) { @@ -9041,9 +9052,9 @@ export function AgentChatPane({ // `launchTimedOut` is the normal abort source: withDraftLaunchTimeout rejects // the renderer wait but cannot cancel the underlying IPC, so a timed-out // step that keeps running must be stopped before its next mutation. - const launchBinding = draftLaunchTargetIsAutoCreate - ? rootAppStoreApi.getState().projectBinding ?? projectBinding - : projectBinding; + const launchBinding = draftExecutionBindingRef.current + ?? rootAppStoreApi.getState().projectBinding + ?? projectBinding; const launchProjectRoot = projectRoot; let launchTimedOut = false; const assertLaunchActive = () => { @@ -10656,152 +10667,37 @@ export function AgentChatPane({ && !lockSessionId && !initialSessionId && (workDraftKind === "chat" || isWorkCliLaunchDraft); - /* ------------------------------------------------------------------------- - * Machine groups for the draft lane picker. - * - * A lane owns its machine, so the lane picker is where a machine gets chosen - * for a chat that has no lane yet. The list is a pure derivation over the - * remote-runtime connection snapshot — the same one the create-lane dialog - * uses — and it is only read when a second machine could exist at all, so - * single-machine setups make no extra IPC call and render the flat list they - * always did. - * ---------------------------------------------------------------------- */ - const multiMachinePossible = - showDraftLaunchControls - && (openRemoteProjectTabs.length > 0 || projectBinding?.kind === "remote"); - const [laneMachineSnapshot, setLaneMachineSnapshot] = - useState(null); - useEffect(() => { - if (!multiMachinePossible) return; - const remoteRuntime = window.ade?.remoteRuntime; - if (!remoteRuntime?.getConnectionSnapshot) return; - let cancelled = false; - const apply = (snapshot: RemoteRuntimeConnectionSnapshot) => { - if (cancelled) return; - setLaneMachineSnapshot((current) => - current && current.updatedAt > snapshot.updatedAt ? current : snapshot, - ); - }; - void remoteRuntime.getConnectionSnapshot().then(apply).catch(() => {}); - const unsubscribe = remoteRuntime.onConnectionSnapshotChanged?.(apply) ?? (() => {}); - return () => { - cancelled = true; - unsubscribe(); - }; - }, [multiMachinePossible]); - - const laneMachineOptions = useMemo(() => { - if (!multiMachinePossible) return []; - const boundProject: LaneMachineProjectRef | null = projectBinding - ? { - // The active binding IS this repo — nothing is inferred here. - matchedBy: "origin" as const, - projectId: projectBinding.kind === "remote" ? projectBinding.projectId : null, - rootPath: projectBinding.rootPath, - displayName: projectBinding.displayName, - } - : null; - const boundTargetId = projectBinding?.kind === "remote" ? projectBinding.targetId : null; - const boundConnection = boundTargetId - ? laneMachineSnapshot?.connections.find((candidate) => candidate.target.id === boundTargetId) - : null; - const repoOriginUrl = projectBinding?.kind === "remote" - ? (boundConnection?.projects.find( - (candidate) => candidate.projectId === projectBinding.projectId, - )?.gitOriginUrl ?? null) - : null; - const options = deriveLaneMachineOptions({ - connections: laneMachineSnapshot?.connections ?? [], - boundTargetId, - boundProject, - repoOriginUrl, - repoDisplayName: boundProject?.displayName ?? null, - localProjectRoots: openProjectTabRoots, - }).filter(canCreateLaneOnMachine); - // Bound machine first. `LaneCombobox` treats the first machine as the - // default — the one that owns lanes with no `machineId` and the bare - // auto-create id — and the bare id must keep meaning "create where this tab - // already is", which is exactly today's behavior. - return [ - ...options.filter((option) => option.isBound), - ...options.filter((option) => !option.isBound), - ]; - }, [laneMachineSnapshot, multiMachinePossible, openProjectTabRoots, projectBinding]); - - /** Empty below two machines, which renders the list exactly as it does today. */ - const draftLaneSelectorMachines = useMemo( - () => laneMachineOptions.length < 2 - ? [] - : laneMachineOptions.map((option) => ({ id: option.id, name: option.name })), - [laneMachineOptions], - ); - - /** - * Every create-lane call is routed by the tab's binding, so creating a lane on - * another machine means binding this tab to that machine's checkout of this - * repo first. A machine we can't prove holds this repo is not switched to. - */ - const switchToLaneMachine = useCallback((machineId: string) => { - const machine = laneMachineOptions.find((candidate) => candidate.id === machineId); - if (!machine || machine.isBound) return; - preserveDraftAcrossMachineSwitchRef.current = true; - const switching = machine.targetId - ? machine.project?.projectId - ? switchRemoteProject(machine.targetId, machine.project.projectId).then(() => {}) - : null - : machine.project?.rootPath - ? switchProjectToPath(machine.project.rootPath) - : null; - if (!switching) { - preserveDraftAcrossMachineSwitchRef.current = false; - setError(`Open this repository on ${machine.name} first, then create the lane there.`); - return; - } - setError(null); - void switching - .then(() => { - // A different machine can expose the same absolute root path. In that - // case the composer storage scope never changes, so there is no - // hydration effect to consume this one-shot preservation marker. - if (selectActiveProjectRoot(rootAppStoreApi.getState()) === projectRoot) { - preserveDraftAcrossMachineSwitchRef.current = false; - } - }) - .catch((err: unknown) => { - preserveDraftAcrossMachineSwitchRef.current = false; - setError(err instanceof Error ? err.message : String(err)); - }); - }, [laneMachineOptions, projectRoot, switchProjectToPath, switchRemoteProject]); - - const draftLaneSelectorLanes = useMemo( - () => showDraftLaunchControls && availableLanes - ? [AUTO_CREATE_LANE_OPTION, ...availableLanes] - : (availableLanes ?? []), - [availableLanes, showDraftLaunchControls], - ); - const primaryDraftLane = useMemo(() => ( - availableLanes?.find((candidate) => candidate.laneType === "primary") - ?? availableLanes?.find((candidate) => candidate.name.trim().toLowerCase() === "primary") - ?? null - ), [availableLanes]); - const autoCreateToolsLane = primaryDraftLane ?? availableLanes?.[0] ?? null; - const draftLaneSelectorValue = draftLaunchTargetIsAutoCreate ? AUTO_CREATE_LANE_OPTION_ID : (laneId ?? ""); - const handleDraftLaneSelectionChange = useCallback((nextLaneId: string) => { - if (isAutoCreateLaneOptionId(nextLaneId)) { - // Stored bare: after a machine switch the chosen machine is the bound one, - // and the bare id is always "auto-create on the machine this tab is on". - setDraftLaunchTargetId(AUTO_CREATE_LANE_OPTION_ID); - const machineId = machineIdFromAutoCreateLaneOptionId(nextLaneId); - if (machineId) { - switchToLaneMachine(machineId); - return; - } - if (autoCreateToolsLane) onLaneChange?.(autoCreateToolsLane.id); - return; - } - setDraftLaunchTargetId(null); - onLaneChange?.(nextLaneId); - }, [autoCreateToolsLane, onLaneChange, switchToLaneMachine]); + const { + machineOptions: laneMachineOptions, + selectorMachines: draftLaneSelectorMachines, + selectorLanes: draftLaneSelectorLanes, + boundMachineId: boundLaneMachineId, + executionLanes: draftExecutionLanes, + executionBinding: draftExecutionBinding, + selectedMachine: selectedDraftMachine, + selectedLaneIsPrimary: selectedDraftLaneIsPrimary, + machineUnavailable: draftMachineUnavailable, + selectorValue: draftLaneSelectorValue, + handleMachineChange: handleDraftMachineChange, + handleLaneSelectionChange: handleDraftLaneSelectionChange, + } = useDraftMachineRouting({ + enabled: showDraftLaunchControls, + projectBinding, + openProjectTabRoots, + crossMachineLanesByMachineId, + lanes, + availableLanes, + laneId, + initialDraftMachineId, + draftLaunchTargetIsAutoCreate, + onDraftMachineChange, + onLaneChange, + setDraftLaunchTargetId, + setError, + }); + draftExecutionLanesRef.current = draftExecutionLanes; + draftExecutionBindingRef.current = draftExecutionBinding; + draftMachineUnavailableRef.current = draftMachineUnavailable; useEffect(() => { if (!showDraftLaunchControls && draftLaunchTargetId) { @@ -11744,6 +11640,19 @@ export function AgentChatPane({ /> ) : null; + const composerMachineBinding = selectedSessionId + ? (chatRuntimePin ?? projectBinding) + : (draftExecutionBinding ?? projectBinding); + const composerMachineId = selectedSessionId + ? (composerMachineBinding?.kind === "remote" ? composerMachineBinding.targetId : "this-mac") + : (selectedDraftMachine?.id ?? boundLaneMachineId); + const composerMachineName = selectedSessionId + ? (composerMachineBinding?.kind === "remote" ? composerMachineBinding.runtimeName : "This Mac") + : (selectedDraftMachine?.name ?? "This Mac"); + const composerMachineSelectable = Boolean( + showDraftLaunchControls + && (draftLaunchTargetIsAutoCreate || selectedDraftLaneIsPrimary), + ); const composerElement = ( ({ + id: machine.id, + name: machine.name, + }))} + onMachineChange={handleDraftMachineChange} layoutVariant={layoutVariant} composerMaxHeightPx={composerMaxHeightPx} isActive={isTileActive} diff --git a/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts b/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts new file mode 100644 index 000000000..00632e262 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/useDraftMachineRouting.ts @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { remoteProjectBindingKey } from "../../../shared/projectIdentity"; +import type { + LaneSummary, + OpenProjectBinding, + RecentProjectSummary, + RemoteRuntimeConnectionSnapshot, +} from "../../../shared/types"; +import type { CrossMachineMachineLanes } from "../../state/appStore"; +import { + AUTO_CREATE_LANE_OPTION_ID, + autoCreateLaneOptionId, + isAutoCreateLaneOptionId, + machineIdFromAutoCreateLaneOptionId, + machineLaneFromOptionId, + machineLaneOptionId, + type LaneComboboxLane, + type LaneComboboxMachine, +} from "../terminals/LaneCombobox"; +import { + canCreateLaneOnMachine, + deriveLaneMachineOptions, + type LaneMachineOption, + type LaneMachineProjectRef, +} from "../lanes/laneMachines"; + +export const AUTO_CREATE_DRAFT_LANE_OPTION = { + id: AUTO_CREATE_LANE_OPTION_ID, + name: "Auto-create lane", + color: null, + branchRef: null, +}; + +export type RoutedDraftLane = LaneComboboxLane & { + laneType?: string | null; + baseRef?: string | null; + worktreePath?: string | null; +}; + +type DraftLaneInput = LaneComboboxLane & { + laneType?: string | null; +}; + +type UseDraftMachineRoutingInput = { + enabled: boolean; + projectBinding: OpenProjectBinding | null; + openProjectTabRoots: readonly string[]; + crossMachineLanesByMachineId: Readonly>; + lanes: readonly LaneSummary[]; + availableLanes?: readonly DraftLaneInput[]; + laneId: string | null; + initialDraftMachineId: string | null; + draftLaunchTargetIsAutoCreate: boolean; + onDraftMachineChange?: (machineId: string | null) => void; + onLaneChange?: (laneId: string) => void; + setDraftLaunchTargetId: (targetId: string | null) => void; + setError: (message: string | null) => void; +}; + +/** + * Owns the draft-only machine and lane routing state. + * + * Selecting a machine here never rebinds the project tab. The returned binding + * pins only the launch that consumes it, which lets a MacBook-bound Work tab + * create a chat or lane on a connected Studio without moving Lanes/PRs/Files. + */ +export function useDraftMachineRouting({ + enabled, + projectBinding, + openProjectTabRoots, + crossMachineLanesByMachineId, + lanes, + availableLanes, + laneId, + initialDraftMachineId, + draftLaunchTargetIsAutoCreate, + onDraftMachineChange, + onLaneChange, + setDraftLaunchTargetId, + setError, +}: UseDraftMachineRoutingInput) { + const [connectionSnapshot, setConnectionSnapshot] = + useState(null); + const [knownLocalProjects, setKnownLocalProjects] = useState([]); + + useEffect(() => { + if (!enabled) return; + const remoteRuntime = window.ade?.remoteRuntime; + if (!remoteRuntime?.getConnectionSnapshot) return; + let cancelled = false; + const apply = (snapshot: RemoteRuntimeConnectionSnapshot) => { + if (cancelled) return; + setConnectionSnapshot((current) => + current && current.updatedAt > snapshot.updatedAt ? current : snapshot, + ); + }; + void remoteRuntime.getConnectionSnapshot().then(apply).catch(() => {}); + const unsubscribe = remoteRuntime.onConnectionSnapshotChanged?.(apply) ?? (() => {}); + return () => { + cancelled = true; + unsubscribe(); + }; + }, [enabled]); + + useEffect(() => { + if (!enabled) return; + const projectApi = window.ade?.project; + if (!projectApi?.listRecent) return; + let cancelled = false; + void projectApi.listRecent() + .then((projects) => { + if (!cancelled) { + setKnownLocalProjects(projects.filter( + (candidate) => candidate.kind !== "remote" && candidate.exists !== false, + )); + } + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [enabled]); + + const machineOptions = useMemo(() => { + if (!enabled) return []; + const boundProject: LaneMachineProjectRef | null = projectBinding + ? { + matchedBy: "origin", + projectId: projectBinding.kind === "remote" ? projectBinding.projectId : null, + rootPath: projectBinding.rootPath, + displayName: projectBinding.displayName, + } + : null; + const boundTargetId = projectBinding?.kind === "remote" ? projectBinding.targetId : null; + const boundConnection = boundTargetId + ? connectionSnapshot?.connections.find( + (candidate) => candidate.target.id === boundTargetId, + ) + : null; + const repoOriginUrl = projectBinding?.gitOriginUrl + ?? (projectBinding?.kind === "remote" + ? boundConnection?.projects.find( + (candidate) => candidate.projectId === projectBinding.projectId, + )?.gitOriginUrl + : knownLocalProjects.find( + (candidate) => candidate.rootPath === projectBinding?.rootPath, + )?.gitOriginUrl) + ?? null; + const options = deriveLaneMachineOptions({ + connections: connectionSnapshot?.connections ?? [], + boundTargetId, + boundProject, + repoOriginUrl, + repoDisplayName: boundProject?.displayName ?? null, + localProjectRoots: openProjectTabRoots, + localProjects: knownLocalProjects, + }).filter(canCreateLaneOnMachine); + return [ + ...options.filter((option) => option.isBound), + ...options.filter((option) => !option.isBound), + ]; + }, [ + connectionSnapshot, + enabled, + knownLocalProjects, + openProjectTabRoots, + projectBinding, + ]); + + const selectorMachines = useMemo( + () => machineOptions.length < 2 + ? [] + : machineOptions.map((option) => ({ id: option.id, name: option.name })), + [machineOptions], + ); + + const boundMachineId = projectBinding?.kind === "remote" + ? projectBinding.targetId + : "this-mac"; + const lanesByMachineId = useMemo(() => { + const byMachine = new Map(); + byMachine.set( + boundMachineId, + (availableLanes ?? lanes).map((lane) => ({ + ...lane, + machineId: boundMachineId, + })), + ); + for (const machine of Object.values(crossMachineLanesByMachineId)) { + if (!machineOptions.some((option) => option.id === machine.machineId)) continue; + byMachine.set( + machine.machineId, + machine.lanes.map((lane) => ({ ...lane, machineId: machine.machineId })), + ); + } + return byMachine; + }, [ + availableLanes, + boundMachineId, + crossMachineLanesByMachineId, + lanes, + machineOptions, + ]); + + const [machineId, setMachineId] = useState( + () => initialDraftMachineId?.trim() || boundMachineId, + ); + const chooseMachine = useCallback((nextMachineId: string) => { + setMachineId(nextMachineId); + onDraftMachineChange?.(nextMachineId === boundMachineId ? null : nextMachineId); + }, [boundMachineId, onDraftMachineChange]); + + useEffect(() => { + const requestedMachineId = initialDraftMachineId?.trim(); + if ( + requestedMachineId + && requestedMachineId !== machineId + && machineOptions.some((option) => option.id === requestedMachineId) + ) { + setMachineId(requestedMachineId); + } + }, [initialDraftMachineId, machineId, machineOptions]); + + useEffect(() => { + if (machineOptions.some((option) => option.id === machineId)) return; + // Preserve a persisted foreign choice while the asynchronous catalog is + // still loading. If it remains unavailable, the launch path reports that + // exact machine instead of silently falling back to the tab binding. + if (initialDraftMachineId?.trim() === machineId) return; + chooseMachine( + machineOptions.find((option) => option.isBound)?.id + ?? machineOptions[0]?.id + ?? boundMachineId, + ); + }, [boundMachineId, chooseMachine, initialDraftMachineId, machineId, machineOptions]); + + const selectorLanes = useMemo(() => { + if (!enabled) return (availableLanes ?? lanes) as RoutedDraftLane[]; + const routed = machineOptions.flatMap( + (machine) => lanesByMachineId.get(machine.id) ?? [], + ); + return [AUTO_CREATE_DRAFT_LANE_OPTION, ...routed]; + }, [availableLanes, enabled, lanes, lanesByMachineId, machineOptions]); + + const primaryLaneForMachine = useCallback((candidateMachineId: string) => { + const machineLanes = lanesByMachineId.get(candidateMachineId) ?? []; + return machineLanes.find((candidate) => candidate.laneType === "primary") + ?? machineLanes.find((candidate) => candidate.name.trim().toLowerCase() === "primary") + ?? machineLanes[0] + ?? null; + }, [lanesByMachineId]); + + const executionLanes = lanesByMachineId.get(machineId) ?? []; + const selectedLane = executionLanes.find((candidate) => candidate.id === laneId) ?? null; + const selectedLaneIsPrimary = selectedLane?.laneType === "primary" + || selectedLane?.name.trim().toLowerCase() === "primary"; + const selectedMachine = machineOptions.find((candidate) => candidate.id === machineId) ?? null; + const machineUnavailable = Boolean( + enabled && machineId !== boundMachineId && !selectedMachine, + ); + + const executionBinding = useMemo(() => { + if (!selectedMachine) { + return machineOptions.length === 0 ? projectBinding : null; + } + if (selectedMachine.isBound) return projectBinding; + const unionBinding = crossMachineLanesByMachineId[selectedMachine.id]?.binding ?? null; + if (unionBinding) return unionBinding; + if (!selectedMachine.project) return null; + if (!selectedMachine.targetId) { + return { + kind: "local", + key: `local:${selectedMachine.project.rootPath}`, + rootPath: selectedMachine.project.rootPath, + displayName: selectedMachine.project.displayName, + gitOriginUrl: projectBinding?.gitOriginUrl ?? null, + }; + } + const connection = connectionSnapshot?.connections.find( + (candidate) => candidate.target.id === selectedMachine.targetId, + ); + const remoteProject = connection?.projects.find( + (candidate) => candidate.projectId === selectedMachine.project?.projectId, + ); + if (!remoteProject || !connection) return null; + return { + kind: "remote", + key: remoteProjectBindingKey(connection.target.id, remoteProject.projectId), + targetId: connection.target.id, + runtimeName: connection.target.name, + hostname: connection.target.hostname, + projectId: remoteProject.projectId, + rootPath: remoteProject.rootPath, + displayName: remoteProject.displayName, + gitOriginUrl: remoteProject.gitOriginUrl, + iconDataUrl: remoteProject.icon?.dataUrl ?? null, + }; + }, [ + connectionSnapshot, + crossMachineLanesByMachineId, + machineOptions.length, + projectBinding, + selectedMachine, + ]); + + const selectorValue = draftLaunchTargetIsAutoCreate + ? autoCreateLaneOptionId( + selectorMachines.length < 2 || machineId === selectorMachines[0]?.id + ? null + : machineId, + ) + : ( + laneId && selectorMachines.length > 0 + ? machineLaneOptionId(machineId, laneId) + : (laneId ?? "") + ); + + const handleMachineChange = useCallback((nextMachineId: string) => { + const nextMachine = machineOptions.find((candidate) => candidate.id === nextMachineId); + if (!nextMachine) return; + const primary = primaryLaneForMachine(nextMachineId); + if (!primary) { + setError(`${nextMachine.name} has no primary lane for this repository.`); + return; + } + setError(null); + chooseMachine(nextMachineId); + onLaneChange?.(primary.id); + }, [chooseMachine, machineOptions, onLaneChange, primaryLaneForMachine, setError]); + + const handleLaneSelectionChange = useCallback((nextLaneId: string) => { + if (isAutoCreateLaneOptionId(nextLaneId)) { + setDraftLaunchTargetId(AUTO_CREATE_LANE_OPTION_ID); + const nextMachineId = machineIdFromAutoCreateLaneOptionId(nextLaneId) + ?? machineOptions[0]?.id + ?? boundMachineId; + chooseMachine(nextMachineId); + const primary = primaryLaneForMachine(nextMachineId); + if (primary) onLaneChange?.(primary.id); + return; + } + const routed = machineLaneFromOptionId(nextLaneId); + const actualLaneId = routed?.laneId ?? nextLaneId; + const selectedMachineId = routed?.machineId ?? machineId; + const nextLane = lanesByMachineId.get(selectedMachineId)?.find( + (candidate) => candidate.id === actualLaneId, + ); + if (!nextLane) return; + chooseMachine(selectedMachineId); + setDraftLaunchTargetId(null); + onLaneChange?.(actualLaneId); + }, [ + boundMachineId, + chooseMachine, + lanesByMachineId, + machineId, + machineOptions, + onLaneChange, + primaryLaneForMachine, + setDraftLaunchTargetId, + ]); + + return { + machineOptions, + selectorMachines, + selectorLanes, + boundMachineId, + executionLanes, + executionBinding, + selectedMachine, + selectedLaneIsPrimary, + machineUnavailable, + selectorValue, + handleMachineChange, + handleLaneSelectionChange, + }; +} diff --git a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx index 0057537b6..fd9cd9f39 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx @@ -23,7 +23,7 @@ async function fetchRepoForCopy(): Promise<{ owner: string; name: string } | nul } } -const menuItemStyle: React.CSSProperties = { +export const menuItemStyle: React.CSSProperties = { display: "block", width: "100%", padding: "7px 14px", @@ -47,22 +47,28 @@ const menuHeaderStyle: React.CSSProperties = { color: COLORS.textDim, }; -function HoverButton({ +export function HoverButton({ style, children, onClick, dataTour, + disabled = false, }: { style: React.CSSProperties; children: React.ReactNode; onClick: () => void; dataTour?: string; + disabled?: boolean; }) { return ( @@ -232,7 +263,7 @@ export function SessionContextMenu({ @@ -246,7 +277,7 @@ export function SessionContextMenu({ @@ -305,7 +336,7 @@ export function SessionContextMenu({ @@ -324,7 +355,7 @@ export function SessionContextMenu({ @@ -334,7 +365,7 @@ export function SessionContextMenu({ @@ -344,7 +375,7 @@ export function SessionContextMenu({ diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx index dd667c360..6b6f1cf91 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -823,6 +823,7 @@ describe("SessionListPane", () => { expect(screen.getByText("Elsewhere Lane")).toBeTruthy(); expect(screen.getByText("Chat on the other machine")).toBeTruthy(); + expect(document.querySelector('[data-session-id="session-elsewhere"]')).toBeTruthy(); // One marker, on the foreign lane only — the local lanes stay untouched. const markers = document.querySelectorAll("[data-machine-marker-mode]"); @@ -837,9 +838,47 @@ describe("SessionListPane", () => { "session-elsewhere", expect.anything(), ["session-elsewhere"], + expect.objectContaining({ + targetId: "target-studio", + projectId: "project-a", + }), + ); + }); + + it("routes a foreign card's context menu through its owning binding", () => { + seedForeignMachine(); + const onContextMenu = vi.fn(); + renderPane({ onContextMenu }); + + const card = document.querySelector( + '[data-session-id="session-elsewhere"]', + )!; + fireEvent.contextMenu(card); + + expect(onContextMenu).toHaveBeenCalledWith( + expect.objectContaining({ id: "session-elsewhere" }), + expect.anything(), + expect.objectContaining({ + targetId: "target-studio", + projectId: "project-a", + }), + "Mac Studio (12)", ); }); + it("offers lane actions from a foreign lane header", () => { + seedForeignMachine(); + renderPane(); + + const header = screen.getByText("Elsewhere Lane").closest( + ".ade-lane-group-header", + )!; + fireEvent.contextMenu(header); + + expect(screen.getByRole("menuitem", { name: "Start chat in lane" })).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Open in Lanes" })).toBeTruthy(); + }); + it("routes foreign shell rows through the owning-runtime selector", () => { seedForeignMachine({ sessions: [ @@ -869,12 +908,14 @@ describe("SessionListPane", () => { ); }); - it("shows a bare glyph for one online foreign machine and the name when it drops", () => { + it("shows a hover label for one online foreign machine and the name when it drops", async () => { seedForeignMachine(); const view = renderPane(); - expect(document.querySelector("[data-machine-marker-mode]")?.getAttribute("data-machine-marker-mode")) - .toBe("glyph"); + const marker = document.querySelector("[data-machine-marker-mode]")!; + expect(marker.getAttribute("data-machine-marker-mode")).toBe("glyph"); expect(screen.queryByText("Mac Studio (12)")).toBeNull(); + fireEvent.mouseEnter(marker.parentElement!); + expect((await screen.findByRole("tooltip")).textContent).toContain("Mac Studio (12)"); view.unmount(); seedForeignMachine({ online: false }); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 5ff55b3f7..80f732ef5 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { CaretDown, CaretRight, CircleNotch, Desktop, Funnel, MagnifyingGlass, Moon, Plus, Square, Terminal, Trash, X } from "@phosphor-icons/react"; +import { CaretDown, CaretRight, CircleNotch, Desktop, DesktopTower, Funnel, MagnifyingGlass, Moon, Plus, Square, Terminal, Trash, X } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; import type { LaneSummary, OpenProjectBinding, PrSummary, TerminalSessionSummary } from "../../../shared/types"; @@ -170,7 +170,7 @@ function StickyGroupHeader({ count: number; collapsed: boolean; onToggleCollapsed: () => void; - onContextMenu?: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; accentColor?: string | null; children: React.ReactNode; /** @@ -229,6 +229,7 @@ function StickyGroupHeader({ }} data-section-id={sectionId} aria-busy={busyLabel ? "true" : undefined} + onContextMenu={onContextMenu} > + aria-label={`${marker.machineName}${marker.online ? "" : ", offline"}`} + data-machine-id={marker.machineId} + data-machine-marker-mode={marker.mode} + > + + {marker.mode === "name" ? {marker.machineName} : null} + + ); } @@ -554,7 +522,12 @@ export const SessionListPane = React.memo(function SessionListPane({ draftKind: WorkDraftKind; showingDraft: boolean; onShowDraftKind: (kind: WorkDraftKind) => void; - onSelectSession: (id: string, event: React.MouseEvent, visibleSessionIds: string[]) => void; + onSelectSession: ( + id: string, + event: React.MouseEvent, + visibleSessionIds: string[], + binding?: OpenProjectBinding | null, + ) => void; onSelectForeignRuntimeSession?: ( session: TerminalSessionSummary, binding: OpenProjectBinding, @@ -565,7 +538,12 @@ export const SessionListPane = React.memo(function SessionListPane({ onBulkClose?: () => void; onBulkDelete?: () => void; onBulkStopAndDelete?: () => void; - onContextMenu: (session: TerminalSessionSummary, e: React.MouseEvent) => void; + onContextMenu: ( + session: TerminalSessionSummary, + e: React.MouseEvent, + binding?: OpenProjectBinding | null, + machineName?: string | null, + ) => void; sessionListOrganization: WorkSessionListOrganization; setSessionListOrganization: (v: WorkSessionListOrganization) => void; workCollapsedLaneIds: string[]; @@ -586,7 +564,11 @@ export const SessionListPane = React.memo(function SessionListPane({ const [createLaneOpen, setCreateLaneOpen] = useState(false); const [settleUndo, setSettleUndo] = useState<{ ids: string[]; count: number } | null>(null); const orderedLanes = useMemo(() => sortLanesForTabs(lanes), [lanes]); - const { trigger: triggerLaneContextMenu, menu: laneContextMenuPortal } = useWorkLaneContextMenu(); + const { + trigger: triggerLaneContextMenu, + triggerForeign: triggerForeignLaneContextMenu, + menu: laneContextMenuPortal, + } = useWorkLaneContextMenu(); const isByLane = sessionListOrganization === "by-lane"; const isByTime = sessionListOrganization === "by-time"; @@ -1272,27 +1254,53 @@ export const SessionListPane = React.memo(function SessionListPane({ // read-only. A wifi blip must never reflow the sidebar. dimmed={!row.online} onToggleCollapsed={() => toggleWorkLaneCollapsed(compositeLaneId)} + onContextMenu={row.binding + ? (event) => triggerForeignLaneContextMenu( + row.lane, + row.binding!, + row.machineName, + row.online, + event, + ) + : undefined} > {row.sessions.map((session) => ( - - onSelectSession(session.id, event, row.sessions.map((candidate) => candidate.id)) - : row.binding && onSelectForeignRuntimeSession - ? (event) => - onSelectForeignRuntimeSession( - session, - row.binding!, - event, - row.sessions.map((candidate) => candidate.id), - ) - : null + lane={row.lane} + isSelected={selectedSessionId === session.id} + isMultiSelected={selectedSessionIds?.has(session.id) ?? false} + onSelect={(_id, event) => { + if (isChatToolType(session.toolType)) { + onSelectSession( + session.id, + event, + row.sessions.map((candidate) => candidate.id), + row.binding, + ); + } else if (row.binding && onSelectForeignRuntimeSession) { + onSelectForeignRuntimeSession( + session, + row.binding, + event, + row.sessions.map((candidate) => candidate.id), + ); + } + }} + onContextMenu={(event) => { + event.preventDefault(); + onContextMenu(session, event, row.binding, row.machineName); + }} + runtimePin={row.binding} + deltaEnabled={false} + disabledReason={!row.online + ? `${row.machineName} is offline` + : !row.binding + ? `${row.machineName} is unavailable` + : null } + disabledBusy={false} /> ))} diff --git a/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx b/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx index f9495dd1e..14bfa741b 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionSnoozeControl.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Moon } from "@phosphor-icons/react"; -import type { TerminalSessionSummary } from "../../../shared/types"; +import type { OpenProjectBinding, TerminalSessionSummary } from "../../../shared/types"; import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition"; import { SNOOZE_DURATION_OPTIONS, type SnoozeDurationKey } from "../../lib/sessionSnooze"; import { snoozeSessionForDuration, wakeSessionNow } from "./sessionLifecycleActions"; @@ -21,11 +21,13 @@ export function SessionSnoozeControl({ session, snoozed, compact = false, + runtimePin = null, }: { session: Pick; /** Already snoozed rows offer "Wake now" instead of a duration menu. */ snoozed: boolean; compact?: boolean; + runtimePin?: OpenProjectBinding | null; }) { const buttonRef = useRef(null); const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); @@ -43,9 +45,9 @@ export function SessionSnoozeControl({ const choose = useCallback( (key: SnoozeDurationKey) => { close(); - void snoozeSessionForDuration(session, key); + void snoozeSessionForDuration(session, key, Date.now(), runtimePin); }, - [close, session], + [close, runtimePin, session], ); const label = snoozed ? "Wake session now" : "Snooze session"; @@ -71,7 +73,7 @@ export function SessionSnoozeControl({ if (snoozed) { event.preventDefault(); event.stopPropagation(); - void wakeSessionNow(session); + void wakeSessionNow(session, runtimePin); return; } openMenu(event); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index c09d32faf..d33bd8be2 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import React from "react"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatSession, @@ -186,7 +186,12 @@ type MockSessionListPaneProps = { ) => void; onBulkDelete?: () => void; onBulkStopAndDelete?: () => void; - onContextMenu: (session: TerminalSessionSummary, event: React.MouseEvent) => void; + onContextMenu: ( + session: TerminalSessionSummary, + event: React.MouseEvent, + binding?: OpenProjectBinding | null, + machineName?: string | null, + ) => void; }; const sessionListPaneProps = vi.hoisted(() => ({ @@ -305,18 +310,37 @@ vi.mock("./WorkSidebar", () => ({ vi.mock("./SessionContextMenu", () => ({ SessionContextMenu: (props: { - menu: { session: TerminalSessionSummary } | null; - onStopAndDelete: (session: TerminalSessionSummary) => void; - onSettle: (session: TerminalSessionSummary) => void; + menu: { + session: TerminalSessionSummary; + binding?: OpenProjectBinding | null; + } | null; + onStopAndDelete: ( + session: TerminalSessionSummary, + binding?: OpenProjectBinding | null, + ) => void; + onSettle: ( + session: TerminalSessionSummary, + binding?: OpenProjectBinding | null, + ) => void; + onClose: () => void; }) => { if (!props.menu) return null; const session = props.menu.session; return ( <> - - @@ -819,6 +843,60 @@ describe("TerminalsPage chat session activation", () => { expect(agentChatDelete).not.toHaveBeenCalled(); }); + it("keeps a foreign runtime pin after the context menu closes for confirmation", async () => { + const runningCli = workMocks.makeTerminalSession("cli-studio", "lane-primary", "codex"); + const sessionDelete = vi.fn().mockResolvedValue(undefined); + const binding: OpenProjectBinding = { + kind: "remote", + key: "remote:studio:ade", + targetId: "studio", + projectId: "ade", + rootPath: "/Users/studio/ADE", + displayName: "ADE", + runtimeName: "Studio", + hostname: "studio.local", + }; + Object.defineProperty(window, "ade", { + configurable: true, + value: { + agentChat: { delete: vi.fn() }, + builtInBrowser: { onEvent: vi.fn(() => vi.fn()) }, + sessions: { delete: sessionDelete }, + }, + }); + workMocks.currentWork = { + ...workMocks.baseWork, + sessions: [runningCli], + visibleSessions: [runningCli], + runningFiltered: [runningCli], + runningSessions: [runningCli], + filtered: [runningCli], + sessionsGroupedByLane: new Map([["lane-primary", [runningCli]]]), + closingPtyIds: new Set(), + }; + + render(); + act(() => { + sessionListPaneProps.latest?.onContextMenu( + runningCli, + { clientX: 10, clientY: 20 } as React.MouseEvent, + binding, + "Studio", + ); + }); + fireEvent.click(await screen.findByRole("button", { + name: "context stop and delete cli-studio", + })); + fireEvent.click(await screen.findByRole("button", { name: "Stop & delete" })); + + await waitFor(() => { + expect(sessionDelete).toHaveBeenCalledWith( + { sessionId: "cli-studio" }, + binding, + ); + }); + }); + it("delegates pending-input dismissal and settlement to one backend operation", async () => { const pendingChat = workMocks.makeTerminalSession( "chat-pending", diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 099e325d2..0a258081d 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -201,7 +201,12 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }, [selectableSessions]); const handleSelectSession = useCallback( - (id: string, event?: React.MouseEvent, visibleSessionIds?: string[]) => { + ( + id: string, + event?: React.MouseEvent, + visibleSessionIds?: string[], + binding?: OpenProjectBinding | null, + ) => { const useRange = event?.shiftKey === true; const useToggle = event?.metaKey === true || event?.ctrlKey === true; const orderedIds = visibleSessionIds?.length ? visibleSessionIds : selectableSessions.map((session) => session.id); @@ -239,7 +244,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { // Opening the row IS the acknowledgement — the "woke" marker only exists // to explain an unexpected return, so it goes as soon as it is seen. const opened = selectableSessions.find((session) => session.id === id); - if (opened?.wokeAt) clearSessionWokeMarker(id); + if (opened?.wokeAt || binding) clearSessionWokeMarker(id, binding); }, [selectableSessions, selectionAnchorId, work], ); @@ -316,12 +321,22 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { ); const handleContextMenu = useCallback( - (session: TerminalSessionSummary, e: React.MouseEvent) => { - setContextMenu({ session, x: e.clientX, y: e.clientY }); + ( + session: TerminalSessionSummary, + e: React.MouseEvent, + binding?: OpenProjectBinding | null, + machineName?: string | null, + ) => { + setContextMenu({ + session, + x: e.clientX, + y: e.clientY, + ...(binding ? { binding } : {}), + ...(machineName ? { machineName } : {}), + }); }, [], ); - const handleOpenChatSession = useCallback( (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => { // Invalidate all cache entries so other views (e.g. Lanes tab) pick up @@ -367,17 +382,38 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }, [work]); const handleGoToLane = useCallback( - (session: TerminalSessionSummary) => { - work.selectLane(session.laneId); - work.focusSession(session.id); - const params = new URLSearchParams({ - laneId: session.laneId, - focus: "single", - sessionId: session.id, + ( + session: TerminalSessionSummary, + binding?: OpenProjectBinding | null, + ) => { + const open = () => { + work.selectLane(session.laneId); + work.focusSession(session.id); + const params = new URLSearchParams({ + laneId: session.laneId, + focus: "single", + sessionId: session.id, + }); + work.navigate(`/lanes?${params.toString()}`); + }; + if (!binding) { + open(); + return; + } + const switching = binding.kind === "remote" + ? switchRemoteProject(binding.targetId, binding.projectId) + : switchProjectToPath(binding.rootPath); + void switching.then(open).catch((error: unknown) => { + setSessionActionError( + error instanceof Error ? error.message : String(error), + ); }); - work.navigate(`/lanes?${params.toString()}`); }, - [work], + [ + switchProjectToPath, + switchRemoteProject, + work, + ], ); const handleGoToLaneById = useCallback( @@ -389,7 +425,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { ); const handleDeleteChat = useCallback( - (session: TerminalSessionSummary) => { + ( + session: TerminalSessionSummary, + runtimePin?: OpenProjectBinding | null, + ) => { const label = (session.goal ?? session.title).trim() || "this chat"; const confirmed = window.confirm( `Delete "${label}"?\n\nThis permanently removes the saved chat history from ADE.`, @@ -398,7 +437,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { setSessionActionError(null); setDeletingSessionId(session.id); - void window.ade.agentChat.delete({ sessionId: session.id }) + const deletion = runtimePin + ? window.ade.agentChat.delete({ sessionId: session.id }, runtimePin) + : window.ade.agentChat.delete({ sessionId: session.id }); + void deletion .then(async () => { invalidateSessionListCache(); work.removeSessionFromList(session.id); @@ -425,7 +467,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { ); const handleDeleteSession = useCallback( - (session: TerminalSessionSummary) => { + ( + session: TerminalSessionSummary, + runtimePin?: OpenProjectBinding | null, + ) => { const label = (session.goal ?? session.title).trim() || "this session"; const confirmed = window.confirm( `Delete "${label}"?\n\nThis permanently removes the saved terminal session from ADE.`, @@ -434,7 +479,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { setSessionActionError(null); setDeletingSessionId(session.id); - void window.ade.sessions.delete({ sessionId: session.id }) + const deletion = runtimePin + ? window.ade.sessions.delete({ sessionId: session.id }, runtimePin) + : window.ade.sessions.delete({ sessionId: session.id }); + void deletion .then(async () => { invalidateSessionListCache(); work.removeSessionFromList(session.id); @@ -458,15 +506,18 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { [work], ); - const handleSettleSession = useCallback((session: TerminalSessionSummary) => { + const handleSettleSession = useCallback(( + session: TerminalSessionSummary, + runtimePin?: OpenProjectBinding | null, + ) => { setSessionActionError(null); void (async () => { try { const dismissPendingInput = sessionNeedsYou(canonicalInputFromSummary(session)); - await window.ade.sessions.settle( - session.id, - dismissPendingInput ? { dismissPendingInput: true } : undefined, - ); + const opts = dismissPendingInput ? { dismissPendingInput: true } : undefined; + await (runtimePin + ? window.ade.sessions.settle(session.id, opts, runtimePin) + : window.ade.sessions.settle(session.id, opts)); } catch (err) { const message = err instanceof Error ? err.message : String(err); setSessionActionError(`Settle failed: ${message}`); @@ -476,7 +527,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { }, []); const handleStopAndDeleteSession = useCallback( - (session: TerminalSessionSummary) => { + ( + session: TerminalSessionSummary, + runtimePin?: OpenProjectBinding | null, + ) => { void (async () => { const label = (session.goal ?? session.title).trim() || "this session"; const confirmed = await stopAndDeleteConfirm.confirmAsync({ @@ -492,7 +546,9 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { // The session-delete service stops a running runtime before removing the // record, so a single call covers both steps for CLI and shell sessions. try { - await window.ade.sessions.delete({ sessionId: session.id }); + await (runtimePin + ? window.ade.sessions.delete({ sessionId: session.id }, runtimePin) + : window.ade.sessions.delete({ sessionId: session.id })); invalidateSessionListCache(); work.removeSessionFromList(session.id); work.closeTab(session.id); @@ -1005,6 +1061,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { draftKind={work.draftKind} orchestratorEnabled={work.orchestratorEnabled} draftLaneId={work.draftLaneId} + draftMachineId={work.draftMachineId} draftContextTargetId={draftContextTargetId} onSelectItem={work.setActiveItemId} onCloseItem={work.closeTab} @@ -1013,6 +1070,7 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { onImportedSession={work.adoptImportedSession} onOpenExistingImportedSession={work.openExistingImportedSession} onDraftLaneChange={work.setDraftLaneId} + onDraftMachineChange={work.setDraftMachineId} onShowDraftKind={work.showDraftKind} closingPtyIds={work.closingPtyIds} onContextMenu={handleContextMenu} @@ -1048,8 +1106,10 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { work.draftKind, work.orchestratorEnabled, work.draftLaneId, + work.draftMachineId, draftContextTargetId, work.setDraftLaneId, + work.setDraftMachineId, work.showDraftKind, work.setActiveItemId, work.closeTab, @@ -1264,7 +1324,12 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { setContextMenu(null)} - onStopRuntime={({ ptyId, sessionId }) => work.stopRuntime(ptyId, sessionId).catch(() => {})} + onStopRuntime={({ ptyId, sessionId }, runtimePin) => { + const stop = runtimePin + ? window.ade.pty.dispose({ ptyId, sessionId }, runtimePin) + : work.stopRuntime(ptyId, sessionId); + void stop.catch(() => {}); + }} onStopAndDelete={handleStopAndDeleteSession} onDeleteChat={handleDeleteChat} onDeleteSession={handleDeleteSession} @@ -1307,9 +1372,12 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { pinnedSessionIds={work.pinnedSessionIds} gridSessionIds={gridSessionIds} onRemoveFromGrid={(session) => handleRemoveSessionFromGrid(session.id)} - onSetChatTag={(session, tag) => { + onSetChatTag={(session, tag, runtimePin) => { setSessionActionError(null); - window.ade.agentChat.updateSession({ sessionId: session.id, tag }) + const update = runtimePin + ? window.ade.agentChat.updateSession({ sessionId: session.id, tag }, runtimePin) + : window.ade.agentChat.updateSession({ sessionId: session.id, tag }); + update .then(() => { invalidateSessionListCache(); work.refresh({ showLoading: false, force: true }).catch((refreshErr: unknown) => { @@ -1323,11 +1391,28 @@ export function TerminalsPage({ active = true }: { active?: boolean }) { window.setTimeout(() => setSessionActionError(null), 6000); }); }} - onRename={(session, newTitle) => { + onRename={(session, newTitle, runtimePin) => { setSessionActionError(null); - const renamePromise = isChatToolType(session.toolType) - ? window.ade.agentChat.updateSession({ sessionId: session.id, title: newTitle, manuallyNamed: true }) - : window.ade.sessions.updateMeta({ sessionId: session.id, title: newTitle, manuallyNamed: true }); + let renamePromise: Promise; + if (isChatToolType(session.toolType)) { + renamePromise = runtimePin + ? window.ade.agentChat.updateSession( + { sessionId: session.id, title: newTitle, manuallyNamed: true }, + runtimePin, + ) + : window.ade.agentChat.updateSession( + { sessionId: session.id, title: newTitle, manuallyNamed: true }, + ); + } else { + renamePromise = runtimePin + ? window.ade.sessions.updateMeta( + { sessionId: session.id, title: newTitle, manuallyNamed: true }, + runtimePin, + ) + : window.ade.sessions.updateMeta( + { sessionId: session.id, title: newTitle, manuallyNamed: true }, + ); + } renamePromise .then(() => { invalidateSessionListCache(); diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx index fa7cbe011..54b060e6d 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.test.tsx @@ -6,32 +6,43 @@ import type { AppState } from "../../state/appStore"; import { WorkStartSurface } from "./WorkStartSurface"; const selectLane = vi.fn(); +const crossMachineState = vi.hoisted(() => ({ + lanesByMachine: {} as AppState["crossMachineLanesByMachineId"], +})); const agentChatPaneProps = vi.hoisted(() => ({ latest: null as null | { laneId: string | null; + laneLabel?: string; workDraftKind?: "chat" | "cli"; orchestratorEnabled?: boolean; draftContextTargetId?: string | null; - onOpenShellSession?: (laneId: string) => void | Promise; + onOpenShellSession?: (laneId: string, pin?: unknown) => void | Promise; onLaunchCliSession?: unknown; suppressDraftLaunchNavigation?: boolean; + initialDraftMachineId?: string | null; }, })); vi.mock("../../state/appStore", () => ({ useAppStore: (selector: (state: AppState) => T): T => selector({ selectedLaneId: null, selectLane, lanesLoading: false } as unknown as AppState), + useRootAppStore: (selector: (state: AppState) => T): T => + selector({ + crossMachineLanesByMachineId: crossMachineState.lanesByMachine, + } as unknown as AppState), })); vi.mock("../chat/AgentChatPane", () => ({ AgentChatPane: (props: { laneId: string | null; + laneLabel?: string; workDraftKind?: "chat" | "cli"; orchestratorEnabled?: boolean; draftContextTargetId?: string | null; - onOpenShellSession?: (laneId: string) => void | Promise; + onOpenShellSession?: (laneId: string, pin?: unknown) => void | Promise; onLaunchCliSession?: unknown; suppressDraftLaunchNavigation?: boolean; + initialDraftMachineId?: string | null; }) => { agentChatPaneProps.latest = props; return ( @@ -49,6 +60,7 @@ describe("WorkStartSurface", () => { cleanup(); vi.clearAllMocks(); agentChatPaneProps.latest = null; + crossMachineState.lanesByMachine = {}; }); it("renders the no-lanes state", () => { @@ -113,4 +125,57 @@ describe("WorkStartSurface", () => { expect(agentChatPaneProps.latest?.draftContextTargetId).toBe("work:draft:lane-local:chat"); expect(agentChatPaneProps.latest?.suppressDraftLaunchNavigation).toBe(true); }); + + it("keeps a foreign draft lane selected instead of falling back locally", async () => { + crossMachineState.lanesByMachine = { + studio: { + machineId: "studio", + machineName: "Mac Studio", + targetId: "studio", + projectId: "project-a", + binding: null, + online: true, + lanes: [{ id: "lane-studio", name: "Studio lane" } as any], + sessions: [], + lastSyncedAtMs: 1, + error: null, + }, + }; + + render( + , + ); + + expect(await screen.findByTestId("agent-chat-pane")).toBeTruthy(); + expect(agentChatPaneProps.latest?.laneId).toBe("lane-studio"); + expect(agentChatPaneProps.latest?.laneLabel).toBe("Studio lane"); + expect(agentChatPaneProps.latest?.initialDraftMachineId).toBe("studio"); + }); + + it("does not overwrite a foreign draft while its machine catalog is still loading", async () => { + const onDraftLaneChange = vi.fn(); + render( + , + ); + + expect(await screen.findByTestId("agent-chat-pane")).toBeTruthy(); + expect(agentChatPaneProps.latest?.laneId).toBe("lane-studio"); + expect(agentChatPaneProps.latest?.laneLabel).toBe("lane-studio"); + expect(onDraftLaneChange).not.toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx index c1d460571..781eb2663 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkStartSurface.tsx @@ -6,7 +6,7 @@ import type { LaneSummary, } from "../../../shared/types"; import type { WorkDraftKind } from "../../state/appStore"; -import { useAppStore } from "../../state/appStore"; +import { useAppStore, useRootAppStore } from "../../state/appStore"; import { AgentChatPane, type AgentChatSessionCreatedOptions } from "../chat/AgentChatPane"; import type { WorkPtyLaunchArgs, WorkPtyLaunchResult } from "./cliLaunch"; import type { ExternalSessionImportResult, ExternalSessionSummary } from "./importSessions/contract"; @@ -15,6 +15,7 @@ type WorkStartSurfaceProps = { draftKind: WorkDraftKind; orchestratorEnabled?: boolean; draftLaneId?: string | null; + draftMachineId?: string | null; draftContextTargetId?: string | null; lanes: LaneSummary[]; onOpenChatSession: (session: AgentChatSession, options?: AgentChatSessionCreatedOptions) => void | Promise; @@ -22,6 +23,7 @@ type WorkStartSurfaceProps = { onImportedSession?: (summary: ExternalSessionSummary, result: ExternalSessionImportResult) => void; onOpenExistingImportedSession?: (ref: { kind: "chat" | "cli"; sessionId: string }) => void; onDraftLaneChange?: (laneId: string) => void; + onDraftMachineChange?: (machineId: string | null) => void; initialLinearIssueContext?: LaneLinearIssue | null; initialLinearIssueContextSource?: "manual" | "lane_link"; initialModelId?: string | null; @@ -33,6 +35,7 @@ export function WorkStartSurface({ draftKind, orchestratorEnabled = false, draftLaneId = null, + draftMachineId = null, draftContextTargetId = null, lanes, onOpenChatSession, @@ -40,6 +43,7 @@ export function WorkStartSurface({ onImportedSession, onOpenExistingImportedSession, onDraftLaneChange, + onDraftMachineChange, initialLinearIssueContext = null, initialLinearIssueContextSource = "lane_link", initialModelId = null, @@ -47,53 +51,108 @@ export function WorkStartSurface({ suppressDraftLaunchNavigation = false, }: WorkStartSurfaceProps) { const globallySelectedLaneId = useAppStore((s) => s.selectedLaneId); + const projectBinding = useAppStore((s) => s.projectBinding); const lanesLoading = useAppStore((s) => s.lanesLoading); const selectLaneGlobal = useAppStore((s) => s.selectLane); + const crossMachineLanesByMachineId = useRootAppStore( + (state) => state.crossMachineLanesByMachineId, + ); + const boundMachineId = projectBinding?.kind === "remote" + ? projectBinding.targetId + : "this-mac"; + const draftMachinePending = Boolean( + draftMachineId + && draftMachineId !== boundMachineId + && !crossMachineLanesByMachineId[draftMachineId], + ); + const laneForMachine = useCallback((machineId: string | null, laneId: string) => { + const resolvedMachineId = machineId ?? boundMachineId; + if (resolvedMachineId === boundMachineId) { + return lanes.find((lane) => lane.id === laneId) ?? null; + } + return crossMachineLanesByMachineId[resolvedMachineId]?.lanes.find( + (lane) => lane.id === laneId, + ) ?? null; + }, [boundMachineId, crossMachineLanesByMachineId, lanes]); + const isKnownLaneId = useCallback( + (laneId: string | null | undefined): laneId is string => + typeof laneId === "string" && Boolean(laneForMachine(draftMachineId, laneId)), + [draftMachineId, laneForMachine], + ); const [selectedLaneId, setSelectedLaneId] = useState(() => { - if (draftLaneId && lanes.some((lane) => lane.id === draftLaneId)) { + if (isKnownLaneId(draftLaneId)) { return draftLaneId; } - if (globallySelectedLaneId && lanes.some((lane) => lane.id === globallySelectedLaneId)) { + if (draftMachinePending && draftLaneId) { + return draftLaneId; + } + if (isKnownLaneId(globallySelectedLaneId)) { return globallySelectedLaneId; } return lanes[0]?.id ?? ""; }); const [launchBusy, setLaunchBusy] = useState(false); const selectedLane = useMemo( - () => lanes.find((lane) => lane.id === selectedLaneId) ?? lanes[0] ?? null, - [lanes, selectedLaneId], + () => laneForMachine(draftMachineId, selectedLaneId) + ?? (draftMachinePending ? null : lanes[0]) + ?? null, + [draftMachineId, draftMachinePending, laneForMachine, lanes, selectedLaneId], ); const setLaneAndSync = useCallback((laneId: string) => { setSelectedLaneId(laneId); onDraftLaneChange?.(laneId); - selectLaneGlobal(laneId); - }, [onDraftLaneChange, selectLaneGlobal]); + if (!draftMachineId || draftMachineId === boundMachineId) { + selectLaneGlobal(laneId); + } + }, [boundMachineId, draftMachineId, onDraftLaneChange, selectLaneGlobal]); useEffect(() => { if (!lanes.length) { setSelectedLaneId(""); return; } - if (draftLaneId && draftLaneId !== selectedLaneId && lanes.some((lane) => lane.id === draftLaneId)) { + if (draftLaneId && draftLaneId !== selectedLaneId && isKnownLaneId(draftLaneId)) { setSelectedLaneId(draftLaneId); - selectLaneGlobal(draftLaneId); + if (!draftMachineId || draftMachineId === boundMachineId) { + selectLaneGlobal(draftLaneId); + } + return; + } + if (draftMachinePending && draftLaneId) { + if (selectedLaneId !== draftLaneId) setSelectedLaneId(draftLaneId); return; } - if (!selectedLaneId || !lanes.some((lane) => lane.id === selectedLaneId)) { + if (!selectedLaneId || !isKnownLaneId(selectedLaneId)) { const fallbackLaneId = - draftLaneId && lanes.some((lane) => lane.id === draftLaneId) + draftLaneId && isKnownLaneId(draftLaneId) ? draftLaneId - : globallySelectedLaneId && lanes.some((lane) => lane.id === globallySelectedLaneId) + : globallySelectedLaneId && isKnownLaneId(globallySelectedLaneId) ? globallySelectedLaneId : lanes[0]!.id; setSelectedLaneId(fallbackLaneId); onDraftLaneChange?.(fallbackLaneId); - selectLaneGlobal(fallbackLaneId); + if (!draftMachineId || draftMachineId === boundMachineId) { + selectLaneGlobal(fallbackLaneId); + } } - }, [draftLaneId, globallySelectedLaneId, lanes, onDraftLaneChange, selectedLaneId, selectLaneGlobal]); + }, [ + draftLaneId, + draftMachineId, + draftMachinePending, + boundMachineId, + globallySelectedLaneId, + isKnownLaneId, + lanes, + onDraftLaneChange, + selectedLaneId, + selectLaneGlobal, + ]); - const launchShell = async (laneId: string) => { + const launchShell = async ( + laneId: string, + pin?: Parameters[0]["pin"], + ) => { if (!laneId || launchBusy) return; setLaunchBusy(true); try { @@ -101,6 +160,7 @@ export function WorkStartSurface({ laneId, profile: "shell", title: "Shell", + ...(pin ? { pin } : {}), }); } finally { setLaunchBusy(false); @@ -152,6 +212,8 @@ export function WorkStartSurface({ onOpenShellSession={launchShell} availableLanes={lanes} onLaneChange={setLaneAndSync} + initialDraftMachineId={draftMachineId} + onDraftMachineChange={onDraftMachineChange} /> diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index a92e27b96..2311b276b 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1092,6 +1092,7 @@ export function WorkViewArea({ draftKind, orchestratorEnabled = false, draftLaneId = null, + draftMachineId = null, draftContextTargetId = null, onContinueCliSession, onResumeCliSession, @@ -1102,6 +1103,7 @@ export function WorkViewArea({ onImportedSession, onOpenExistingImportedSession, onDraftLaneChange, + onDraftMachineChange, onShowDraftKind, closingPtyIds, onContextMenu, @@ -1135,6 +1137,7 @@ export function WorkViewArea({ /** Orthogonal orchestrator flag for the chat draft (forwarded to the composer). */ orchestratorEnabled?: boolean; draftLaneId?: string | null; + draftMachineId?: string | null; draftContextTargetId?: string | null; onSelectItem: (sessionId: string) => void; onCloseItem: (sessionId: string) => void; @@ -1143,6 +1146,7 @@ export function WorkViewArea({ onImportedSession?: (summary: ExternalSessionSummary, result: ExternalSessionImportResult) => void; onOpenExistingImportedSession?: (ref: { kind: "chat" | "cli"; sessionId: string }) => void; onDraftLaneChange?: (laneId: string) => void; + onDraftMachineChange?: (machineId: string | null) => void; onShowDraftKind: (kind: WorkDraftKind) => void; closingPtyIds: Set; onContextMenu?: (session: TerminalSessionSummary, e: React.MouseEvent) => void; @@ -1289,6 +1293,7 @@ export function WorkViewArea({ draftKind={draftKind} orchestratorEnabled={orchestratorEnabled} draftLaneId={draftLaneId} + draftMachineId={draftMachineId} draftContextTargetId={draftContextTargetId} lanes={lanes} onOpenChatSession={onOpenChatSession} @@ -1296,6 +1301,7 @@ export function WorkViewArea({ onImportedSession={onImportedSession} onOpenExistingImportedSession={onOpenExistingImportedSession} onDraftLaneChange={onDraftLaneChange} + onDraftMachineChange={onDraftMachineChange} initialLinearIssueContext={initialLinearIssueContext} initialLinearIssueContextSource={initialLinearIssueContextSource} initialModelId={initialModelId} diff --git a/apps/desktop/src/renderer/components/terminals/laneComboboxMachineGroups.test.tsx b/apps/desktop/src/renderer/components/terminals/laneComboboxMachineGroups.test.tsx index 88adfb2d4..65ce65038 100644 --- a/apps/desktop/src/renderer/components/terminals/laneComboboxMachineGroups.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/laneComboboxMachineGroups.test.tsx @@ -13,6 +13,8 @@ import { autoCreateLaneOptionId, isAutoCreateLaneOptionId, machineIdFromAutoCreateLaneOptionId, + machineLaneFromOptionId, + machineLaneOptionId, } from "./LaneCombobox"; afterEach(cleanup); @@ -56,6 +58,17 @@ describe("auto-create lane option ids", () => { }); }); +describe("machine lane option ids", () => { + it("round-trips machine and lane ids without collisions", () => { + const optionId = machineLaneOptionId("studio:2", "primary/shared"); + expect(machineLaneFromOptionId(optionId)).toEqual({ + machineId: "studio:2", + laneId: "primary/shared", + }); + expect(machineLaneFromOptionId("primary/shared")).toBeNull(); + }); +}); + describe("LaneCombobox machine grouping", () => { it("renders one flat list when there is nothing to group by", () => { render(); @@ -78,16 +91,23 @@ describe("LaneCombobox machine grouping", () => { (node) => node.textContent?.trim() ?? "", ); expect(rows[0]).toBe("This Mac"); - expect(rows[1]).toContain("auth-refresh"); - expect(rows[2]).toBe("Auto-create lane here"); + expect(rows[1]).toBe("Auto-create lane here"); + expect(rows[2]).toContain("auth-refresh"); expect(rows[3]).toBe("MacBook Pro (97)"); - expect(rows[4]).toContain("render-perf"); - expect(rows[5]).toBe("Auto-create lane here"); + expect(rows[4]).toBe("Auto-create lane here"); + expect(rows[5]).toContain("render-perf"); }); it("selects the per-machine auto-create row with its own id", () => { const onChange = vi.fn(); - render(); + render( + , + ); const popover = openList(); const autoCreateRows = within(popover).getAllByText("Auto-create lane here"); @@ -114,14 +134,22 @@ describe("LaneCombobox machine grouping", () => { it("keeps arrow-key navigation on selectable rows only", () => { const onChange = vi.fn(); - render(); + render( + , + ); const popover = openList(); const search = within(popover).getByPlaceholderText("Search lanes..."); - // Highlight starts on the selected lane (index 0 of the selectable rows). + // Highlight starts on the selected local lane; the next selectable row is + // the auto-create action at the top of the next machine group. fireEvent.keyDown(search, { key: "ArrowDown" }); fireEvent.keyDown(search, { key: "Enter" }); - expect(onChange).toHaveBeenCalledWith(AUTO_CREATE_LANE_OPTION_ID); + expect(onChange).toHaveBeenCalledWith(autoCreateLaneOptionId("studio")); }); it("labels a selected per-machine auto-create row on the trigger", () => { @@ -138,4 +166,26 @@ describe("LaneCombobox machine grouping", () => { "Auto-create lane", ); }); + + it("keeps duplicate lane ids machine-qualified", () => { + const onChange = vi.fn(); + const duplicateLanes = [ + autoCreateLane, + { id: "primary", name: "Primary local", machineId: "this-mac" }, + { id: "primary", name: "Primary studio", machineId: "studio" }, + ]; + render( + , + ); + const popover = openList(); + + fireEvent.click(within(popover).getByText("Primary studio")); + + expect(onChange).toHaveBeenCalledWith(machineLaneOptionId("studio", "primary")); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts index 4543e1bc6..ee3115ee6 100644 --- a/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts +++ b/apps/desktop/src/renderer/components/terminals/sessionLifecycleActions.ts @@ -1,4 +1,8 @@ -import type { SessionSettleOverride, TerminalSessionSummary } from "../../../shared/types"; +import type { + OpenProjectBinding, + SessionSettleOverride, + TerminalSessionSummary, +} from "../../../shared/types"; import { showToast } from "../app/toast/toastStore"; import { snoozeConfirmationLabel, @@ -32,10 +36,13 @@ export async function snoozeSessionForDuration( session: Pick, key: SnoozeDurationKey, nowMs: number = Date.now(), + pin?: OpenProjectBinding | null, ): Promise { const untilIso = snoozeDeadlineIso(key, nowMs); try { - await window.ade.sessions.snoozeSession(session.id, untilIso); + await (pin + ? window.ade.sessions.snoozeSession(session.id, untilIso, pin) + : window.ade.sessions.snoozeSession(session.id, untilIso)); } catch (error) { reportFailure("Snooze", session.id, error); return; @@ -47,8 +54,10 @@ export async function snoozeSessionForDuration( action: { label: "Undo", onClick: () => { - void window.ade.sessions - .wakeSession(session.id, "manual") + const wake = pin + ? window.ade.sessions.wakeSession(session.id, "manual", pin) + : window.ade.sessions.wakeSession(session.id, "manual"); + void wake .catch((error: unknown) => reportFailure("Undo snooze", session.id, error)); }, }, @@ -56,9 +65,14 @@ export async function snoozeSessionForDuration( } /** Wake a snoozed row right now (the user asked, so the reason is "manual"). */ -export async function wakeSessionNow(session: Pick): Promise { +export async function wakeSessionNow( + session: Pick, + pin?: OpenProjectBinding | null, +): Promise { try { - await window.ade.sessions.wakeSession(session.id, "manual"); + await (pin + ? window.ade.sessions.wakeSession(session.id, "manual", pin) + : window.ade.sessions.wakeSession(session.id, "manual")); } catch (error) { reportFailure("Wake", session.id, error); } @@ -72,9 +86,12 @@ export async function wakeSessionNow(session: Pick export async function setSessionSettleOverride( session: Pick, override: SessionSettleOverride, + pin?: OpenProjectBinding | null, ): Promise { try { - await window.ade.sessions.setSettleOverride(session.id, override); + await (pin + ? window.ade.sessions.setSettleOverride(session.id, override, pin) + : window.ade.sessions.setSettleOverride(session.id, override)); } catch (error) { reportFailure(override === "active" ? "Keep active" : "Settle", session.id, error); } @@ -89,22 +106,29 @@ export async function setSessionSettleOverride( */ export async function unsettleSession( session: Pick, + pin?: OpenProjectBinding | null, ): Promise { if (!session.settledAt) { - await setSessionSettleOverride(session, "active"); + await setSessionSettleOverride(session, "active", pin); return; } try { - await window.ade.sessions.unsettle(session.id); + await (pin + ? window.ade.sessions.unsettle(session.id, pin) + : window.ade.sessions.unsettle(session.id)); } catch (error) { reportFailure("Unsettle", session.id, error); } } /** Drop a row's "woke" marker once the user has actually looked at it. */ -export function clearSessionWokeMarker(sessionId: string): void { - void window.ade.sessions - ?.clearWokeMarker?.(sessionId) +export function clearSessionWokeMarker( + sessionId: string, + pin?: OpenProjectBinding | null, +): void { + const clear = window.ade.sessions?.clearWokeMarker; + if (!clear) return; + void (pin ? clear(sessionId, pin) : clear(sessionId)) .catch((error: unknown) => { console.error("[sessionLifecycle] clearWokeMarker failed", { sessionId, error }); }); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx index 23edf4f21..725d1ef7d 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { act, cleanup, render, renderHook } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import React from "react"; import { MemoryRouter } from "react-router-dom"; @@ -10,6 +10,8 @@ import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu"; const navigate = vi.fn(); const selectLane = vi.fn(); const setWorkViewState = vi.fn(); +const switchRemoteProject = vi.fn().mockResolvedValue(undefined); +const switchProjectToPath = vi.fn().mockResolvedValue(undefined); let capturedLaneContextMenuProps: Record | null = null; let capturedManageLaneHostProps: Record | null = null; @@ -49,9 +51,15 @@ vi.mock("../../state/appStore", async () => { } satisfies LaneSummary, ], project: { rootPath: "/local/project" }, - projectBinding: { kind: "remote", rootPath: "/remote/project" }, + projectBinding: { + kind: "remote", + key: "/remote/project", + rootPath: "/remote/project", + }, selectLane, setWorkViewState, + switchRemoteProject, + switchProjectToPath, }), }; }); @@ -61,6 +69,16 @@ vi.mock("../lanes/LaneContextMenu", () => ({ capturedLaneContextMenuProps = props; return null; }, + HoverButton: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; + }) => , + menuItemStyle: {}, })); vi.mock("./WorkManageLaneDialogHost", () => ({ @@ -77,6 +95,8 @@ afterEach(() => { navigate.mockReset(); selectLane.mockReset(); setWorkViewState.mockReset(); + switchRemoteProject.mockReset(); + switchProjectToPath.mockReset(); }); describe("useWorkLaneContextMenu", () => { @@ -115,6 +135,57 @@ describe("useWorkLaneContextMenu", () => { expect(navigate).toHaveBeenCalledWith("/work"); }); + it("starts a foreign lane draft with its owning machine", () => { + const writeClipboardText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(window, "ade", { + configurable: true, + value: { app: { writeClipboardText } }, + }); + const lane = { + id: "lane-remote", + name: "Remote Lane", + laneType: "worktree", + branchRef: "refs/heads/remote-lane", + worktreePath: "/Users/studio/ADE/.ade/worktrees/remote-lane", + linearIssue: null, + } as LaneSummary; + const binding = { + kind: "remote" as const, + key: "remote:studio:ade", + targetId: "studio", + projectId: "ade", + rootPath: "/Users/studio/ADE", + displayName: "ADE", + runtimeName: "Studio", + hostname: "studio.local", + }; + const { result } = renderHook(() => useWorkLaneContextMenu(), { + wrapper: ({ children }) => {children}, + }); + + act(() => { + result.current.triggerForeign(lane, binding, "Studio", true, { + preventDefault: vi.fn(), + clientX: 12, + clientY: 34, + }); + }); + render(<>{result.current.menu}); + fireEvent.click(screen.getByRole("menuitem", { name: "Start chat in lane" })); + + expect(setWorkViewState).toHaveBeenCalledWith("/remote/project", expect.any(Function)); + const updater = setWorkViewState.mock.calls[0]?.[1] as ( + previous: Record, + ) => Record; + expect(updater({})).toMatchObject({ + draftKind: "chat", + draftLaneId: "lane-remote", + draftMachineId: "studio", + activeItemId: null, + selectedItemId: null, + }); + }); + it("opens lane management in Work without navigating to the Lanes tab", () => { const { result } = renderHook(() => useWorkLaneContextMenu(), { wrapper: ({ children }) => {children}, diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx index b7cc6154c..b8e2c02dd 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx +++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx @@ -1,31 +1,54 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "react-router-dom"; -import { useAppStore, selectActiveProjectRoot } from "../../state/appStore"; +import type { LaneSummary, OpenProjectBinding } from "../../../shared/types"; +import { + useAppStore, + selectActiveProjectStateKey, +} from "../../state/appStore"; import { useStartChatInLane } from "../../hooks/useStartChatInLane"; import { LaneContextMenu } from "../lanes/LaneContextMenu"; +import { ForeignLaneContextMenu } from "./ForeignLaneContextMenu"; import { WorkManageLaneDialogHost } from "./WorkManageLaneDialogHost"; type MenuState = { laneId: string; x: number; y: number }; +type ForeignMenuState = { + lane: LaneSummary; + binding: OpenProjectBinding; + machineName: string; + online: boolean; + x: number; + y: number; +}; export type LaneContextTrigger = ( laneId: string, e: { preventDefault: () => void; clientX: number; clientY: number }, ) => void; +export type ForeignLaneContextTrigger = ( + lane: LaneSummary, + binding: OpenProjectBinding, + machineName: string, + online: boolean, + e: { preventDefault: () => void; clientX: number; clientY: number }, +) => void; export function useWorkLaneContextMenu(): { trigger: LaneContextTrigger; + triggerForeign: ForeignLaneContextTrigger; menu: React.ReactNode; } { const navigate = useNavigate(); const lanes = useAppStore((s) => s.lanes); const selectLane = useAppStore((s) => s.selectLane); - const projectRoot = useAppStore(selectActiveProjectRoot); + const projectStateKey = useAppStore(selectActiveProjectStateKey); const setWorkViewState = useAppStore((s) => s.setWorkViewState); + const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); + const switchRemoteProject = useAppStore((s) => s.switchRemoteProject); const [menuState, setMenuState] = useState(null); + const [foreignMenuState, setForeignMenuState] = useState(null); const [managedLaneId, setManagedLaneId] = useState(null); - const lanesById = useMemo(() => { const map = new Map(); for (const lane of lanes) map.set(lane.id, lane); @@ -41,15 +64,36 @@ export function useWorkLaneContextMenu(): { e.preventDefault(); setMenuState({ laneId, x: e.clientX, y: e.clientY }); }, []); + const triggerForeign = useCallback(( + lane, + binding, + machineName, + online, + event, + ) => { + event.preventDefault(); + setMenuState(null); + setForeignMenuState({ + lane, + binding, + machineName, + online, + x: event.clientX, + y: event.clientY, + }); + }, []); - const close = useCallback(() => setMenuState(null), []); + const close = useCallback(() => { + setMenuState(null); + setForeignMenuState(null); + }, []); useEffect(() => { - if (!menuState) return; - const onPointerDown = () => setMenuState(null); + if (!menuState && !foreignMenuState) return; + const onPointerDown = () => close(); document.addEventListener("pointerdown", onPointerDown); return () => document.removeEventListener("pointerdown", onPointerDown); - }, [menuState]); + }, [close, foreignMenuState, menuState]); const goToLanesAction = useCallback( (laneId: string | null, action: string, extras?: Record) => { @@ -63,13 +107,54 @@ export function useWorkLaneContextMenu(): { ); const startChatInLane = useStartChatInLane({ - projectRoot, + projectStateKey, setWorkViewState, selectLane, navigate, }); + const startForeignChat = useCallback(() => { + if (!foreignMenuState || !projectStateKey || !foreignMenuState.online) return; + const laneId = foreignMenuState.lane.id; + setWorkViewState(projectStateKey, (previous) => ({ + ...previous, + draftKind: "chat", + draftLaneId: laneId, + draftMachineId: foreignMenuState.binding.kind === "remote" + ? foreignMenuState.binding.targetId + : "this-mac", + activeItemId: null, + selectedItemId: null, + })); + close(); + void navigate("/work"); + }, [ + close, + foreignMenuState, + navigate, + projectStateKey, + setWorkViewState, + ]); + const openForeignLane = useCallback(() => { + if (!foreignMenuState?.online) return; + const { binding, lane } = foreignMenuState; + close(); + const switching = binding.kind === "remote" + ? switchRemoteProject(binding.targetId, binding.projectId) + : switchProjectToPath(binding.rootPath); + void switching.then(() => { + selectLane(lane.id); + void navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`); + }); + }, [ + close, + foreignMenuState, + navigate, + selectLane, + switchProjectToPath, + switchRemoteProject, + ]); - const menu = menuState || managedLaneId + const menu = menuState || foreignMenuState || managedLaneId ? createPortal( <> {menuState ? ( @@ -100,6 +185,27 @@ export function useWorkLaneContextMenu(): { onStartChatInLane={startChatInLane} /> ) : null} + {foreignMenuState ? ( + <> +
{ + event.preventDefault(); + close(); + }} + /> + + + ) : null} setManagedLaneId(null)} @@ -109,5 +215,5 @@ export function useWorkLaneContextMenu(): { ) : null; - return { trigger, menu }; + return { trigger, triggerForeign, menu }; } diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts index 5235d84b5..8c61293fb 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.test.ts @@ -1039,6 +1039,18 @@ describe("useWorkSessions — refresh-before-focus ordering", () => { expect(selectLaneSpy).toHaveBeenCalledWith("lane-2"); }); + it("setDraftMachineId remembers the draft owner independently from its lane id", () => { + const { result } = renderHook(() => useWorkSessions()); + + act(() => { + result.current.setDraftMachineId("studio"); + }); + + expect(setWorkViewStateSpy).toHaveBeenCalledWith("/fake/project", { + draftMachineId: "studio", + }); + }); + it("setActiveItemId selects the active tab lane in tab mode", async () => { const sessionA = makeSession("session-a", "lane-a"); const sessionB = makeSession("session-b", "lane-b"); diff --git a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts index 8145fdf7b..04fcca11a 100644 --- a/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts +++ b/apps/desktop/src/renderer/components/terminals/useWorkSessions.ts @@ -52,6 +52,7 @@ const DEFAULT_PROJECT_WORK_STATE: WorkProjectViewState = { draftKind: "chat", orchestratorEnabled: false, draftLaneId: null, + draftMachineId: null, laneFilter: "all", search: "", sessionListOrganization: "by-lane", @@ -525,6 +526,7 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) const draftKind = projectViewState.draftKind; const orchestratorEnabled = projectViewState.orchestratorEnabled; const draftLaneId = projectViewState.draftLaneId; + const draftMachineId = projectViewState.draftMachineId; const filterLaneId = projectViewState.laneFilter; const q = projectViewState.search; const sessionListOrganization: WorkSessionListOrganization = @@ -652,6 +654,14 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) [selectLane, setProjectViewState], ); + const setDraftMachineId = useCallback( + (machineId: string | null) => { + const normalizedMachineId = machineId?.trim() ?? ""; + setProjectViewState({ draftMachineId: normalizedMachineId || null }); + }, + [setProjectViewState], + ); + const setFilterLaneId = useCallback( (laneId: string) => { setProjectViewState({ laneFilter: laneId || "all" }); @@ -1875,6 +1885,8 @@ export function useWorkSessions({ active = true }: UseWorkSessionsOptions = {}) setOrchestratorEnabled, draftLaneId, setDraftLaneId, + draftMachineId, + setDraftMachineId, showDraftKind, openSessionTab, closeTab, diff --git a/apps/desktop/src/renderer/hooks/useStartChatInLane.ts b/apps/desktop/src/renderer/hooks/useStartChatInLane.ts index 541316ea3..0610415bf 100644 --- a/apps/desktop/src/renderer/hooks/useStartChatInLane.ts +++ b/apps/desktop/src/renderer/hooks/useStartChatInLane.ts @@ -8,26 +8,26 @@ type WorkViewStateUpdater = ( ) => void; export function useStartChatInLane({ - projectRoot, + projectStateKey, setWorkViewState, selectLane, navigate, }: { - projectRoot: string | null; + projectStateKey: string | null; setWorkViewState: WorkViewStateUpdater; selectLane: (laneId: string) => void; navigate: (path: string) => void | Promise; }) { return useCallback( (laneId: string) => { - if (!projectRoot) return; - setWorkViewState(projectRoot, (prev) => ({ + if (!projectStateKey) return; + setWorkViewState(projectStateKey, (prev) => ({ ...prev, ...startChatDraftPatch(laneId), })); selectLane(laneId); void navigate("/work"); }, - [navigate, projectRoot, selectLane, setWorkViewState], + [navigate, projectStateKey, selectLane, setWorkViewState], ); } diff --git a/apps/desktop/src/renderer/lib/workDraft.test.ts b/apps/desktop/src/renderer/lib/workDraft.test.ts index 34e3ecd81..c167a644a 100644 --- a/apps/desktop/src/renderer/lib/workDraft.test.ts +++ b/apps/desktop/src/renderer/lib/workDraft.test.ts @@ -7,6 +7,7 @@ describe("startChatDraftPatch", () => { draftKind: "chat", orchestratorEnabled: false, draftLaneId: "lane-42", + draftMachineId: null, activeItemId: null, selectedItemId: null, }); diff --git a/apps/desktop/src/renderer/lib/workDraft.ts b/apps/desktop/src/renderer/lib/workDraft.ts index 6dd7da2d7..c9ba5136c 100644 --- a/apps/desktop/src/renderer/lib/workDraft.ts +++ b/apps/desktop/src/renderer/lib/workDraft.ts @@ -2,7 +2,12 @@ import type { WorkProjectViewState } from "../state/appStore"; export type StartChatDraftPatch = Pick< WorkProjectViewState, - "draftKind" | "orchestratorEnabled" | "draftLaneId" | "activeItemId" | "selectedItemId" + | "draftKind" + | "orchestratorEnabled" + | "draftLaneId" + | "draftMachineId" + | "activeItemId" + | "selectedItemId" >; export function startChatDraftPatch(laneId: string): StartChatDraftPatch { @@ -10,6 +15,7 @@ export function startChatDraftPatch(laneId: string): StartChatDraftPatch { draftKind: "chat", orchestratorEnabled: false, draftLaneId: laneId, + draftMachineId: null, activeItemId: null, selectedItemId: null, }; diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index b6bbc39db..14f2a602f 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -160,6 +160,8 @@ export type WorkProjectViewState = { */ orchestratorEnabled: boolean; draftLaneId: string | null; + /** Machine that owns `draftLaneId`; null means the project tab's bound machine. */ + draftMachineId: string | null; laneFilter: string; search: string; /** Session list grouping mode. */ @@ -218,6 +220,7 @@ function createDefaultWorkProjectViewState(): WorkProjectViewState { draftKind: "chat", orchestratorEnabled: false, draftLaneId: null, + draftMachineId: null, laneFilter: "all", search: "", sessionListOrganization: "by-lane", @@ -283,6 +286,7 @@ function normalizeWorkProjectViewState(value: unknown): WorkProjectViewState { && (candidate.orchestratorEnabled === true || (candidate as { draftKind?: unknown }).draftKind === "chat-orchestrator"), draftLaneId: normalizeOptionalString(candidate.draftLaneId), + draftMachineId: normalizeOptionalString(candidate.draftMachineId), laneFilter: normalizeOptionalString(candidate.laneFilter) ?? "all", search: typeof candidate.search === "string" ? candidate.search : "", sessionListOrganization: diff --git a/apps/desktop/src/renderer/state/crossMachineLanes.ts b/apps/desktop/src/renderer/state/crossMachineLanes.ts index a062b72c3..352aecac2 100644 --- a/apps/desktop/src/renderer/state/crossMachineLanes.ts +++ b/apps/desktop/src/renderer/state/crossMachineLanes.ts @@ -24,6 +24,7 @@ */ import { useEffect, useMemo, useState } from "react"; +import { remoteProjectBindingKey } from "../../shared/projectIdentity"; import type { LaneSummary, OpenProjectBinding, @@ -706,7 +707,10 @@ async function runRefresh(): Promise { option.project?.projectId as string, { kind: "remote", - key: `remote:${option.targetId}:${option.project?.projectId}`, + key: remoteProjectBindingKey( + option.targetId as string, + option.project?.projectId as string, + ), targetId: option.targetId as string, runtimeName: option.name, ...(option.hostname ? { hostname: option.hostname } : {}), diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index 5c0a734d2..0d0160b5b 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -294,6 +294,8 @@ export type OpenProjectBinding = key: string; rootPath: string; displayName: string; + /** Credential-free origin used to join this checkout across machines. */ + gitOriginUrl?: string | null; } | { kind: "remote"; @@ -304,6 +306,8 @@ export type OpenProjectBinding = projectId: string; rootPath: string; displayName: string; + /** Credential-free origin used to join this checkout across machines. */ + gitOriginUrl?: string | null; /** * The remote project's icon as a base64 data URL, resolved on the host * machine. Lets the project tab show the real project logo instead of a @@ -483,6 +487,8 @@ export type RecentProjectRemoteRef = { /** Human-friendly machine name shown on the recents row chip. */ runtimeName: string; hostname: string; + /** Credential-free origin retained so offline recents can still auto-bind. */ + gitOriginUrl?: string | null; /** Host-resolved project logo for remote recents, when available. */ iconDataUrl?: string | null; };