From d15bdb721cfbd88f7e8e2492d474ad928983946e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:55:18 -0400 Subject: [PATCH] fix(desktop): backfill pty resume target on launch + persist per-project route - ptyService: when relaunching a tracked CLI session whose resumeMetadata has no targetId, run tryBackfillResumeTarget("resume-launch") before spawning so the new pty starts on the right resume command instead of cold-starting. Replaces the old warn-only branch. - AppShell: persist the last-visited route per project to localStorage and restore it on project switch, instead of always slamming /work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/main/services/pty/ptyService.test.ts | 44 ++++++++++++ .../src/main/services/pty/ptyService.ts | 35 +++++----- .../src/renderer/components/app/AppShell.tsx | 69 ++++++++++++++++++- 3 files changed, 128 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index a83d8a60b..2ebe85047 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -731,6 +731,50 @@ describe("ptyService", () => { expect(sessionService.create).toHaveBeenCalledTimes(createCallsBeforeResume); }); + it("backfills a targetless Claude resume command before launching the resumed PTY", async () => { + (mocks.extractResumeCommandFromOutput as any).mockReturnValueOnce("claude --resume claude-session-123"); + const { service, sessionService, mockPty } = createHarness(); + sessionService.create({ + sessionId: "session-claude-picker", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Claude CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-claude-picker.log", + toolType: "claude", + resumeCommand: "claude --permission-mode default --resume", + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: null, + launch: { permissionMode: "default" }, + }, + }); + sessionService.end({ + sessionId: "session-claude-picker", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + await service.create({ + sessionId: "session-claude-picker", + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + startupCommand: "claude --permission-mode default --resume", + }); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith( + "session-claude-picker", + "claude --resume claude-session-123", + ); + expect(mockPty.write).toHaveBeenCalledWith("claude --resume claude-session-123\r"); + }); + it("preserves the strict resume path when a requested session id does not exist", async () => { const { service } = createHarness(); diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index a67bb1a7f..041f51c2d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -766,7 +766,7 @@ export function createPtyService({ const tryBackfillResumeTarget = async ( sessionId: string, preferredToolType: TerminalToolType | null, - reason: "close" | "dispose" | "orphan-dispose" | "session-list", + reason: "close" | "dispose" | "orphan-dispose" | "session-list" | "resume-launch", sessionCwd?: string | null, ): Promise => { const session = sessionService.get(sessionId); @@ -1104,15 +1104,15 @@ export function createPtyService({ const tracked = existingSession?.tracked ?? (args.tracked !== false); const toolTypeHint = normalizeToolType(args.toolType ?? existingSession?.toolType ?? null); const requestedStartupCommand = typeof args.startupCommand === "string" ? args.startupCommand.trim() : ""; - const initialResumeCommand = existingSession?.resumeCommand ?? defaultResumeCommandForTool(toolTypeHint); - const initialResumeMetadata = existingSession?.resumeMetadata ?? buildInitialResumeMetadata({ + let initialResumeCommand = existingSession?.resumeCommand ?? defaultResumeCommandForTool(toolTypeHint); + let initialResumeMetadata = existingSession?.resumeMetadata ?? buildInitialResumeMetadata({ toolType: toolTypeHint, startupCommand: requestedStartupCommand, }); const transcriptPath = tracked ? (existingSession?.transcriptPath?.trim() || safeTranscriptPathFor(sessionId)) : ""; - const startupCommand = requestedStartupCommand.trim(); + let startupCommand = requestedStartupCommand.trim(); const cleanupPaths: string[] = []; let transcriptStream: fs.WriteStream | null = null; @@ -1157,6 +1157,20 @@ export function createPtyService({ ...(args.env ?? {}) }; const launchEnv = getAdeCliAgentEnv?.(baseLaunchEnv) ?? baseLaunchEnv; + const shouldBackfillResumeTarget = + existingSession + && isTrackedCliToolType(toolTypeHint) + && !existingSession.resumeMetadata?.targetId?.trim(); + if (shouldBackfillResumeTarget) { + const backfilled = await tryBackfillResumeTarget(sessionId, toolTypeHint, "resume-launch", cwd); + const updatedSession = backfilled ? sessionService.get(sessionId) : null; + if (updatedSession?.resumeCommand?.trim()) { + initialResumeCommand = updatedSession.resumeCommand.trim(); + initialResumeMetadata = updatedSession.resumeMetadata ?? initialResumeMetadata; + startupCommand = initialResumeCommand; + } + } + const shellCandidates = resolveShellCandidates(); let pty: IPty; let selectedShell: ShellSpec | null = null; @@ -1262,19 +1276,6 @@ export function createPtyService({ .catch(() => {}); } - if ( - existingSession - && isTrackedCliToolType(toolTypeHint) - && !existingSession.resumeMetadata?.targetId?.trim() - ) { - logger.warn("pty.resume_target_missing", { - sessionId, - ptyId, - toolType: toolTypeHint, - reason: "resume-launch", - }); - } - const entry: PtyEntry = { pty, laneId, diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index b23db088e..4674bb0ab 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -63,6 +63,52 @@ function primaryTabPath(pathname: string): string { return roots.find((root) => pathname === root || pathname.startsWith(`${root}/`)) ?? pathname; } +const PROJECT_ROUTE_STORAGE_PREFIX = "ade:project-route:"; + +function projectRouteStorageKey(projectRoot: string): string { + return `${PROJECT_ROUTE_STORAGE_PREFIX}${projectRoot}`; +} + +function serializeLocationRoute(location: ReturnType): string | null { + const pathname = location.pathname || "/work"; + const route = `${pathname}${location.search ?? ""}${location.hash ?? ""}`; + const allowedRoots = [ + "/project", + "/lanes", + "/files", + "/work", + "/graph", + "/prs", + "/review", + "/history", + "/automations", + "/missions", + "/cto", + "/settings", + ]; + if (!allowedRoots.some((root) => pathname === root || pathname.startsWith(`${root}/`))) { + return null; + } + return route; +} + +function readStoredProjectRoute(projectRoot: string): string | null { + try { + const value = window.localStorage.getItem(projectRouteStorageKey(projectRoot)); + return value?.startsWith("/") ? value : null; + } catch { + return null; + } +} + +function writeStoredProjectRoute(projectRoot: string, route: string): void { + try { + window.localStorage.setItem(projectRouteStorageKey(projectRoot), route); + } catch { + // localStorage can be unavailable in private/test environments. + } +} + type AiBannerState = { laneId: string | null; jobId: string | null; @@ -206,6 +252,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [projectMissing, setProjectMissing] = useState(false); const [feedbackGenerating, setFeedbackGenerating] = useState(false); const previousProjectRootRef = useRef(undefined); + const lastRouteSaveProjectRootRef = useRef(undefined); const isOnboardingRoute = location.pathname === "/onboarding"; const isLanesRoute = location.pathname.startsWith("/lanes"); const shouldTrackTerminalAttention = @@ -582,10 +629,26 @@ export function AppShell({ children }: { children: React.ReactNode }) { if (previousProjectRoot === undefined) return; if (!nextProjectRoot || showWelcome) return; - if (location.pathname !== "/project") return; if (previousProjectRoot === nextProjectRoot) return; - navigate("/work", { replace: true }); - }, [location.pathname, navigate, project?.rootPath, showWelcome]); + if (previousProjectRoot) { + const previousRoute = serializeLocationRoute(location); + if (previousRoute) writeStoredProjectRoute(previousProjectRoot, previousRoute); + } + navigate(readStoredProjectRoute(nextProjectRoot) ?? "/work", { replace: true }); + }, [location, navigate, project?.rootPath, showWelcome]); + + useEffect(() => { + const projectRoot = project?.rootPath ?? null; + if (!projectRoot || showWelcome) return; + + if (lastRouteSaveProjectRootRef.current !== projectRoot) { + lastRouteSaveProjectRootRef.current = projectRoot; + return; + } + + const route = serializeLocationRoute(location); + if (route) writeStoredProjectRoute(projectRoot, route); + }, [location, project?.rootPath, showWelcome]); useEffect(() => { let cancelled = false;