From 05c59747f627e74b2a340da41ae424e9decaa138 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 02:03:02 +0000 Subject: [PATCH 1/3] feat(desktop): persist execution targets and chat target metadata - Add AdeExecutionTargetsState in project SQLite via ade_execution_targets key - Expose executionTargets get/set IPC and preload API - Include optional projectId on ProjectInfo for stable project scoping - Persist executionTargetId/label on agent chat sessions and summaries - Merge chat target fields into sessions.list for Work sidebar Co-authored-by: Arul Sharma --- apps/desktop/src/main/main.ts | 4 +- .../main/services/chat/agentChatService.ts | 80 +++++++++++++- .../executionTargetsStateService.ts | 26 +++++ .../src/main/services/ipc/registerIpc.ts | 42 ++++++- .../main/services/projects/projectService.ts | 9 +- apps/desktop/src/preload/global.d.ts | 5 + apps/desktop/src/preload/preload.ts | 6 + apps/desktop/src/shared/ipc.ts | 2 + apps/desktop/src/shared/types/chat.ts | 12 ++ apps/desktop/src/shared/types/core.ts | 2 + .../src/shared/types/executionTargets.ts | 104 ++++++++++++++++++ apps/desktop/src/shared/types/index.ts | 1 + apps/desktop/src/shared/types/sessions.ts | 3 + 13 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/main/services/executionTargets/executionTargetsStateService.ts create mode 100644 apps/desktop/src/shared/types/executionTargets.ts diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 967903407..656efa661 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -666,8 +666,8 @@ app.whenReady().then(async () => { const agentToolsService = createAgentToolsService({ logger }); const devToolsService = createDevToolsService({ logger }); - const project = toProjectInfo(projectRoot, baseRef); - const { projectId } = upsertProjectRow({ db, repoRoot: projectRoot, displayName: project.displayName, baseRef }); + const { projectId } = upsertProjectRow({ db, repoRoot: projectRoot, displayName: path.basename(projectRoot), baseRef }); + const project = toProjectInfo(projectRoot, baseRef, projectId); const operationService = createOperationService({ db, projectId }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c18c3d12d..312ac6dbc 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -66,6 +66,7 @@ import type { EpisodicSummaryService } from "../memory/episodicSummaryService"; import { createDefaultComputerUsePolicy, normalizeComputerUsePolicy, + ADE_LOCAL_EXECUTION_TARGET_ID, } from "../../../shared/types"; import type { AgentChatApprovalDecision, @@ -247,6 +248,8 @@ type PersistedChatState = { lastLaneDirectiveKey?: string | null; manuallyNamed?: boolean; requestedCwd?: string | null; + executionTargetId?: string | null; + executionTargetLabel?: string | null; /** Persisted "Allow for Session" tool approval overrides (Claude runtime). */ approvalOverrides?: string[]; updatedAt: string; @@ -4566,6 +4569,12 @@ export function createAgentChatService(args: { ...(managed.session.requestedCwd != null && String(managed.session.requestedCwd).trim().length ? { requestedCwd: String(managed.session.requestedCwd).trim() } : {}), + ...(managed.session.executionTargetId != null && String(managed.session.executionTargetId).trim().length + ? { executionTargetId: String(managed.session.executionTargetId).trim() } + : {}), + ...(managed.session.executionTargetLabel != null && String(managed.session.executionTargetLabel).trim().length + ? { executionTargetLabel: String(managed.session.executionTargetLabel).trim() } + : {}), updatedAt: nowIso() }; @@ -4706,6 +4715,12 @@ export function createAgentChatService(args: { ...(typeof record.requestedCwd === "string" && record.requestedCwd.trim().length ? { requestedCwd: record.requestedCwd.trim() } : {}), + ...(typeof record.executionTargetId === "string" && record.executionTargetId.trim().length + ? { executionTargetId: record.executionTargetId.trim() } + : {}), + ...(typeof record.executionTargetLabel === "string" && record.executionTargetLabel.trim().length + ? { executionTargetLabel: record.executionTargetLabel.trim() } + : {}), updatedAt: typeof record.updatedAt === "string" && record.updatedAt.trim().length ? record.updatedAt : nowIso() }; hydrateNativePermissionControls(hydrated as Parameters[0]); @@ -5625,6 +5640,14 @@ export function createAgentChatService(args: { ...(persisted?.requestedCwd != null && String(persisted.requestedCwd).trim().length ? { requestedCwd: String(persisted.requestedCwd).trim() } : {}), + ...(persisted?.executionTargetId != null && String(persisted.executionTargetId).trim().length + ? { + executionTargetId: String(persisted.executionTargetId).trim(), + ...(persisted?.executionTargetLabel != null && String(persisted.executionTargetLabel).trim().length + ? { executionTargetLabel: String(persisted.executionTargetLabel).trim() } + : {}), + } + : {}), createdAt: row.startedAt, lastActivityAt: persisted?.updatedAt ?? row.endedAt ?? row.startedAt }, @@ -10145,6 +10168,8 @@ export function createAgentChatService(args: { automationRunId, computerUse, requestedCwd, + executionTargetId: requestedExecutionTargetId, + executionTargetLabel: requestedExecutionTargetLabel, }: AgentChatCreateArgs): Promise => { const launchContext = resolveLaneLaunchContext({ laneService, @@ -10220,6 +10245,15 @@ export function createAgentChatService(args: { : requestedPermMode; const chatConfig = resolveChatConfig(); + const rawExecTargetId = typeof requestedExecutionTargetId === "string" ? requestedExecutionTargetId.trim() : ""; + const normalizedExecTargetId = rawExecTargetId.length ? rawExecTargetId : ADE_LOCAL_EXECUTION_TARGET_ID; + const normalizedExecTargetLabel = + typeof requestedExecutionTargetLabel === "string" && requestedExecutionTargetLabel.trim().length + ? requestedExecutionTargetLabel.trim() + : normalizedExecTargetId === ADE_LOCAL_EXECUTION_TARGET_ID + ? "This computer" + : normalizedExecTargetId; + const nativePermissionFields = (() => { if (effectiveProvider === "claude") { const interactionMode = requestedInteractionMode @@ -10309,6 +10343,8 @@ export function createAgentChatService(args: { ...(typeof requestedCwd === "string" && requestedCwd.trim().length ? { requestedCwd: requestedCwd.trim() } : {}), + executionTargetId: normalizedExecTargetId, + executionTargetLabel: normalizedExecTargetLabel, }, transcriptPath, transcriptBytesWritten: fileSizeOrZero(transcriptPath), @@ -10445,6 +10481,8 @@ export function createAgentChatService(args: { permissionMode: managed.session.permissionMode, surface: managed.session.surface, computerUse: managed.session.computerUse, + executionTargetId: managed.session.executionTargetId ?? null, + executionTargetLabel: managed.session.executionTargetLabel ?? null, }); const createdManaged = ensureManagedSession(created.id); @@ -12239,7 +12277,19 @@ export function createAgentChatService(args: { ...(hasLivePendingInput(liveManaged) ? { awaitingInput: true } : {}), ...(liveSession?.threadId || persisted?.threadId ? { threadId: liveSession?.threadId ?? persisted?.threadId } - : {}) + : {}), + ...(() => { + const idRaw = liveSession?.executionTargetId ?? persisted?.executionTargetId; + const id = typeof idRaw === "string" && idRaw.trim().length ? idRaw.trim() : ADE_LOCAL_EXECUTION_TARGET_ID; + const labelRaw = liveSession?.executionTargetLabel ?? persisted?.executionTargetLabel; + const label = + typeof labelRaw === "string" && labelRaw.trim().length + ? labelRaw.trim() + : id === ADE_LOCAL_EXECUTION_TARGET_ID + ? "This computer" + : id; + return { executionTargetId: id, executionTargetLabel: label }; + })() } satisfies AgentChatSessionSummary; }; @@ -12772,6 +12822,8 @@ export function createAgentChatService(args: { cursorConfigValues, permissionMode, computerUse, + executionTargetId, + executionTargetLabel, }: AgentChatUpdateSessionArgs): Promise => { const managed = ensureManagedSession(sessionId); const chatConfig = resolveChatConfig(); @@ -12986,6 +13038,32 @@ export function createAgentChatService(args: { } } + if (executionTargetId !== undefined) { + const nextId = typeof executionTargetId === "string" && executionTargetId.trim().length + ? executionTargetId.trim() + : ADE_LOCAL_EXECUTION_TARGET_ID; + managed.session.executionTargetId = nextId; + if (executionTargetLabel !== undefined) { + const nextLabel = typeof executionTargetLabel === "string" && executionTargetLabel.trim().length + ? executionTargetLabel.trim() + : nextId === ADE_LOCAL_EXECUTION_TARGET_ID + ? "This computer" + : nextId; + managed.session.executionTargetLabel = nextLabel; + } else if (nextId === ADE_LOCAL_EXECUTION_TARGET_ID) { + managed.session.executionTargetLabel = "This computer"; + } else if (!managed.session.executionTargetLabel?.trim()) { + managed.session.executionTargetLabel = nextId; + } + } else if (executionTargetLabel !== undefined) { + const nextLabel = typeof executionTargetLabel === "string" && executionTargetLabel.trim().length + ? executionTargetLabel.trim() + : managed.session.executionTargetId === ADE_LOCAL_EXECUTION_TARGET_ID + ? "This computer" + : (managed.session.executionTargetId ?? "This computer"); + managed.session.executionTargetLabel = nextLabel; + } + if (computerUse !== undefined) { const nextComputerUse = normalizeComputerUsePolicy(computerUse, createDefaultComputerUsePolicy()); const prevComputerUse = managed.session.computerUse; diff --git a/apps/desktop/src/main/services/executionTargets/executionTargetsStateService.ts b/apps/desktop/src/main/services/executionTargets/executionTargetsStateService.ts new file mode 100644 index 000000000..f633a5f4b --- /dev/null +++ b/apps/desktop/src/main/services/executionTargets/executionTargetsStateService.ts @@ -0,0 +1,26 @@ +import type { AdeDb } from "../state/kvDb"; +import type { AdeExecutionTargetsState } from "../../../shared/types"; +import { defaultExecutionTargetsState, normalizeExecutionTargetsState } from "../../../shared/types"; + +function keyForProject(projectId: string): string { + return `ade_execution_targets:${projectId}`; +} + +export function getExecutionTargetsState(db: AdeDb | null, projectId: string): AdeExecutionTargetsState { + const pid = projectId.trim(); + if (!db || !pid.length) return defaultExecutionTargetsState(); + const raw = db.getJson(keyForProject(pid)); + return normalizeExecutionTargetsState(raw); +} + +export function setExecutionTargetsState( + db: AdeDb | null, + projectId: string, + next: AdeExecutionTargetsState, +): AdeExecutionTargetsState { + const pid = projectId.trim(); + if (!db || !pid.length) return defaultExecutionTargetsState(); + const normalized = normalizeExecutionTargetsState(next); + db.setJson(keyForProject(pid), normalized); + return normalized; +} diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 447438cfb..a810de763 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -226,6 +226,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + AdeExecutionTargetsState, RecentProjectSummary, PtyCreateArgs, PtyCreateResult, @@ -3216,6 +3217,21 @@ export function registerIpc({ ctx.db.setJson(key, arg.state); }); + ipcMain.handle(IPC.executionTargetsGet, async (): Promise => { + const ctx = getCtx(); + const { getExecutionTargetsState } = await import("../executionTargets/executionTargetsStateService"); + return getExecutionTargetsState(ctx.db, ctx.projectId); + }); + + ipcMain.handle( + IPC.executionTargetsSet, + async (_event, arg: AdeExecutionTargetsState): Promise => { + const ctx = getCtx(); + const { setExecutionTargetsState } = await import("../executionTargets/executionTargetsStateService"); + return setExecutionTargetsState(ctx.db, ctx.projectId, arg); + }, + ); + ipcMain.handle(IPC.lanesList, async (_event, arg: ListLanesArgs): Promise => { const ctx = getCtx(); return await withIpcTiming( @@ -3879,13 +3895,27 @@ export function registerIpc({ const chatSummaryBySessionId = new Map(chats.map((chat) => [chat.sessionId, chat] as const)); return sessions.map((session) => { if (!isChatToolType(session.toolType)) return session; - if (session.status !== "running") return session; const chat = chatSummaryBySessionId.get(session.id); - if (!chat) return session; - if (chat.awaitingInput) return { ...session, runtimeState: "waiting-input" as const }; - if (chat.status === "active") return { ...session, runtimeState: "running" as const }; - if (chat.status === "idle") return { ...session, runtimeState: "idle" as const }; - return session; + const withTarget = + chat + && (chat.executionTargetId != null && String(chat.executionTargetId).trim().length > 0 + || chat.executionTargetLabel != null && String(chat.executionTargetLabel).trim().length > 0) + ? { + ...session, + ...(chat.executionTargetId != null && String(chat.executionTargetId).trim().length + ? { executionTargetId: String(chat.executionTargetId).trim() } + : {}), + ...(chat.executionTargetLabel != null && String(chat.executionTargetLabel).trim().length + ? { executionTargetLabel: String(chat.executionTargetLabel).trim() } + : {}), + } + : session; + if (session.status !== "running") return withTarget; + if (!chat) return withTarget; + if (chat.awaitingInput) return { ...withTarget, runtimeState: "waiting-input" as const }; + if (chat.status === "active") return { ...withTarget, runtimeState: "running" as const }; + if (chat.status === "idle") return { ...withTarget, runtimeState: "idle" as const }; + return withTarget; }); }, { diff --git a/apps/desktop/src/main/services/projects/projectService.ts b/apps/desktop/src/main/services/projects/projectService.ts index 0e8573753..2cca97f99 100644 --- a/apps/desktop/src/main/services/projects/projectService.ts +++ b/apps/desktop/src/main/services/projects/projectService.ts @@ -57,6 +57,11 @@ export function upsertProjectRow({ return { projectId: id }; } -export function toProjectInfo(repoRoot: string, baseRef: string): ProjectInfo { - return { rootPath: repoRoot, displayName: path.basename(repoRoot), baseRef }; +export function toProjectInfo(repoRoot: string, baseRef: string, projectId?: string): ProjectInfo { + return { + rootPath: repoRoot, + displayName: path.basename(repoRoot), + baseRef, + ...(projectId?.trim() ? { projectId: projectId.trim() } : {}), + }; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index bda16166a..c017b59d5 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -351,6 +351,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + AdeExecutionTargetsState, RecentProjectSummary, PtyCreateArgs, PtyCreateResult, @@ -593,6 +594,10 @@ declare global { onMissing: (cb: (data: { rootPath: string }) => void) => () => void; onStateEvent: (cb: (event: AdeProjectEvent) => void) => () => void; }; + executionTargets: { + get: () => Promise; + set: (state: AdeExecutionTargetsState) => Promise; + }; keybindings: { get: () => Promise; set: (overrides: KeybindingOverride[]) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 155f18fd1..aebbb6c22 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -282,6 +282,7 @@ import type { ProjectConfigTrust, ProjectConfigValidationResult, ProjectInfo, + AdeExecutionTargetsState, RecentProjectSummary, PtyCreateArgs, PtyCreateResult, @@ -599,6 +600,11 @@ contextBridge.exposeInMainWorld("ade", { return () => ipcRenderer.removeListener(IPC.projectStateEvent, listener); } }, + executionTargets: { + get: async (): Promise => ipcRenderer.invoke(IPC.executionTargetsGet), + set: async (state: AdeExecutionTargetsState): Promise => + ipcRenderer.invoke(IPC.executionTargetsSet, state), + }, keybindings: { get: async (): Promise => ipcRenderer.invoke(IPC.keybindingsGet), set: async (overrides: KeybindingOverride[]): Promise => diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index f28b45da3..0baf99db1 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -17,6 +17,8 @@ export const IPC = { projectForgetRecent: "ade.project.forgetRecent", projectReorderRecent: "ade.project.reorderRecent", projectMissing: "ade.project.missing", + executionTargetsGet: "ade.executionTargets.get", + executionTargetsSet: "ade.executionTargets.set", projectStateGetSnapshot: "ade.project.state.getSnapshot", projectStateInitializeOrRepair: "ade.project.state.initializeOrRepair", projectStateRunIntegrityCheck: "ade.project.state.runIntegrityCheck", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index df5d2fd49..be9dbbb6a 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -477,6 +477,12 @@ export type AgentChatSession = { threadId?: string; /** Subdirectory or absolute path under the lane worktree used as cwd; persisted for relaunch/resume. */ requestedCwd?: string | null; + /** + * Intended execution environment for this chat (`local` = this computer). + * Remote SSH targets are metadata until runner/SSH execution is wired. + */ + executionTargetId?: string | null; + executionTargetLabel?: string | null; createdAt: string; lastActivityAt: string; }; @@ -516,6 +522,8 @@ export type AgentChatSessionSummary = { summary: string | null; awaitingInput?: boolean; threadId?: string; + executionTargetId?: string | null; + executionTargetLabel?: string | null; }; export type AgentChatTranscriptEntry = { @@ -592,6 +600,8 @@ export type AgentChatCreateArgs = { automationRunId?: string | null; computerUse?: ComputerUsePolicy | null; requestedCwd?: string; + executionTargetId?: string | null; + executionTargetLabel?: string | null; }; export type AgentChatHandoffArgs = { @@ -691,6 +701,8 @@ export type AgentChatUpdateSessionArgs = { cursorModeId?: string | null; cursorConfigValues?: Record | null; computerUse?: ComputerUsePolicy | null; + executionTargetId?: string | null; + executionTargetLabel?: string | null; }; export type AgentChatSlashCommand = { diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index b3c452fb1..d15d41a6a 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -43,6 +43,8 @@ export type ProjectInfo = { rootPath: string; displayName: string; baseRef: string; + /** Stable id for project-scoped KV (SQLite); empty when project context is dormant. */ + projectId?: string; }; export type ClearLocalAdeDataArgs = { diff --git a/apps/desktop/src/shared/types/executionTargets.ts b/apps/desktop/src/shared/types/executionTargets.ts new file mode 100644 index 000000000..b06662e29 --- /dev/null +++ b/apps/desktop/src/shared/types/executionTargets.ts @@ -0,0 +1,104 @@ +// --------------------------------------------------------------------------- +// Execution targets — where agent/chat work is intended to run (local vs SSH). +// Tool execution on remote hosts is not fully wired yet; UI + metadata first. +// --------------------------------------------------------------------------- + +export type AdeExecutionTargetKind = "local" | "ssh"; + +/** Stable id; use "local" for the primary machine running ADE. */ +export type AdeExecutionTargetId = string; + +export type AdeSshExecutionTargetProfile = { + id: AdeExecutionTargetId; + kind: "ssh"; + /** Short display name, e.g. "GPU VM" */ + label: string; + /** SSH destination, e.g. user@host or token host from Daytona */ + sshHost: string; + /** Optional jump / ProxyJump host */ + jumpHost?: string; + /** Working directory on the remote for this repo */ + workspacePath: string; + /** + * How ADE will run tools when implemented. + * `ssh-shell` — commands over SSH; `ade-runner` — headless ADE on remote; `planned` — not connected yet. + */ + connectionMode: "ssh-shell" | "ade-runner" | "planned"; +}; + +export type AdeLocalExecutionTargetProfile = { + id: "local"; + kind: "local"; + label: string; +}; + +export type AdeExecutionTargetProfile = AdeLocalExecutionTargetProfile | AdeSshExecutionTargetProfile; + +export type AdeExecutionTargetsState = { + version: 1; + /** User-defined and built-in targets (always includes `local`). */ + profiles: AdeExecutionTargetProfile[]; + /** Last-selected target for this project (workspace focus). */ + activeTargetId: AdeExecutionTargetId; +}; + +export const ADE_LOCAL_EXECUTION_TARGET_ID = "local" as const; + +export function defaultExecutionTargetsState(): AdeExecutionTargetsState { + return { + version: 1, + profiles: [{ id: ADE_LOCAL_EXECUTION_TARGET_ID, kind: "local", label: "This computer" }], + activeTargetId: ADE_LOCAL_EXECUTION_TARGET_ID, + }; +} + +export function normalizeExecutionTargetsState(raw: unknown): AdeExecutionTargetsState { + const fallback = defaultExecutionTargetsState(); + if (!raw || typeof raw !== "object") return fallback; + const rec = raw as Partial; + if (rec.version !== 1) return fallback; + const profilesIn = Array.isArray(rec.profiles) ? rec.profiles : []; + const profiles: AdeExecutionTargetProfile[] = []; + let sawLocal = false; + for (const entry of profilesIn) { + if (!entry || typeof entry !== "object") continue; + const e = entry as Partial; + if (e.kind === "local" && e.id === ADE_LOCAL_EXECUTION_TARGET_ID) { + const label = typeof e.label === "string" && e.label.trim() ? e.label.trim() : "This computer"; + profiles.push({ id: ADE_LOCAL_EXECUTION_TARGET_ID, kind: "local", label }); + sawLocal = true; + continue; + } + if (e.kind === "ssh" && typeof e.id === "string" && e.id.trim()) { + const id = e.id.trim(); + if (id === ADE_LOCAL_EXECUTION_TARGET_ID) continue; + const label = typeof e.label === "string" && e.label.trim() ? e.label.trim() : id; + const sshHost = typeof e.sshHost === "string" ? e.sshHost.trim() : ""; + const workspacePath = typeof e.workspacePath === "string" ? e.workspacePath.trim() : ""; + const jumpHost = typeof e.jumpHost === "string" && e.jumpHost.trim() ? e.jumpHost.trim() : undefined; + const mode = e.connectionMode === "ade-runner" || e.connectionMode === "planned" ? e.connectionMode : "ssh-shell"; + if (!sshHost || !workspacePath) continue; + profiles.push({ + id, + kind: "ssh", + label, + sshHost, + workspacePath, + ...(jumpHost ? { jumpHost } : {}), + connectionMode: mode, + }); + } + } + if (!sawLocal) { + profiles.unshift({ id: ADE_LOCAL_EXECUTION_TARGET_ID, kind: "local", label: "This computer" }); + } + const activeRaw = typeof rec.activeTargetId === "string" ? rec.activeTargetId.trim() : ""; + const activeTargetId = profiles.some((p) => p.id === activeRaw) ? activeRaw : ADE_LOCAL_EXECUTION_TARGET_ID; + return { version: 1, profiles, activeTargetId }; +} + +export function executionTargetSummaryLabel(profile: AdeExecutionTargetProfile | undefined): string { + if (!profile) return "Unknown target"; + if (profile.kind === "local") return profile.label || "This computer"; + return profile.label || profile.sshHost; +} diff --git a/apps/desktop/src/shared/types/index.ts b/apps/desktop/src/shared/types/index.ts index 6fb4425b8..87afd767c 100644 --- a/apps/desktop/src/shared/types/index.ts +++ b/apps/desktop/src/shared/types/index.ts @@ -26,6 +26,7 @@ export * from "./budget"; export * from "./usage"; export * from "./memory"; export * from "./projectState"; +export * from "./executionTargets"; export * from "./sync"; export * from "./devTools"; diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index 9224d0728..b5923e7c1 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -43,6 +43,9 @@ export type TerminalSessionSummary = { summary: string | null; runtimeState: TerminalRuntimeState; resumeCommand: string | null; + /** Agent chat: intended execution target (local id or SSH profile id). */ + executionTargetId?: string | null; + executionTargetLabel?: string | null; }; export type TerminalSessionDetail = TerminalSessionSummary & { From f0267e71357cb1c3dd4e7f3552e8b06f73d2fbab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 02:03:29 +0000 Subject: [PATCH 2/3] feat(desktop): execution target UI in top bar, work, files, settings - Top bar workspace target switcher and settings CRUD for SSH profiles - Context banners on Work and Files; chat composer target picker when configured - Session list badges when chat target differs from project active target - Wire Work/Lane work surfaces with full target profile list for picker Co-authored-by: Arul Sharma --- apps/desktop/src/renderer/browserMock.ts | 12 ++ .../src/renderer/components/app/TopBar.tsx | 3 + .../components/chat/AgentChatComposer.tsx | 59 ++++- .../components/chat/AgentChatPane.tsx | 113 +++++++++- .../ExecutionTargetContextBanner.tsx | 60 ++++++ .../TopBarExecutionTargetSelect.tsx | 102 +++++++++ .../renderer/components/files/FilesPage.tsx | 5 + .../components/lanes/LaneWorkPane.tsx | 7 + .../settings/ExecutionTargetsSection.tsx | 201 ++++++++++++++++++ .../settings/WorkspaceSettingsSection.tsx | 2 + .../components/terminals/SessionCard.tsx | 16 ++ .../components/terminals/SessionListPane.tsx | 3 + .../components/terminals/TerminalsPage.tsx | 13 ++ .../components/terminals/WorkStartSurface.tsx | 16 +- .../components/terminals/WorkViewArea.tsx | 36 +++- .../src/renderer/hooks/useExecutionTargets.ts | 70 ++++++ 16 files changed, 712 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/renderer/components/executionTargets/ExecutionTargetContextBanner.tsx create mode 100644 apps/desktop/src/renderer/components/executionTargets/TopBarExecutionTargetSelect.tsx create mode 100644 apps/desktop/src/renderer/components/settings/ExecutionTargetsSection.tsx create mode 100644 apps/desktop/src/renderer/hooks/useExecutionTargets.ts diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 0304c362d..edaa3385b 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -860,6 +860,18 @@ if (typeof window !== "undefined" && !(window as any).ade) { onMissing: noop, onStateEvent: noop, }, + executionTargets: { + get: resolved({ + version: 1, + profiles: [{ id: "local", kind: "local" as const, label: "This computer" }], + activeTargetId: "local", + }), + set: resolvedArg({ + version: 1, + profiles: [{ id: "local", kind: "local" as const, label: "This computer" }], + activeTargetId: "local", + }), + }, keybindings: { get: resolved({ definitions: [], overrides: [] }), set: resolvedArg({ definitions: [], overrides: [] }), diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 4b25d2907..b42b0fac8 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -12,6 +12,7 @@ import { getStoredZoomLevel, } from "../../lib/zoom"; import { cn } from "../ui/cn"; +import { TopBarExecutionTargetSelect } from "../executionTargets/TopBarExecutionTargetSelect"; import type { ProcessRuntime, RecentProjectSummary, SyncRoleSnapshot } from "../../../shared/types"; import { AutoUpdateControl } from "./AutoUpdateControl"; @@ -417,6 +418,8 @@ export function TopBar() { > + + {syncSnapshot && syncLabel ? ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index ca4429849..9915e917f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { At, CaretDown, Check, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, Cube, BookOpen } from "@phosphor-icons/react"; +import { At, CaretDown, Check, Image, Paperclip, PencilSimple, Square, X, PaperPlaneTilt, Cube, BookOpen, DesktopTower } from "@phosphor-icons/react"; import { inferAttachmentType, type AgentChatApprovalDecision, @@ -18,7 +18,9 @@ import { type ChatSurfaceMode, type ComputerUsePolicy, type PendingInputRequest, + type AdeExecutionTargetProfile, } from "../../../shared/types"; +import { executionTargetSummaryLabel } from "../../../shared/types"; import { getModelById } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; import { UnifiedModelSelector } from "../shared/UnifiedModelSelector"; @@ -328,6 +330,10 @@ export function AgentChatComposer({ onCancelSteer, onEditSteer, onOpenAiSettings, + executionTargetProfiles, + executionTargetId, + executionTargetLabel, + onExecutionTargetChange, }: { surfaceMode?: ChatSurfaceMode; layoutVariant?: "standard" | "grid-tile"; @@ -398,6 +404,10 @@ export function AgentChatComposer({ onCancelSteer?: (steerId: string) => void; onEditSteer?: (steerId: string, text: string) => void; onOpenAiSettings?: () => void; + executionTargetProfiles?: AdeExecutionTargetProfile[]; + executionTargetId?: string; + executionTargetLabel?: string; + onExecutionTargetChange?: (targetId: string) => void; }) { const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); const [attachmentQuery, setAttachmentQuery] = useState(""); @@ -413,6 +423,8 @@ export function AgentChatComposer({ const [hoveredCodexPreset, setHoveredCodexPreset] = useState<"plan" | "edit" | "full-auto" | null>(null); const [dragActive, setDragActive] = useState(false); + const [targetMenuOpen, setTargetMenuOpen] = useState(false); + const targetMenuRef = useRef(null); const attachmentInputRef = useRef(null); const uploadInputRef = useRef(null); @@ -420,6 +432,15 @@ export function AgentChatComposer({ const fileAddInProgressRef = useRef(false); const canAttach = !turnActive; + useEffect(() => { + if (!targetMenuOpen) return; + const onDoc = (e: MouseEvent) => { + if (!targetMenuRef.current?.contains(e.target as Node)) setTargetMenuOpen(false); + }; + document.addEventListener("mousedown", onDoc); + return () => document.removeEventListener("mousedown", onDoc); + }, [targetMenuOpen]); + const attachedPaths = useMemo(() => new Set(attachments.map((a) => a.path)), [attachments]); const selectedModel = useMemo(() => getModelById(modelId), [modelId]); @@ -1192,6 +1213,42 @@ export function AgentChatComposer({ / + {executionTargetProfiles?.length && onExecutionTargetChange && executionTargetId ? ( +
+ + {targetMenuOpen ? ( +
+ {executionTargetProfiles.map((p) => ( + + ))} +
+ ) : null} +
+ ) : null} + {/* Proof drawer toggle */} {onToggleProof ? ( + {open ? ( +
+
+ Workspace target +
+ {profiles.map((p: AdeExecutionTargetProfile) => ( + + ))} +
+ +
+
+ ) : null} + + ); +} diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 14e4a0de6..06c878be9 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -35,6 +35,8 @@ import type { import { MonacoDiffView } from "../lanes/MonacoDiffView"; import { LaneTerminalsPanel } from "../lanes/LaneTerminalsPanel"; import { useAppStore } from "../../state/appStore"; +import { useExecutionTargets } from "../../hooks/useExecutionTargets"; +import { ExecutionTargetContextBanner } from "../executionTargets/ExecutionTargetContextBanner"; import { replaceDirtyBuffersForWorkspace } from "../../lib/dirtyWorkspaceBuffers"; import { PaneTilingLayout } from "../ui/PaneTilingLayout"; import { revealLabel } from "../../lib/platform"; @@ -343,6 +345,8 @@ export function FilesPage() { const location = useLocation(); const selectedLaneId = useAppStore((s) => s.selectedLaneId); const projectRootPath = useAppStore((s) => s.project?.rootPath ?? "__unknown_project__"); + const projectRootForTargets = useAppStore((s) => s.project?.rootPath ?? null); + const { activeProfile } = useExecutionTargets(projectRootForTargets); const sessionKey = filesSessionKey(projectRootPath, selectedLaneId); const initialSession = filesPageSessionByScope.get(sessionKey); @@ -1751,6 +1755,7 @@ export function FilesPage() { return (
+ {/* Header bar */}
{/* Numbered title group */} diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index 1030285cf..834a5dc4b 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -4,6 +4,8 @@ import { EmptyState } from "../ui/EmptyState"; import { COLORS, SANS_FONT, SPACING } from "./laneDesignTokens"; import { WorkViewArea } from "../terminals/WorkViewArea"; import { useLaneWorkSessions } from "./useLaneWorkSessions"; +import { useAppStore } from "../../state/appStore"; +import { useExecutionTargets } from "../../hooks/useExecutionTargets"; const ENTRY_OPTIONS: Array<{ kind: WorkDraftKind; @@ -22,6 +24,8 @@ export function LaneWorkPane({ laneId: string | null; }) { const work = useLaneWorkSessions(laneId); + const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const { state: execTargetsState, activeProfile, activeTargetId } = useExecutionTargets(projectRoot); const laneList = work.lane ? [work.lane] : []; if (!laneId) { @@ -83,6 +87,9 @@ export function LaneWorkPane({ s.project?.rootPath ?? null); + const { state, persist, refresh } = useExecutionTargets(projectRoot); + const [label, setLabel] = useState(""); + const [sshHost, setSshHost] = useState(""); + const [workspacePath, setWorkspacePath] = useState("~/project"); + const [jumpHost, setJumpHost] = useState(""); + const [mode, setMode] = useState("planned"); + const [busy, setBusy] = useState(false); + + const saveState = useCallback( + async (next: AdeExecutionTargetsState) => { + setBusy(true); + try { + await persist(next); + } finally { + setBusy(false); + } + }, + [persist], + ); + + const addSshTarget = useCallback(async () => { + const trimmedLabel = label.trim(); + const host = sshHost.trim(); + const ws = workspacePath.trim(); + if (!trimmedLabel || !host || !ws) return; + const id = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const nextProfile: AdeSshExecutionTargetProfile = { + id, + kind: "ssh", + label: trimmedLabel, + sshHost: host, + workspacePath: ws, + ...(jumpHost.trim() ? { jumpHost: jumpHost.trim() } : {}), + connectionMode: mode, + }; + const next: AdeExecutionTargetsState = { + ...state, + profiles: [...state.profiles.filter((p) => p.id !== id), nextProfile], + }; + await saveState(next); + setLabel(""); + setSshHost(""); + setJumpHost(""); + }, [jumpHost, label, mode, saveState, sshHost, state, workspacePath]); + + const removeTarget = useCallback( + async (id: string) => { + if (id === ADE_LOCAL_EXECUTION_TARGET_ID) return; + const next: AdeExecutionTargetsState = { + ...state, + profiles: state.profiles.filter((p) => p.id !== id), + activeTargetId: state.activeTargetId === id ? ADE_LOCAL_EXECUTION_TARGET_ID : state.activeTargetId, + }; + if (!next.profiles.some((p) => p.id === ADE_LOCAL_EXECUTION_TARGET_ID)) { + next.profiles = defaultExecutionTargetsState().profiles; + } + await saveState(next); + }, + [saveState, state], + ); + + if (!projectRoot?.trim()) { + return ( +
+ Open a project to configure execution targets. +
+ ); + } + + return ( +
+
Execution targets
+

+ Choose where this project's workspace focus points. Chats can record a target for when remote execution is + available; tools still run on this computer until SSH or a remote runner is connected. +

+ +
+
+ Saved targets +
+
    + {state.profiles.map((p) => ( +
  • + + {executionTargetSummaryLabel(p)} + {p.kind === "ssh" ? ( + + {p.sshHost} → {p.workspacePath} + + ) : null} + + {p.id === state.activeTargetId ? ( + Active + ) : ( + + )} + {p.id !== ADE_LOCAL_EXECUTION_TARGET_ID ? ( + + ) : null} +
  • + ))} +
+
+ +
+
+ Add SSH target +
+
+ setLabel(e.target.value)} /> + setSshHost(e.target.value)} /> + setWorkspacePath(e.target.value)} + /> + setJumpHost(e.target.value)} /> + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/settings/WorkspaceSettingsSection.tsx b/apps/desktop/src/renderer/components/settings/WorkspaceSettingsSection.tsx index 135b2781d..81a372d51 100644 --- a/apps/desktop/src/renderer/components/settings/WorkspaceSettingsSection.tsx +++ b/apps/desktop/src/renderer/components/settings/WorkspaceSettingsSection.tsx @@ -1,11 +1,13 @@ import React from "react"; import { ContextSection } from "./ContextSection"; +import { ExecutionTargetsSection } from "./ExecutionTargetsSection"; import { ProjectSection } from "./ProjectSection"; export function WorkspaceSettingsSection() { return (
+
); diff --git a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx index 4026dc978..6b18feb5b 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionCard.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionCard.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Info, Play } from "@phosphor-icons/react"; import type { TerminalSessionSummary } from "../../../shared/types"; +import { ADE_LOCAL_EXECUTION_TARGET_ID } from "../../../shared/types"; import { sessionStatusDot } from "../../lib/terminalAttention"; import { primarySessionLabel, secondarySessionLabel } from "../../lib/sessions"; import { useSessionDelta } from "./useSessionDelta"; @@ -38,8 +39,10 @@ export const SessionCard = React.memo(function SessionCard({ onInfoClick, onContextMenu, resumingSessionId, + projectActiveTargetId, }: { session: TerminalSessionSummary; + projectActiveTargetId?: string | null; isSelected: boolean; onSelect: (id: string) => void; onResume: () => void; @@ -53,6 +56,11 @@ export const SessionCard = React.memo(function SessionCard({ const delta = useSessionDelta(session.id, true); const primaryText = primarySessionLabel(session); const secondaryText = truncateSummary(secondarySessionLabel(session), 20); + const chatTargetId = session.executionTargetId?.trim() || ADE_LOCAL_EXECUTION_TARGET_ID; + const projectTarget = (projectActiveTargetId ?? ADE_LOCAL_EXECUTION_TARGET_ID).trim() || ADE_LOCAL_EXECUTION_TARGET_ID; + const showOtherTargetBadge = + Boolean(session.executionTargetId || session.executionTargetLabel) + && chatTargetId !== projectTarget; return (
@@ -89,6 +97,14 @@ export const SessionCard = React.memo(function SessionCard({ > {primaryText} + {showOtherTargetBadge ? ( + + {session.executionTargetLabel ? session.executionTargetLabel.slice(0, 12) : "Remote"} + + ) : null}
{/* Meta row */} diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 8dc8eb0a2..64096d60f 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -95,8 +95,10 @@ export const SessionListPane = React.memo(function SessionListPane({ workCollapsedLaneIds, toggleWorkLaneCollapsed, sessionsGroupedByLane, + projectActiveTargetId, }: { lanes: LaneSummary[]; + projectActiveTargetId?: string | null; runningFiltered: TerminalSessionSummary[]; awaitingInputFiltered: TerminalSessionSummary[]; endedFiltered: TerminalSessionSummary[]; @@ -143,6 +145,7 @@ export const SessionListPane = React.memo(function SessionListPane({ onResume(session)} diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx index 216dd9c9e..3f9933ad8 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.tsx @@ -8,6 +8,9 @@ import { SessionContextMenu, type SessionContextMenuState } from "./SessionConte import { SessionInfoPopover, type InfoPopoverState } from "./SessionInfoPopover"; import type { AgentChatSession, TerminalSessionSummary } from "../../../shared/types"; import { sortLanesForTabs } from "../lanes/laneUtils"; +import { useAppStore } from "../../state/appStore"; +import { useExecutionTargets } from "../../hooks/useExecutionTargets"; +import { ExecutionTargetContextBanner } from "../executionTargets/ExecutionTargetContextBanner"; const TERMINALS_TILING_TREE: PaneSplit = { type: "split", @@ -20,6 +23,8 @@ const TERMINALS_TILING_TREE: PaneSplit = { export function TerminalsPage() { const work = useWorkSessions(); + const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const { state: execTargetsState, activeProfile, activeTargetId } = useExecutionTargets(projectRoot); const sortedLanes = useMemo(() => sortLanesForTabs(work.lanes), [work.lanes]); const [contextMenu, setContextMenu] = useState(null); @@ -79,6 +84,9 @@ export function TerminalsPage() { ), }, @@ -198,6 +210,7 @@ export function TerminalsPage() { return (
+ {renameError ? (
void | Promise; onLaunchPtySession: (args: { laneId: string; @@ -29,6 +37,9 @@ type WorkStartSurfaceProps = { export function WorkStartSurface({ draftKind, lanes, + workExecutionTargetProfile, + executionTargetProfiles, + projectActiveExecutionTargetId, onOpenChatSession, onLaunchPtySession, }: WorkStartSurfaceProps) { @@ -143,6 +154,9 @@ export function WorkStartSurface({ onSessionCreated={onOpenChatSession} availableLanes={lanes} onLaneChange={setLaneAndSync} + workExecutionTargetProfile={workExecutionTargetProfile} + executionTargetProfiles={executionTargetProfiles} + projectActiveExecutionTargetId={projectActiveExecutionTargetId} />
diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 009b7bcab..08ce03883 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { GridFour, List, Plus, X } from "@phosphor-icons/react"; -import type { AgentChatSession, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import type { AdeExecutionTargetProfile, AgentChatSession, LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import type { WorkDraftKind, WorkViewMode } from "../../state/appStore"; import { TerminalView } from "./TerminalView"; import { ToolLogo } from "./ToolLogos"; @@ -32,12 +32,18 @@ function SessionSurface({ layoutVariant = "standard", terminalVisible = isActive, onOpenChatSession, + workExecutionTargetProfile, + executionTargetProfiles, + projectActiveExecutionTargetId, }: { session: TerminalSessionSummary; isActive: boolean; layoutVariant?: "standard" | "grid-tile"; terminalVisible?: boolean; onOpenChatSession: (session: AgentChatSession) => void | Promise; + workExecutionTargetProfile?: AdeExecutionTargetProfile; + executionTargetProfiles?: AdeExecutionTargetProfile[]; + projectActiveExecutionTargetId?: string | null; }) { const isChat = isChatToolType(session.toolType); if (isChat) { @@ -48,6 +54,9 @@ function SessionSurface({ lockSessionId={session.id} onSessionCreated={onOpenChatSession} layoutVariant={layoutVariant} + workExecutionTargetProfile={workExecutionTargetProfile} + executionTargetProfiles={executionTargetProfiles} + projectActiveExecutionTargetId={projectActiveExecutionTargetId} /> ); } @@ -114,6 +123,9 @@ function ModeSwitcherPills({ export function WorkViewArea({ gridLayoutId, lanes, + workExecutionTargetProfile, + executionTargetProfiles, + projectActiveExecutionTargetId, sessions, visibleSessions, activeItemId, @@ -130,6 +142,9 @@ export function WorkViewArea({ }: { gridLayoutId: string; lanes: LaneSummary[]; + workExecutionTargetProfile?: AdeExecutionTargetProfile; + executionTargetProfiles?: AdeExecutionTargetProfile[]; + projectActiveExecutionTargetId?: string | null; sessions: TerminalSessionSummary[]; visibleSessions: TerminalSessionSummary[]; activeItemId: string | null; @@ -193,6 +208,9 @@ export function WorkViewArea({ lanes={lanes} onOpenChatSession={onOpenChatSession} onLaunchPtySession={onLaunchPtySession} + workExecutionTargetProfile={workExecutionTargetProfile} + executionTargetProfiles={executionTargetProfiles} + projectActiveExecutionTargetId={projectActiveExecutionTargetId} />
@@ -275,6 +293,9 @@ export function WorkViewArea({ terminalVisible layoutVariant="grid-tile" onOpenChatSession={onOpenChatSession} + workExecutionTargetProfile={workExecutionTargetProfile} + executionTargetProfiles={executionTargetProfiles} + projectActiveExecutionTargetId={projectActiveExecutionTargetId} /> ), @@ -402,7 +423,15 @@ export function WorkViewArea({ {activeSession ? ( activeRunningTerminalSession ? null : (
- +
) ) : ( @@ -417,6 +446,9 @@ export function WorkViewArea({ lanes={lanes} onOpenChatSession={onOpenChatSession} onLaunchPtySession={onLaunchPtySession} + workExecutionTargetProfile={workExecutionTargetProfile} + executionTargetProfiles={executionTargetProfiles} + projectActiveExecutionTargetId={projectActiveExecutionTargetId} /> diff --git a/apps/desktop/src/renderer/hooks/useExecutionTargets.ts b/apps/desktop/src/renderer/hooks/useExecutionTargets.ts new file mode 100644 index 000000000..7943c63bc --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useExecutionTargets.ts @@ -0,0 +1,70 @@ +import { useCallback, useEffect, useState } from "react"; +import type { AdeExecutionTargetProfile, AdeExecutionTargetsState } from "../../shared/types"; +import { + ADE_LOCAL_EXECUTION_TARGET_ID, + defaultExecutionTargetsState, + executionTargetSummaryLabel, +} from "../../shared/types"; + +export function useExecutionTargets(projectRoot: string | null | undefined) { + const [state, setState] = useState(() => defaultExecutionTargetsState()); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + const root = typeof projectRoot === "string" ? projectRoot.trim() : ""; + if (!root) { + setState(defaultExecutionTargetsState()); + return; + } + setLoading(true); + try { + const next = await window.ade.executionTargets.get(); + setState(next); + } catch { + setState(defaultExecutionTargetsState()); + } finally { + setLoading(false); + } + }, [projectRoot]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const persist = useCallback( + async (next: AdeExecutionTargetsState) => { + const root = typeof projectRoot === "string" ? projectRoot.trim() : ""; + if (!root) return; + const saved = await window.ade.executionTargets.set(next); + setState(saved); + }, + [projectRoot], + ); + + const setActiveTargetId = useCallback( + async (targetId: string) => { + const id = targetId.trim() || ADE_LOCAL_EXECUTION_TARGET_ID; + const next: AdeExecutionTargetsState = { + ...state, + activeTargetId: state.profiles.some((p) => p.id === id) ? id : ADE_LOCAL_EXECUTION_TARGET_ID, + }; + await persist(next); + }, + [persist, state], + ); + + const activeProfile: AdeExecutionTargetProfile | undefined = state.profiles.find((p) => p.id === state.activeTargetId) + ?? state.profiles.find((p) => p.id === ADE_LOCAL_EXECUTION_TARGET_ID); + + return { + state, + loading, + refresh, + persist, + setActiveTargetId, + activeProfile, + activeTargetId: state.activeTargetId, + profiles: state.profiles, + activeLabel: executionTargetSummaryLabel(activeProfile), + }; +} From ec1c73efa07ad7d15e9a56257f5552d869790a0c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 04:14:26 +0000 Subject: [PATCH 3/3] fix(browserMock): execution targets set() should echo back passed state Agent-Logs-Url: https://github.com/arul28/ADE/sessions/25761bd5-f927-430a-ba53-270d1e565027 Co-authored-by: arul28 <31745423+arul28@users.noreply.github.com> --- apps/desktop/src/renderer/browserMock.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index edaa3385b..9095a4269 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -866,11 +866,7 @@ if (typeof window !== "undefined" && !(window as any).ade) { profiles: [{ id: "local", kind: "local" as const, label: "This computer" }], activeTargetId: "local", }), - set: resolvedArg({ - version: 1, - profiles: [{ id: "local", kind: "local" as const, label: "This computer" }], - activeTargetId: "local", - }), + set: async (state: any) => state, }, keybindings: { get: resolved({ definitions: [], overrides: [] }),