diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index b08690a14..a5962a85a 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2435,6 +2435,9 @@ describe("adeRpcServer", () => { rows: 24, command: "codex", startupCommand: expect.stringContaining("codex --no-alt-screen"), + env: expect.objectContaining({ + ADE_AGENT_SKILLS_DIRS: expect.stringContaining(path.join("lane-1", "apps", "desktop", "resources", "agent-skills")), + }), }), ); expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenCalledWith("session-1", "fix failing tests\r"); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 85d2f5842..e560cbf98 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -31,7 +31,12 @@ import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../ import { runGit } from "../../desktop/src/main/services/git/git"; import { resolvePathWithinRoot } from "../../desktop/src/main/services/shared/utils"; import { getDefaultModelDescriptor } from "../../desktop/src/shared/modelRegistry"; -import { ADE_CLI_INLINE_GUIDANCE } from "../../desktop/src/shared/adeCliGuidance"; +import { buildAdeCliInlineGuidance } from "../../desktop/src/shared/adeCliGuidance"; +import { + ADE_AGENT_SKILLS_DIRS_ENV, + getAdeAgentSkillRootsForPrompt, + joinAdeAgentSkillRoots, +} from "../../desktop/src/shared/agentSkillRoots"; import { getPrIssueResolutionAvailability, isActionablePrIssueComment, @@ -2981,6 +2986,10 @@ function resolveLaneWorktreePath(runtime: AdeRuntime, laneId: string | null | un return null; } +function buildAdeInlineGuidanceForLane(laneWorktreePath: string | null | undefined): string { + return buildAdeCliInlineGuidance(getAdeAgentSkillRootsForPrompt({ cwd: laneWorktreePath ?? undefined })); +} + function resolveRunContextLaneId(runtime: AdeRuntime, callerCtx: CallerContext): string | null { const runId = asOptionalTrimmedString(callerCtx.runId); if (!runId) return null; @@ -5106,10 +5115,11 @@ async function runTool(args: { const model = asOptionalTrimmedString(toolArgs.model) ?? asOptionalTrimmedString(toolArgs.modelId); const ptyService = runtime.ptyService; const preassignedSessionId = provider === "claude" ? randomUUID() : undefined; + const laneWorktreePath = resolveLaneWorktreePath(runtime, laneId); const launchFields: { startupCommand?: string; command?: string; args?: string[]; env?: Record } = (() => { if (!isCliProvider(provider)) return {}; - return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId, model }); + return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId, model, laneWorktreePath }); })(); const created = await ptyService.create({ @@ -6812,7 +6822,7 @@ async function runTool(args: { }); const promptSegments: string[] = []; - promptSegments.push(ADE_CLI_INLINE_GUIDANCE); + promptSegments.push(buildAdeInlineGuidanceForLane(laneWorktreePath)); if (promptRunId || promptStepId || promptAttemptId) { promptSegments.push( `Mission context: run=${promptRunId ?? "n/a"} step=${promptStepId ?? "n/a"} attempt=${promptAttemptId ?? "n/a"}.` @@ -6881,6 +6891,8 @@ async function runTool(args: { // command remains a display/resume preview only; the actual launch uses // command/args/env so it works on Windows without POSIX inline assignment. const workerEnv: Record = {}; + const skillRootsEnv = joinAdeAgentSkillRoots(getAdeAgentSkillRootsForPrompt({ cwd: laneWorktreePath })); + if (skillRootsEnv) workerEnv[ADE_AGENT_SKILLS_DIRS_ENV] = skillRootsEnv; const envPrefixParts: string[] = []; const addWorkerEnv = (key: string, value: string | null | undefined) => { if (!value) return; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index c4a8a2b29..b15c689ee 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1440,6 +1440,17 @@ async function resolvePrimaryLaneIdOnlyForSync(args: SyncRemoteCommandServiceArg return lanes.find((lane) => lane.laneType === "primary")?.id ?? ""; } +function resolveLaneWorktreePathForSync(args: SyncRemoteCommandServiceArgs, laneId: string): string | null { + try { + const lane = args.laneService.getLaneBaseAndBranch(laneId); + const trimmed = typeof lane?.worktreePath === "string" ? lane.worktreePath.trim() : ""; + if (trimmed.length) return trimmed; + } catch { + // Ignore and let the caller fall back to process/app skill roots. + } + return null; +} + async function resolveLaneOverlayContext(args: SyncRemoteCommandServiceArgs, laneId: string) { const projectConfigService = requireService(args.projectConfigService, "Project config service not available."); const lanes = await args.laneService.list({ includeStatus: false }); @@ -1792,6 +1803,7 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg permissionMode, sessionId: preassignedSessionId, model: parsed.modelId ?? parsed.model ?? undefined, + laneWorktreePath: resolveLaneWorktreePathForSync(args, parsed.laneId), }); } diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index a095fe47e..5aa0bdf4a 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -310,6 +310,12 @@ describe("buildCodingAgentSystemPrompt", () => { expect(result).toContain("## User-Facing Progress"); expect(result).toContain("## Mission"); }); + + it("uses the active cwd when describing ADE skill roots", () => { + const result = buildCodingAgentSystemPrompt({ cwd: "/repo/.ade/worktrees/chat-lane" }); + + expect(result).toContain("/repo/.ade/worktrees/chat-lane/apps/desktop/resources/agent-skills"); + }); }); describe("composeSystemPrompt", () => { diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 3fe2a0b36..77dd5eee3 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -1,4 +1,5 @@ -import { ADE_CLI_AGENT_GUIDANCE } from "../../../../shared/adeCliGuidance"; +import { buildAdeCliAgentGuidance } from "../../../../shared/adeCliGuidance"; +import { getAdeAgentSkillRootsForPrompt } from "../../../../shared/agentSkillRoots"; type HarnessMode = "chat" | "coding" | "planning"; type HarnessPermissionMode = "plan" | "edit" | "full-auto"; @@ -82,6 +83,7 @@ export function buildCodingAgentSystemPrompt(args: { toolNames?: string[]; interactive?: boolean; runtime?: AdeRuntimeKind; + adeSkillRoots?: readonly string[]; }): string { const mode = args.mode ?? "coding"; const permissionMode = args.permissionMode ?? "edit"; @@ -103,6 +105,7 @@ export function buildCodingAgentSystemPrompt(args: { const hasTodoTools = toolNames.includes("TodoWrite") || toolNames.includes("TodoRead"); const hasWorkflowTools = hasCreateLane || hasCreatePr || hasCaptureScreenshot || hasReportCompletion; const guardedLocalReadOnly = permissionMode === "plan"; + const adeSkillRoots = args.adeSkillRoots ?? getAdeAgentSkillRootsForPrompt({ cwd: args.cwd }); const PR_ISSUE_TOOL_NAMES = new Set([ "prGetChecks", "prGetReviewComments", @@ -181,7 +184,7 @@ export function buildCodingAgentSystemPrompt(args: { : "If requirements are unclear, make the safest reasonable assumption and continue. State the assumption in the final answer.", "If tool results fail or contradict the current plan, synthesize the finding and adapt rather than repeating the same failing action.", "", - ADE_CLI_AGENT_GUIDANCE, + buildAdeCliAgentGuidance(adeSkillRoots), ...(hasMemoryTools ? [ "", diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f73bf79c2..add6c0a32 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -215,7 +215,8 @@ import { reportProviderRuntimeReady, } from "../ai/providerRuntimeHealth"; import { resolveAdeLayout } from "../../../shared/adeLayout"; -import { ADE_CLI_AGENT_GUIDANCE } from "../../../shared/adeCliGuidance"; +import { buildAdeCliAgentGuidance } from "../../../shared/adeCliGuidance"; +import { getAdeAgentSkillRootsForPrompt } from "../../../shared/agentSkillRoots"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { extractLeadingSlashCommand, isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import { stripAnsi } from "../../utils/ansiStrip"; @@ -1599,6 +1600,7 @@ const DEFAULT_TRANSCRIPT_READ_CHARS = 8_000; const MAX_TRANSCRIPT_READ_CHARS = 40_000; const AUTOMATIC_MACOS_VM_CONTEXT_HEADER = "ADE macOS VM capability for this lane (automatic context)."; const AUTOMATIC_MACOS_VM_CONTEXT_ENDINGS = [ + "- Tools: macos_vm_status, macos_vm_start, macos_vm_screenshot, macos_vm_click, macos_vm_type.", "- This lane uses a sanitized mirror for the VM share; ADE syncs code while excluding secrets, runtime databases, caches, transcripts, generated local history, and .git.", "- Keep VM-side edits inside the mounted guest lane path so the host lane and guest stay aligned.", ] as const; @@ -3544,6 +3546,10 @@ function toHarnessPermissionMode( return "edit"; } +function buildAdeGuidanceForLane(laneWorktreePath: string): string { + return buildAdeCliAgentGuidance(getAdeAgentSkillRootsForPrompt({ cwd: laneWorktreePath })); +} + function buildCodexDeveloperInstructions(args: { laneWorktreePath: string; session: Pick; @@ -3558,6 +3564,7 @@ function buildCodexDeveloperInstructions(args: { permissionMode: toHarnessPermissionMode(args.session.permissionMode), interactive: true, runtime: "codex-app-server", + adeSkillRoots: getAdeAgentSkillRootsForPrompt({ cwd: args.laneWorktreePath }), }); } @@ -10243,7 +10250,7 @@ export function createAgentChatService(args: { "DO NOT save: file paths, raw error messages without lessons, task progress updates, information derivable from git log or the code itself, obvious patterns already visible in the codebase.", ...slashCommandsSection, "", - ADE_CLI_AGENT_GUIDANCE, + buildAdeGuidanceForLane(managed.laneWorktreePath), ].join("\n"); }; @@ -13735,7 +13742,7 @@ export function createAgentChatService(args: { "DO NOT save: file paths, raw error messages without lessons, task progress updates, information derivable from git log or the code itself, obvious patterns already visible in the codebase.", ...slashCommandsSection, "", - ADE_CLI_AGENT_GUIDANCE, + buildAdeGuidanceForLane(managed.laneWorktreePath), ].join("\n"), }; opts.settingSources = ["user", "project", "local"]; @@ -15112,7 +15119,7 @@ export function createAgentChatService(args: { const laneDirectiveKey = executionContext.laneDirectiveKey; const shouldInjectLaneDirective = laneDirectiveKey != null && managed.lastLaneDirectiveKey !== laneDirectiveKey; // Guidance injection is capability-based, not session-state-based: - // Claude sessions receive ADE_CLI_AGENT_GUIDANCE in their persistent system + // Claude sessions receive lane-scoped ADE guidance in their persistent system // prompt, and native Codex app-server sessions receive ADE guidance through // developerInstructions/collaborationMode settings. Providers without a // trusted instruction channel still need the guidance in the user prompt, @@ -15162,7 +15169,7 @@ export function createAgentChatService(args: { : null, buildExecutionModeDirective(executionMode, managed.session.provider), buildClaudeInteractionModeDirective(managed.session.interactionMode, managed.session.provider), - shouldInjectGuidance ? ADE_CLI_AGENT_GUIDANCE : null, + shouldInjectGuidance ? buildAdeGuidanceForLane(managed.laneWorktreePath) : null, buildComputerUseDirective( computerUseArtifactBrokerRef?.getBackendStatus() ?? null, ), diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index b5f0b2c1b..9e5a2b983 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -4084,6 +4084,7 @@ describe("aiOrchestratorService", () => { }); it("recovers running attempts with tracked sessions that go silent", async () => { + vi.useRealTimers(); const fixture = await createFixture({ aiIntegrationService: createStagnationRecoveryAiIntegrationService() }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 690ee4237..094940a23 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -3701,6 +3701,7 @@ export function AgentChatMessageList({ // coalesce every source (ResizeObserver, stick-flip effect, jump button) // into at most one scrollTop assignment per frame. const scrollRafRef = useRef(null); + const scrollFollowFramesRef = useRef(0); // Each programmatic scroll write increments this counter; the matching // scroll event then decrements it and skips stick-state updates. Keeps // user gestures as the only thing that toggles auto-follow off. @@ -3837,26 +3838,37 @@ export function AgentChatMessageList({ // - A ResizeObserver on the content wrapper picks up every size change — // new rows appearing *and* streaming tokens extending existing rows — // without the old MutationObserver's characterData firehose. - const scrollToBottomSoon = useCallback(() => { + const scrollToBottomSoon = useCallback((followUpFrames = 1) => { + scrollFollowFramesRef.current = Math.max(scrollFollowFramesRef.current, followUpFrames); if (scrollRafRef.current !== null) return; - scrollRafRef.current = requestAnimationFrame(() => { + const run = () => { scrollRafRef.current = null; const el = scrollRef.current; - if (!el || !stickToBottomRef.current) return; - const target = el.scrollHeight - el.clientHeight; - if (target <= 0) return; + if (!el || !stickToBottomRef.current) { + scrollFollowFramesRef.current = 0; + return; + } + const target = Math.max(0, el.scrollHeight - el.clientHeight); const before = el.scrollTop; - if (Math.abs(before - target) < 1) return; - el.scrollTop = target; - // Only register a pending programmatic scroll event if the assignment - // actually moved the element. Otherwise (clamped to the same value, - // hidden element, etc.) no scroll event will fire and the counter - // would stay positive forever, misclassifying the next real user - // scroll as programmatic. - if (el.scrollTop !== before) { - programmaticScrollCountRef.current += 1; + if (Math.abs(before - target) >= 1) { + el.scrollTop = target; + setScrollTop(el.scrollTop); + // Only register a pending programmatic scroll event if the assignment + // actually moved the element. Otherwise (clamped to the same value, + // hidden element, etc.) no scroll event will fire and the counter + // would stay positive forever, misclassifying the next real user + // scroll as programmatic. + if (el.scrollTop !== before) { + programmaticScrollCountRef.current += 1; + } } - }); + const remaining = scrollFollowFramesRef.current; + if (remaining > 0) { + scrollFollowFramesRef.current = remaining - 1; + scrollRafRef.current = requestAnimationFrame(run); + } + }; + scrollRafRef.current = requestAnimationFrame(run); }, []); useEffect(() => () => { @@ -3864,6 +3876,7 @@ export function AgentChatMessageList({ cancelAnimationFrame(scrollRafRef.current); scrollRafRef.current = null; } + scrollFollowFramesRef.current = 0; }, []); // When the user re-enters the sticky zone (or on first mount), snap to bottom. @@ -3884,7 +3897,7 @@ export function AgentChatMessageList({ return; } const ro = new ResizeObserver(() => { - if (stickToBottomRef.current) scrollToBottomSoon(); + if (stickToBottomRef.current) scrollToBottomSoon(2); }); ro.observe(wrapper); return () => ro.disconnect(); @@ -3945,14 +3958,18 @@ export function AgentChatMessageList({ } // Debounce measurement tick updates to batch rapid height changes // into a single re-render instead of one per row. - if (!measureFlushTimer.current) { - measureFlushTimer.current = setTimeout(() => { - measureFlushTimer.current = null; - setMeasurementTick((value) => value + 1); - }, 80); + const isFollowingBottom = stickToBottomRef.current; + if (measureFlushTimer.current) { + if (!isFollowingBottom) return; + clearTimeout(measureFlushTimer.current); } + measureFlushTimer.current = setTimeout(() => { + measureFlushTimer.current = null; + setMeasurementTick((value) => value + 1); + if (isFollowingBottom) scrollToBottomSoon(2); + }, isFollowingBottom ? 16 : 80); } - }, [rowHeight, shouldVirtualize, timelineRowGapPx]); + }, [rowHeight, scrollToBottomSoon, shouldVirtualize, timelineRowGapPx]); // Compute the visible window of rows when virtualization is active. // measurementTick forces recomputation when row heights are measured so @@ -3973,6 +3990,10 @@ export function AgentChatMessageList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [shouldVirtualize, groupedRows.length, scrollTop, containerHeight, rowHeight, measurementTick, timelineRowGapPx]); + useLayoutEffect(() => { + if (stickToBottomRef.current) scrollToBottomSoon(2); + }, [containerHeight, groupedRows.length, measurementTick, scrollToBottomSoon, shouldVirtualize, totalHeight]); + const handleScroll = useCallback((event: React.UIEvent) => { const target = event.currentTarget; // Absorb scroll events produced by our own programmatic scroll-to-bottom diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 095cc5fc6..7ca926f8a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -739,6 +739,46 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("does not inject lane macOS VM context into unrelated sends", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { send } = installAdeMocks({ sessions: [session] }); + (window.ade as any).macosVm = { + getStatus: vi.fn().mockResolvedValue({ + platform: "darwin", + arch: "arm64", + supported: true, + checkedAt: "2026-05-07T00:00:00.000Z", + activeProvider: { + kind: "lume", + available: true, + version: "0.3.9", + detail: "Lume is available.", + docsUrl: "https://cua.ai/docs/lume/guide/fundamentals/vm-management", + }, + tools: [], + laneVm: null, + vms: [], + docs: {}, + }), + onEvent: vi.fn().mockImplementation(() => () => undefined), + }; + + renderPane(session); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Fix the PR header action." } }); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => { + expect(window.ade.macosVm.getStatus).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: session.sessionId, + displayText: "Fix the PR header action.", + text: "Fix the PR header action.", + })); + }); + }); + it("shows an optimistic queued bubble immediately for Cursor-style sends", async () => { const session = buildSession("session-1", { status: "idle" }); let resolveSend!: () => void; @@ -859,6 +899,36 @@ describe("AgentChatPane submit recovery", () => { }); }); + it("renders committed user messages without waiting for the debounced event flush", async () => { + const session = buildSession("session-1", { status: "idle" }); + const { emitChatEvent } = installAdeMocks({ + sessions: [session], + }); + + renderPane(session); + + await screen.findByRole("textbox"); + + vi.useFakeTimers(); + try { + act(() => { + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-03-24T05:57:46.000Z", + sequence: 1, + event: { + type: "user_message", + text: "Render this committed message immediately.", + }, + }); + }); + + expect(screen.getByText("Render this committed message immediately.")).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); + it("keeps the draft cleared after steer succeeds even if session refresh fails", async () => { const session = buildSession("session-1"); const { steer } = installAdeMocks({ @@ -1884,6 +1954,112 @@ describe("AgentChatPane submit recovery", () => { expect(readTranscriptTail).toHaveBeenCalledWith(expect.objectContaining({ sessionId: session.sessionId })); }); + it("streams live events into visible inactive grid tiles without requiring focus", async () => { + const session = buildSession("grid-live-chat", { + title: "Grid live chat", + }); + const { emitChatEvent } = installAdeMocks({ sessions: [session] }); + window.ade.sessions.readTranscriptTail = vi.fn().mockResolvedValue("") as any; + + render( + + + , + ); + + await screen.findByRole("textbox"); + + vi.useFakeTimers(); + try { + act(() => { + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-03-24T06:00:02.000Z", + sequence: 2, + event: { + type: "text", + text: "Visible inactive grid tile streamed", + turnId: "turn-grid-live", + messageId: "assistant-grid-live", + }, + }); + }); + + expect(screen.getByText("Visible inactive grid tile streamed")).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); + + it("poll-recovers visible active grid tiles when live IPC misses an event", async () => { + vi.useFakeTimers(); + const session = buildSession("grid-recovery-chat", { + title: "Grid recovery chat", + status: "active", + }); + installAdeMocks({ sessions: [session] }); + let transcript = ""; + const readTranscriptTail = vi.fn().mockImplementation(async () => transcript); + window.ade.sessions.readTranscriptTail = readTranscriptTail as any; + + try { + render( + + + , + ); + + await act(async () => { + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(600); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.queryByText("Recovered grid tile output")).toBeNull(); + + transcript = `${JSON.stringify({ + sessionId: session.sessionId, + timestamp: "2026-03-24T06:00:03.000Z", + sequence: 3, + event: { + type: "text", + text: "Recovered grid tile output", + turnId: "turn-grid-recovery", + messageId: "assistant-grid-recovery", + }, + })}\n`; + + await act(async () => { + vi.advanceTimersByTime(5000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByText("Recovered grid tile output")).toBeTruthy(); + expect(readTranscriptTail).toHaveBeenCalledWith(expect.objectContaining({ sessionId: session.sessionId })); + } finally { + vi.useRealTimers(); + } + }); + it("does not hydrate hidden inactive chat tiles", async () => { vi.useFakeTimers(); const session = buildSession("hidden-inactive-chat", { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 4bc179409..43c59ca62 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1897,6 +1897,11 @@ export function AgentChatPane({ const lane = lanes.find((entry) => entry.id === scopedLaneId); return lane?.worktreePath ?? projectRoot; }, [laneId, lanes, projectRoot, selectedSession?.laneId]); + const activeLaneWorktreePath = useMemo(() => { + if (!laneId) return projectRoot; + const lane = lanes.find((entry) => entry.id === laneId); + return lane?.worktreePath ?? projectRoot; + }, [laneId, lanes, projectRoot]); const selectedEvents = selectedSessionId ? eventsBySession[selectedSessionId] ?? [] : []; const optimisticOutgoingMessageRef = useRef(null); @@ -3429,6 +3434,39 @@ export function AgentChatPane({ return () => window.clearTimeout(handle); }, [isTileActive, isTileVisible, loadHistory, lockedSingleSessionMode, selectedSessionId]); + useEffect(() => { + if (!isTileVisible || !selectedSessionId) return undefined; + const shouldRecoverLiveTranscript = + turnActive + || selectedSession?.status === "active" + || selectedSessionAwaitingInput; + if (!shouldRecoverLiveTranscript) return undefined; + + let disposed = false; + const offset = stableSessionDelayOffset(selectedSessionId); + const initialDelayMs = isTileActive ? 900 : 1200 + (offset % 500); + const intervalMs = isTileActive ? 2200 : 2800 + (offset % 700); + const recover = () => { + if (disposed) return; + void loadHistory(selectedSessionId, { force: true }); + }; + const initialTimer = window.setTimeout(recover, initialDelayMs); + const intervalTimer = window.setInterval(recover, intervalMs); + return () => { + disposed = true; + window.clearTimeout(initialTimer); + window.clearInterval(intervalTimer); + }; + }, [ + isTileActive, + isTileVisible, + loadHistory, + selectedSession?.status, + selectedSessionAwaitingInput, + selectedSessionId, + turnActive, + ]); + useEffect(() => { if (!isTileActive) { setComputerUseSnapshot(null); @@ -3641,9 +3679,15 @@ export function AgentChatPane({ }); } - // "done" events must flush immediately so turnActive clears and the - // spinner stops. Other events can use the debounced 16ms schedule. - if (envelope.event.type === "done") { + // User messages and lifecycle edges must flush immediately so the + // optimistic bubble cannot disappear behind the 16ms debounce and + // visible grid tiles show fresh activity without requiring focus. + if ( + envelope.event.type === "done" + || envelope.event.type === "user_message" + || envelope.event.type === "status" + || (layoutVariant === "grid-tile" && isTileVisible) + ) { if (eventFlushTimerRef.current != null) { window.clearTimeout(eventFlushTimerRef.current); eventFlushTimerRef.current = null; @@ -3712,7 +3756,7 @@ export function AgentChatPane({ } }); return unsubscribe; - }, [lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); + }, [isTileVisible, layoutVariant, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); useEffect(() => { if (!isTileActive) return undefined; @@ -4255,7 +4299,9 @@ export function AgentChatPane({ snapshot: DraftLaunchSnapshot, targetLaneId: string, ): Promise => { - const automaticMacosVmContextPrefix = await buildAutomaticMacosVmContextForPrompt(targetLaneId); + const automaticMacosVmContextPrefix = await buildAutomaticMacosVmContextForPrompt(targetLaneId, { + promptText: snapshot.text, + }); const finalTextPrefix = [automaticMacosVmContextPrefix, snapshot.visualContextPrefix].filter(Boolean).join("\n"); let finalText = finalTextPrefix ? `${finalTextPrefix}${snapshot.text}` : snapshot.text; if (!finalText.trim().length && snapshot.contextAttachments.length) { @@ -4913,7 +4959,9 @@ export function AgentChatPane({ } try { - const automaticMacosVmContextPrefix = await buildAutomaticMacosVmContextForPrompt(laneId); + const automaticMacosVmContextPrefix = await buildAutomaticMacosVmContextForPrompt(laneId, { + promptText: text, + }); let justCreatedSession = false; const finalTextPrefix = [automaticMacosVmContextPrefix, visualContextPrefix].filter(Boolean).join("\n"); let finalText = finalTextPrefix ? `${finalTextPrefix}${text}` : text; @@ -4972,6 +5020,7 @@ export function AgentChatPane({ model: runtimeModel, reasoningEffort, initialPrompt: cliPrompt, + laneWorktreePath: activeLaneWorktreePath, }); await onLaunchCliSession({ laneId, @@ -5137,6 +5186,7 @@ export function AgentChatPane({ forceDraft, embeddedWorkLayout, projectRoot, + activeLaneWorktreePath, navigate, onLaunchCliSession, buildNativeControlPayloadForSlot, diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx index 58f9203cb..d63c1937c 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx @@ -110,6 +110,16 @@ describe("ChatGitToolbar", () => { expect(screen.getByTestId("location").textContent).toBe("/lanes/lane-1"); }); + it("opens the PR creation handoff when the current lane has no linked PR", async () => { + renderToolbar(); + + fireEvent.click(await screen.findByRole("button", { name: "PR" })); + + expect(screen.getByTestId("location").textContent).toBe( + "/prs?tab=normal&create=1&sourceLaneId=lane-1&target=primary", + ); + }); + it("opens the Run menu from the chat Git toolbar without starting commands", async () => { renderToolbar(); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index 35c633af7..b44ae7445 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -276,9 +276,15 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ if (linkedPr) { navigate(`/prs?tab=normal&prId=${encodeURIComponent(linkedPr.id)}`); } else { - navigate("/prs"); + const params = new URLSearchParams({ + tab: "normal", + create: "1", + sourceLaneId: laneId, + target: "primary", + }); + navigate(`/prs?${params.toString()}`); } - }, [linkedPr, navigate]); + }, [laneId, linkedPr, navigate]); // Reset menu state when the linked PR identity changes (lane switch, PR // unlinked) so stale data from another PR doesn't show. diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index b8bc1dfbf..0b9ecc7dd 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -333,7 +333,8 @@ export function LanesPage() { const [laneActionStatus, setLaneActionStatus] = useState(null); const [laneActionError, setLaneActionError] = useState(null); const [laneActionKind, setLaneActionKind] = useState<"delete" | "archive" | "adopt" | null>(null); - const [deleteProgressByLaneId, setDeleteProgressByLaneId] = useState>({}); + const deleteProgressByLaneId = useAppStore((s) => s.laneDeleteProgressByLaneId); + const setDeleteProgressByLaneId = useAppStore((s) => s.setLaneDeleteProgressByLaneId); const laneDeleteWarningMessagesRef = useRef>(new Map()); const [managedLaneIds, setManagedLaneIds] = useState([]); const [conflictChipsByLane, setConflictChipsByLane] = useState>({}); @@ -369,6 +370,7 @@ export function LanesPage() { const pendingLaneDeleteRefreshIdsRef = useRef>(new Set()); const laneDeleteRefreshTimerRef = useRef(null); const hydratedLaneDeleteProgressProjectRef = useRef(null); + const deleteProgressProjectRootRef = useRef(project?.rootPath ?? null); const activeLanePresenceSignatureRef = useRef(null); // Refs for the onDeleteEvent IPC handler. Capturing high-churn values // (selectedLaneId, lanesById, managedLaneIds, manageOpen) in refs lets the @@ -409,6 +411,9 @@ export function LanesPage() { }, []); useEffect(() => { + const projectRoot = project?.rootPath ?? null; + const previousProjectRoot = deleteProgressProjectRootRef.current; + deleteProgressProjectRootRef.current = projectRoot; hydratedLaneDeleteProgressProjectRef.current = null; completedLaneDeleteRefreshesRef.current.clear(); pendingLaneDeleteRefreshIdsRef.current.clear(); @@ -416,8 +421,10 @@ export function LanesPage() { window.clearTimeout(laneDeleteRefreshTimerRef.current); laneDeleteRefreshTimerRef.current = null; } - setDeleteProgressByLaneId({}); - }, [project?.rootPath]); + if (previousProjectRoot !== projectRoot) { + setDeleteProgressByLaneId({}); + } + }, [project?.rootPath, setDeleteProgressByLaneId]); const laneSnapshotByLaneId = useMemo( () => new Map(laneSnapshots.map((snapshot) => [snapshot.lane.id, snapshot] as const)), @@ -787,6 +794,15 @@ export function LanesPage() { }, LANE_DELETE_REFRESH_DEBOUNCE_MS); }, [refreshLanes]); + const queueLaneDeleteRefresh = useCallback((laneIds: string[]) => { + for (const laneId of laneIds) { + if (laneId) pendingLaneDeleteRefreshIdsRef.current.add(laneId); + } + if (pendingLaneDeleteRefreshIdsRef.current.size > 0) { + scheduleLaneDeleteRefresh(); + } + }, [scheduleLaneDeleteRefresh]); + /* ---- Effects ---- */ // Mirror high-churn values into refs so the IPC subscription below doesn't @@ -843,11 +859,10 @@ export function LanesPage() { const remainingWarnings = formatLaneDeleteWarningMessages(laneDeleteWarningMessagesRef.current); setLaneActionError((current) => remainingWarnings ?? (current && /\bdelet(?:e|ed|ing)\b/i.test(current) ? null : current)); } - pendingLaneDeleteRefreshIdsRef.current.add(laneId); - scheduleLaneDeleteRefresh(); + queueLaneDeleteRefresh([laneId]); }); return unsubscribe; - }, [clearLaneInspectorTab, scheduleLaneDeleteRefresh, selectLane]); + }, [clearLaneInspectorTab, queueLaneDeleteRefresh, selectLane, setDeleteProgressByLaneId]); useEffect(() => { const unsubscribe = window.ade.conflicts.onEvent((event) => { @@ -1064,7 +1079,7 @@ export function LanesPage() { } return Object.keys(next).length === Object.keys(prev).length ? prev : next; }); - }, [lanesById]); + }, [lanesById, setDeleteProgressByLaneId]); useEffect(() => { const pinned = Array.from(pinnedLaneIds).filter((laneId) => lanesById.has(laneId) && !deletingLaneIds.has(laneId)); @@ -1410,41 +1425,63 @@ export function LanesPage() { useEffect(() => { const projectRoot = project?.rootPath ?? null; - if (!projectRoot || !window.ade.lanes.listDeleteProgress) return; + if (!projectRoot) return; if (hydratedLaneDeleteProgressProjectRef.current === projectRoot) return; hydratedLaneDeleteProgressProjectRef.current = projectRoot; let cancelled = false; + const getStoredActiveLaneIds = () => Object.values(useAppStore.getState().laneDeleteProgressByLaneId) + .filter(isLaneDeleteProgressActive) + .map((progress) => progress.laneId); + const recoverStoredActiveLaneDeletes = () => { + const storedActiveLaneIds = getStoredActiveLaneIds(); + if (storedActiveLaneIds.length === 0) return; + moveAwayFromDeletingLanes(storedActiveLaneIds); + queueLaneDeleteRefresh(storedActiveLaneIds); + }; + if (!window.ade.lanes.listDeleteProgress) { + recoverStoredActiveLaneDeletes(); + return; + } void window.ade.lanes.listDeleteProgress() .then((progresses) => { if (cancelled) return; - const activeProgresses = progresses.filter(isLaneDeleteProgressActive); - if (activeProgresses.length === 0) return; - const laneIds = activeProgresses.map((progress) => progress.laneId); - setDeleteProgressByLaneId((prev) => { - const next = { ...prev }; - for (const progress of activeProgresses) { - next[progress.laneId] = progress; - } - return next; - }); + const activeProgresses = (Array.isArray(progresses) ? progresses : []).filter(isLaneDeleteProgressActive); + const activeProgressLaneIds = new Set(activeProgresses.map((progress) => progress.laneId)); + const storedActiveLaneIds = getStoredActiveLaneIds(); + const laneIdsWithoutBackendProgress = storedActiveLaneIds.filter((laneId) => !activeProgressLaneIds.has(laneId)); + const laneIds = mergeUnique( + activeProgresses.map((progress) => progress.laneId), + laneIdsWithoutBackendProgress, + ); + if (laneIds.length === 0) return; + if (activeProgresses.length > 0) { + setDeleteProgressByLaneId((prev) => { + const next = { ...prev }; + for (const progress of activeProgresses) { + next[progress.laneId] = progress; + } + return next; + }); + } moveAwayFromDeletingLanes(laneIds); + const refreshLaneIds = [...laneIdsWithoutBackendProgress]; for (const progress of activeProgresses) { if (progress.overallStatus !== "completed" && progress.overallStatus !== "completed_with_warnings") continue; if (completedLaneDeleteRefreshesRef.current.has(progress.laneId)) continue; completedLaneDeleteRefreshesRef.current.add(progress.laneId); - pendingLaneDeleteRefreshIdsRef.current.add(progress.laneId); - } - if (pendingLaneDeleteRefreshIdsRef.current.size > 0) { - scheduleLaneDeleteRefresh(); + refreshLaneIds.push(progress.laneId); } + queueLaneDeleteRefresh(refreshLaneIds); }) .catch((error) => { + if (cancelled) return; + recoverStoredActiveLaneDeletes(); console.debug("Failed to hydrate lane delete progress:", error); }); return () => { cancelled = true; }; - }, [project?.rootPath, moveAwayFromDeletingLanes, scheduleLaneDeleteRefresh]); + }, [project?.rootPath, moveAwayFromDeletingLanes, queueLaneDeleteRefresh, setDeleteProgressByLaneId]); const deleteManagedLanes = async () => { const targets = isBatchManage ? managedLanes : managedLane ? [managedLane] : []; diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx index 938e034b8..3402e5b0f 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx @@ -238,6 +238,22 @@ describe("CreatePrModal queue workflow", () => { ); }); + it("prefills a single PR from the requested lane into the primary branch", async () => { + renderWithRouter( + , + ); + + const sourceSelect = document.querySelector('[data-tour="prs.createModal.source"]') as HTMLSelectElement | null; + const targetInput = document.querySelector('[data-tour="prs.createModal.base"]') as HTMLInputElement | null; + + await waitFor(() => expect(sourceSelect?.value).toBe("lane-2")); + expect(targetInput?.value).toBe("main"); + }); + it("defaults single-PR title and body from a linked Linear issue", async () => { const user = userEvent.setup(); renderWithRouter(); diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index e6ce736e1..0070c99e4 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -24,6 +24,10 @@ import { branchNameFromRef, describePrTargetDiff, resolveLaneBaseBranch } from " import { buildLaneRebaseRecommendedLaneIds, describeLanePrIssues } from "./shared/lanePrWarnings"; type CreateMode = "normal" | "queue" | "integration"; +export type CreatePrModalInitialValues = { + sourceLaneId?: string | null; + target?: "primary" | null; +}; /** Alias mapping from old `C` tokens to centralized COLORS. */ const C = { @@ -504,10 +508,12 @@ export function CreatePrModal({ open, onOpenChange, onCreated, + initialValues = null, }: { open: boolean; onOpenChange: (open: boolean) => void; onCreated?: (created: PrSummary[]) => void | Promise; + initialValues?: CreatePrModalInitialValues | null; }) { const navigate = useNavigate(); const lanes = useAppStore((s) => s.lanes); @@ -821,6 +827,42 @@ export function CreatePrModal({ }); }, [knownPrs, lanes, open, primaryLane?.branchRef, selectedNormalLane]); + const appliedInitialValuesKeyRef = React.useRef(null); + React.useEffect(() => { + if (!open) { + appliedInitialValuesKeyRef.current = null; + return; + } + + const sourceLaneId = initialValues?.sourceLaneId?.trim() ?? ""; + const target = initialValues?.target ?? null; + const key = `${sourceLaneId}:${target ?? ""}`; + if (!sourceLaneId && !target) return; + if (appliedInitialValuesKeyRef.current === key) return; + + const sourceLane = sourceLaneId + ? lanes.find((lane) => lane.id === sourceLaneId && lane.laneType !== "primary") ?? null + : null; + if (sourceLaneId && !sourceLane && lanes.length === 0) return; + appliedInitialValuesKeyRef.current = key; + + setMode("normal"); + setNumericStep(1); + setExecError(null); + setResults(null); + setQueueErrors([]); + setIntegrationResult(null); + setProposal(null); + + if (sourceLane) { + setNormalLaneId(sourceLane.id); + } + if (target === "primary") { + normalBaseBranchDefaultRef.current = ""; + setNormalBaseBranch(branchNameFromRef(primaryLane?.branchRef ?? "main") || "main"); + } + }, [initialValues, lanes, open, primaryLane?.branchRef]); + const handleSimulate = async () => { if (integrationSources.length === 0) return; setSimulating(true); diff --git a/apps/desktop/src/renderer/components/prs/PRsPage.tsx b/apps/desktop/src/renderer/components/prs/PRsPage.tsx index 96be9b62a..5f21ff955 100644 --- a/apps/desktop/src/renderer/components/prs/PRsPage.tsx +++ b/apps/desktop/src/renderer/components/prs/PRsPage.tsx @@ -4,7 +4,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { EmptyState } from "../ui/EmptyState"; import { cn } from "../ui/cn"; import { PrsProvider, usePrs } from "./state/PrsContext"; -import { CreatePrModal } from "./CreatePrModal"; +import { CreatePrModal, type CreatePrModalInitialValues } from "./CreatePrModal"; import { useAppStore } from "../../state/appStore"; import { useDialogBus } from "../../lib/useDialogBus"; import { GitHubTab } from "./tabs/GitHubTab"; @@ -49,6 +49,59 @@ function writeLastWorkflowTab(projectRoot: string | null, tab: WorkflowCategory) } } +function parseParams(search: string): URLSearchParams { + return new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); +} + +function parseHashParams(hash: string): URLSearchParams { + const queryIndex = hash.indexOf("?"); + if (queryIndex < 0) return new URLSearchParams(); + return parseParams(hash.slice(queryIndex + 1)); +} + +function createInitialValuesFromParams(params: URLSearchParams): CreatePrModalInitialValues | null { + const create = params.get("create"); + if (create !== "1" && create !== "true") return null; + + const sourceLaneId = (params.get("sourceLaneId") ?? params.get("laneId") ?? "").trim(); + const target = params.get("target") === "primary" ? "primary" : null; + return { + sourceLaneId: sourceLaneId || null, + target, + }; +} + +function readCreatePrRouteRequest(args: { search: string; hash: string }): { + key: string; + initialValues: CreatePrModalInitialValues; +} | null { + const searchParams = parseParams(args.search); + const searchInitialValues = createInitialValuesFromParams(searchParams); + if (searchInitialValues) { + return { + key: searchParams.toString(), + initialValues: searchInitialValues, + }; + } + + const hashParams = parseHashParams(args.hash); + if (!args.hash.startsWith("#/prs")) return null; + const initialValues = createInitialValuesFromParams(hashParams); + if (!initialValues) return null; + return { + key: hashParams.toString(), + initialValues, + }; +} + +function createInitialValuesFromDialogProps(props?: Record): CreatePrModalInitialValues | null { + if (!props) return null; + const sourceLaneId = typeof props.sourceLaneId === "string" ? props.sourceLaneId.trim() : ""; + const target = props.target === "primary" ? "primary" : null; + if (!sourceLaneId && !target) return null; + return { sourceLaneId: sourceLaneId || null, target }; +} + function PRsPageInner() { const navigate = useNavigate(); const location = useLocation(); @@ -73,6 +126,8 @@ function PRsPageInner() { } = usePrs(); const [createPrOpen, setCreatePrOpen] = React.useState(false); + const [createPrInitialValues, setCreatePrInitialValues] = React.useState(null); + const consumedCreateRouteKeyRef = React.useRef(null); const [lastWorkflowTab, setLastWorkflowTab] = React.useState(() => readLastWorkflowTab(projectRoot)); const [integrationRefreshNonce, setIntegrationRefreshNonce] = React.useState(0); const [selectedDetailTab, setSelectedDetailTab] = React.useState(() => { @@ -109,7 +164,10 @@ function PRsPageInner() { if (!targeted) setIntegrationRefreshNonce((prev) => prev + 1); }, [refresh, refreshLanes]); - const openCreatePr = React.useCallback(() => setCreatePrOpen(true), []); + const openCreatePr = React.useCallback((props?: Record) => { + setCreatePrInitialValues(createInitialValuesFromDialogProps(props)); + setCreatePrOpen(true); + }, []); const closeCreatePr = React.useCallback(() => setCreatePrOpen(false), []); useDialogBus("prs.create", { @@ -133,6 +191,10 @@ function PRsPageInner() { hash: window.location.hash, }); const resolved = resolvePrsActiveTab(routeState); + const createRequest = readCreatePrRouteRequest({ + search: location.search, + hash: window.location.hash, + }); const routeRebaseItemId = resolveRouteRebaseSelection({ rebaseNeeds, routeItemId: routeState.laneId, @@ -153,6 +215,13 @@ function PRsPageInner() { if (resolved.effectiveWorkflow === "rebase") { setSelectedRebaseItemId(routeRebaseItemId); } + if (createRequest && consumedCreateRouteKeyRef.current !== createRequest.key) { + consumedCreateRouteKeyRef.current = createRequest.key; + setCreatePrInitialValues(createRequest.initialValues); + setCreatePrOpen(true); + } else if (!createRequest) { + consumedCreateRouteKeyRef.current = null; + } } catch { // Ignore malformed URLs and fall back to current state. } @@ -202,6 +271,7 @@ function PRsPageInner() { React.useEffect(() => { if (location.pathname !== "/prs") return; + if (readCreatePrRouteRequest({ search: location.search, hash: window.location.hash })) return; writeStoredPrsRoute(`${location.pathname}${location.search}`, projectRoot); }, [location.pathname, location.search, projectRoot]); @@ -353,7 +423,7 @@ function PRsPageInner() {