diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 684f8504..bdc272a7 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -703,8 +703,11 @@ app.whenReady().then(async () => { const projectInitPromises = new Map>(); const closeContextPromises = new Map>(); const mcpSocketCleanupByRoot = new Map void>(); + const projectLastActivatedAt = new Map(); + const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1; let activeProjectRoot: string | null = null; let dormantContext!: AppContext; + let projectContextRebalancePromise: Promise = Promise.resolve(); const emitProjectChanged = (project: ProjectInfo | null): void => { broadcast(IPC.appProjectChanged, project); @@ -713,6 +716,7 @@ app.whenReady().then(async () => { const setActiveProject = (projectRoot: string | null): void => { activeProjectRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; if (activeProjectRoot) { + projectLastActivatedAt.set(activeProjectRoot, Date.now()); try { adeArtifactAllowedDir = resolveAdeLayout(activeProjectRoot).artifactsDir; @@ -743,6 +747,194 @@ app.whenReady().then(async () => { broadcast(channel, payload); }; + const hasActiveProjectWorkloads = async ( + projectRoot: string, + ctx: AppContext, + ): Promise => { + const keepAliveOnProbeFailure = ( + probe: string, + error: unknown, + ): boolean => { + ctx.logger.warn("project.context_workload_probe_failed", { + projectRoot, + probe, + error: error instanceof Error ? error.message : String(error), + }); + return true; + }; + + try { + if (ctx.sessionService.list({ status: "running", limit: 1 }).length > 0) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("sessions", error); + } + + try { + if (ctx.missionService.list({ status: "active", limit: 1 }).length > 0) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("missions", error); + } + + try { + if (ctx.testService.hasActiveRuns()) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("tests", error); + } + + try { + const lanes = await ctx.laneService.list({ + includeArchived: false, + includeStatus: false, + }); + for (const lane of lanes) { + if ( + ctx.processService.listRuntime(lane.id).some((runtime) => + runtime.status === "starting" + || runtime.status === "running" + || runtime.status === "degraded" + || runtime.status === "stopping" + ) + ) { + return true; + } + } + } catch (error) { + return keepAliveOnProbeFailure("processes", error); + } + + try { + if ((ctx.laneProxyService?.getStatus().routes.length ?? 0) > 0) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("proxy_routes", error); + } + + try { + if ( + ctx.oauthRedirectService?.listSessions().some((session) => + session.status === "pending" || session.status === "active" + ) ?? false + ) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("oauth_sessions", error); + } + + try { + if ((ctx.getActiveMcpConnectionCount?.() ?? 0) > 0) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("mcp_connections", error); + } + + try { + if ((ctx.syncHostService?.getPeerStates().length ?? 0) > 0) { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("sync_peers", error); + } + + try { + const syncStatus = await ctx.syncService?.getStatus?.(); + if (syncStatus?.client.state === "connected") { + return true; + } + } catch (error) { + return keepAliveOnProbeFailure("sync_client", error); + } + + return false; + }; + + const rebalanceProjectContexts = async (): Promise => { + const currentActiveRoot = activeProjectRoot; + if (!currentActiveRoot) return; + + const idleRoots: string[] = []; + for (const [projectRoot, ctx] of projectContexts.entries()) { + if (projectRoot === currentActiveRoot) continue; + if (await hasActiveProjectWorkloads(projectRoot, ctx)) { + ctx.logger.info("project.context_retained", { + projectRoot, + policy: "active_workload", + }); + continue; + } + idleRoots.push(projectRoot); + } + + idleRoots.sort( + (left, right) => + (projectLastActivatedAt.get(right) ?? 0) + - (projectLastActivatedAt.get(left) ?? 0), + ); + const warmRoots = new Set( + idleRoots.slice(0, MAX_WARM_IDLE_PROJECT_CONTEXTS), + ); + + for (const projectRoot of idleRoots) { + if (activeProjectRoot !== currentActiveRoot) { + return; + } + const ctx = projectContexts.get(projectRoot); + if (!ctx) continue; + if (projectRoot === activeProjectRoot) continue; + if (warmRoots.has(projectRoot)) { + ctx.logger.info("project.context_retained", { + projectRoot, + policy: "warm_idle", + activeProjectRoot: currentActiveRoot, + }); + continue; + } + // Re-check workloads immediately before eviction to avoid TOCTOU races + if (await hasActiveProjectWorkloads(projectRoot, ctx)) { + ctx.logger.info("project.context_retained", { + projectRoot, + policy: "became_active_during_rebalance", + activeProjectRoot: currentActiveRoot, + }); + continue; + } + ctx.logger.info("project.context_evicted", { + projectRoot, + policy: "idle_after_switch", + activeProjectRoot: currentActiveRoot, + }); + await closeProjectContext(projectRoot); + } + }; + + const scheduleProjectContextRebalance = (): void => { + projectContextRebalancePromise = projectContextRebalancePromise + .catch(() => { + // Swallow previous rebalance failures so future rebalances still run. + }) + .then(async () => { + try { + await rebalanceProjectContexts(); + } catch (error) { + const logger = activeProjectRoot + ? projectContexts.get(activeProjectRoot)?.logger ?? dormantContext.logger + : dormantContext.logger; + logger.warn("project.context_rebalance_failed", { + activeProjectRoot, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + }; + const initContextForProjectRoot = async ({ projectRoot, baseRef, @@ -2697,6 +2889,7 @@ app.whenReady().then(async () => { projectId, adeDir: adePaths.adeDir, hasUserSelectedProject: userSelectedProject, + getActiveMcpConnectionCount: () => activeMcpConnections.size, disposeHeadWatcher, keybindingsService, agentToolsService, @@ -2797,6 +2990,7 @@ app.whenReady().then(async () => { hasUserSelectedProject: false, projectId: "", adeDir: "", + getActiveMcpConnectionCount: () => 0, disposeHeadWatcher: () => {}, keybindingsService: null, agentToolsService: null, @@ -2951,6 +3145,26 @@ app.whenReady().then(async () => { } catch { // ignore } + try { + ctx.embeddingService?.stopHealthCheck?.(); + } catch { + // ignore + } + try { + await ctx.embeddingService?.dispose?.(); + } catch { + // ignore + } + try { + await ctx.laneProxyService?.dispose?.(); + } catch { + // ignore + } + try { + ctx.oauthRedirectService?.dispose?.(); + } catch { + // ignore + } try { await ctx.externalMcpService?.dispose?.(); } catch { @@ -3037,6 +3251,7 @@ app.whenReady().then(async () => { const closePromise = (async () => { await disposeContextResources(ctx); projectContexts.delete(normalizedRoot); + projectLastActivatedAt.delete(normalizedRoot); if (activeProjectRoot === normalizedRoot) { activeProjectRoot = null; } @@ -3080,6 +3295,7 @@ app.whenReady().then(async () => { recordRecent: false, }); emitProjectChanged(existing.project); + scheduleProjectContextRebalance(); return existing.project; } @@ -3111,6 +3327,7 @@ app.whenReady().then(async () => { recordRecent: false, }); emitProjectChanged(ctx.project); + scheduleProjectContextRebalance(); return ctx.project; }; diff --git a/apps/desktop/src/main/services/ai/providerTaskRunner.ts b/apps/desktop/src/main/services/ai/providerTaskRunner.ts index c5955a27..5ef20d87 100644 --- a/apps/desktop/src/main/services/ai/providerTaskRunner.ts +++ b/apps/desktop/src/main/services/ai/providerTaskRunner.ts @@ -88,6 +88,8 @@ async function runCommand(args: { let settled = false; const timeoutMs = Math.max(1_000, args.timeoutMs ?? 120_000); const timeoutHandle = setTimeout(() => { + if (settled) return; + settled = true; child.kill("SIGTERM"); reject(new Error(`Provider task timed out after ${timeoutMs}ms.`)); }, timeoutMs); diff --git a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts index 8c9e7d77..7d803117 100644 --- a/apps/desktop/src/main/services/feedback/feedbackReporterService.ts +++ b/apps/desktop/src/main/services/feedback/feedbackReporterService.ts @@ -280,6 +280,8 @@ export function createFeedbackReporterService({ jsonSchema: FEEDBACK_ISSUE_JSON_SCHEMA, permissionMode: "read-only", oneShot: true, + timeoutMs: 300_000, + ...(submission.reasoningEffort ? { reasoningEffort: submission.reasoningEffort } : {}), }); const structuredCandidate = result.structuredOutput ?? parseStructuredOutput(result.text); @@ -298,7 +300,19 @@ export function createFeedbackReporterService({ submission.generatedBody = normalized.draft.body; } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Generation failed: ${message}`); + logger.warn("feedback.generation_failed_using_fallback", { + id: submission.id, + category: submission.category, + modelId: submission.modelId, + error: message, + }); + normalizedDraft = { + title: fallbackTitle(submission), + body: fallbackBody(submission), + labels: defaultLabelsForCategory(submission.category), + }; + submission.generatedTitle = normalizedDraft.title; + submission.generatedBody = normalizedDraft.body; } // -- Post to GitHub -- @@ -357,6 +371,7 @@ export function createFeedbackReporterService({ category: args.category, userDescription: args.userDescription, modelId: args.modelId, + reasoningEffort: args.reasoningEffort ?? null, status: "pending", generatedTitle: null, generatedBody: null, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 2df191e3..ecdb6127 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -578,6 +578,7 @@ export type AppContext = { hasUserSelectedProject: boolean; projectId: string; adeDir: string; + getActiveMcpConnectionCount?: (() => number) | null; disposeHeadWatcher: () => void; keybindingsService: ReturnType; agentToolsService: ReturnType; diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts b/apps/desktop/src/main/services/prs/issueInventoryService.test.ts index 5454541b..8a215522 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.test.ts @@ -5,7 +5,7 @@ import type { PrComment, PrReviewThread, } from "../../../shared/types"; -import { createIssueInventoryService } from "./issueInventoryService"; +import { createIssueInventoryService, detectSource } from "./issueInventoryService"; // --------------------------------------------------------------------------- // Helpers @@ -534,7 +534,7 @@ describe("issueInventoryService", () => { expect(args[2]).toBe("human"); // source }); - it("maps unrecognized bot authors as unknown source", () => { + it("maps known bot aliases to their canonical source", () => { const db = makeMockDb(); db.get.mockReturnValue(null); db.all.mockReturnValue([]); @@ -561,7 +561,7 @@ describe("issueInventoryService", () => { (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("insert into pr_issue_inventory"), ); const args = insertCalls[0][1] as unknown[]; - expect(args[2]).toBe("unknown"); // source + expect(args[2]).toBe("greptile"); // source — known alias }); it("extracts severity from bold keywords (Critical/Major/Minor)", () => { @@ -2049,3 +2049,50 @@ describe("issueInventoryService", () => { }); }); }); + +// --------------------------------------------------------------------------- +// detectSource — map-based auto-detection +// --------------------------------------------------------------------------- + +describe("detectSource", () => { + it("returns 'unknown' for null / undefined / empty", () => { + expect(detectSource(null)).toBe("unknown"); + expect(detectSource(undefined)).toBe("unknown"); + expect(detectSource("")).toBe("unknown"); + expect(detectSource(" ")).toBe("unknown"); + }); + + it("maps known bot aliases to their canonical name", () => { + expect(detectSource("coderabbitai[bot]")).toBe("coderabbit"); + expect(detectSource("chatgpt-codex-connector[bot]")).toBe("codex"); + expect(detectSource("copilot[bot]")).toBe("copilot"); + expect(detectSource("github-copilot[bot]")).toBe("copilot"); + expect(detectSource("ade-review[bot]")).toBe("ade"); + expect(detectSource("greptile[bot]")).toBe("greptile"); + expect(detectSource("greptile-review[bot]")).toBe("greptile"); + expect(detectSource("seer[bot]")).toBe("seer"); + expect(detectSource("seer-code-review[bot]")).toBe("seer"); + }); + + it("known aliases work even without [bot] suffix", () => { + expect(detectSource("coderabbitai")).toBe("coderabbit"); + expect(detectSource("codex")).toBe("codex"); + expect(detectSource("greptile")).toBe("greptile"); + }); + + it("auto-detects unknown bots by [bot] suffix", () => { + // Unknown GitHub App — should return the base name as-is + expect(detectSource("sonarqube-review[bot]")).toBe("sonarqube-review"); + expect(detectSource("my-custom-tool[bot]")).toBe("my-custom-tool"); + }); + + it("returns 'bot' for names containing the word bot without [bot] suffix", () => { + expect(detectSource("some-bot-thing")).toBe("bot"); + }); + + it("returns 'human' for regular author names", () => { + expect(detectSource("octocat")).toBe("human"); + expect(detectSource("jane-dev")).toBe("human"); + expect(detectSource("Alice Smith")).toBe("human"); + }); +}); diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts index 87c19801..d85e8eea 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -18,17 +18,24 @@ import { isNoisyIssueComment } from "./resolverUtils"; import { nowIso } from "../shared/utils"; // --------------------------------------------------------------------------- -// Source detection — maps GitHub comment authors to known review bot sources +// Source detection — auto-detects review bot sources from GitHub author names // --------------------------------------------------------------------------- -const SOURCE_PATTERNS: Array<{ pattern: RegExp; source: IssueSource }> = [ - { pattern: /^coderabbitai(\[bot\])?$/i, source: "coderabbit" }, - { pattern: /^chatgpt-codex-connector(\[bot\])?$/i, source: "codex" }, - { pattern: /^codex(\[bot\])?$/i, source: "codex" }, - { pattern: /^copilot(\[bot\])?$/i, source: "copilot" }, - { pattern: /^github-copilot(\[bot\])?$/i, source: "copilot" }, - { pattern: /^ade-review(\[bot\])?$/i, source: "ade" }, -]; +// Canonical name mappings for well-known bots (normalizes variant names). +// Bots not listed here are auto-detected from the [bot] suffix and their +// extracted name is used directly — no code change needed for new bots. +const KNOWN_BOT_ALIASES: Record = { + "coderabbitai": "coderabbit", + "chatgpt-codex-connector": "codex", + "codex": "codex", + "copilot": "copilot", + "github-copilot": "copilot", + "ade-review": "ade", + "greptile-review": "greptile", + "greptile": "greptile", + "seer-code-review": "seer", + "seer": "seer", +}; const CONVERGENCE_RUNTIME_STATUS_VALUES = new Set([ "idle", @@ -56,10 +63,22 @@ const CONVERGENCE_POLLER_STATUS_VALUES = new Set []) : Promise.resolve([]), + headSha ? fetchCheckRuns(repo, headSha).catch((err) => { console.warn("[prService] fetchCheckRuns failed in refreshOne:", err?.message ?? err); return []; }) : Promise.resolve([]), fetchReviews(repo, Number(row.github_pr_number)).catch(() => []) ]); const reviewStatesByUser = new Map(); @@ -1578,7 +1578,7 @@ export function createPrService({ const [combinedStatus, checkRuns, reviews, compare] = await Promise.all([ headSha ? fetchCombinedStatus(repo, headSha) : Promise.resolve({ state: "", statuses: [] }), - headSha ? fetchCheckRuns(repo, headSha).catch(() => []) : Promise.resolve([]), + headSha ? fetchCheckRuns(repo, headSha).catch((err) => { console.warn("[prService] fetchCheckRuns failed in computeStatus:", err?.message ?? err); return []; }) : Promise.resolve([]), fetchReviews(repo, summary.githubPrNumber).catch(() => []), baseSha && headSha ? fetchCompare(repo, baseSha, headSha).catch(() => ({ behindBy: 0 })) : Promise.resolve({ behindBy: 0 }) ]); @@ -1630,8 +1630,8 @@ export function createPrService({ const headSha = asString(pr?.head?.sha); if (!headSha) return []; const [combinedStatus, checkRuns] = await Promise.all([ - fetchCombinedStatus(repo, headSha).catch(() => ({ state: "", statuses: [] })), - fetchCheckRuns(repo, headSha).catch(() => []) + fetchCombinedStatus(repo, headSha).catch((err) => { console.warn("[prService] fetchCombinedStatus failed in getChecks:", err?.message ?? err); return { state: "", statuses: [] }; }), + fetchCheckRuns(repo, headSha).catch((err) => { console.warn("[prService] fetchCheckRuns failed in getChecks:", err?.message ?? err); return []; }) ]); const out: PrCheck[] = []; diff --git a/apps/desktop/src/main/services/tests/testService.ts b/apps/desktop/src/main/services/tests/testService.ts index e156f477..3ef56dd0 100644 --- a/apps/desktop/src/main/services/tests/testService.ts +++ b/apps/desktop/src/main/services/tests/testService.ts @@ -482,6 +482,10 @@ export function createTestService({ return readTail(row.log_path, limit); }, + hasActiveRuns(): boolean { + return activeRuns.size > 0; + }, + disposeAll() { for (const entry of activeRuns.values()) { if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer); diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx index 559e8267..18376f99 100644 --- a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx @@ -116,6 +116,7 @@ function NewReportTab({ const [category, setCategory] = useState("bug"); const [description, setDescription] = useState(""); const [modelId, setModelId] = useState(""); + const [reasoningEffort, setReasoningEffort] = useState(null); const [submitting, setSubmitting] = useState(false); const [flash, setFlash] = useState<{ msg: string; ok: boolean } | null>(null); const flashTimer = useRef(null); @@ -128,6 +129,7 @@ function NewReportTab({ category, userDescription: description.trim(), modelId, + reasoningEffort, }; await window.ade.feedback.submit(args); setDescription(""); @@ -144,7 +146,7 @@ function NewReportTab({ } finally { setSubmitting(false); } - }, [category, description, modelId, submitting, onSubmitted]); + }, [category, description, modelId, reasoningEffort, submitting, onSubmitted]); useEffect(() => { return () => { @@ -216,8 +218,11 @@ function NewReportTab({ Model { setModelId(id); setReasoningEffort(null); }} availableModelIds={availableModelIds} + showReasoning + reasoningEffort={reasoningEffort} + onReasoningEffortChange={setReasoningEffort} onOpenAiSettings={openAiProvidersSettings} /> 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 3999880c..b18856ac 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -148,6 +148,11 @@ function installAdeMocks(options?: { send, steer, list, + getSummary: vi.fn().mockImplementation(async ({ sessionId }: { sessionId: string }) => { + const sessions = options?.sessions ?? [buildSession("session-1")]; + return sessions.find((s) => s.sessionId === sessionId) ?? null; + }), + editSteer: vi.fn().mockResolvedValue(undefined), updateSession: vi.fn().mockResolvedValue(undefined), interrupt: vi.fn().mockResolvedValue(undefined), approve: vi.fn().mockResolvedValue(undefined), @@ -332,8 +337,8 @@ describe("AgentChatPane submit recovery", () => { }); it("keeps the draft cleared after send succeeds even if session refresh fails", async () => { - const session = buildSession("session-1"); - const { send, list } = installAdeMocks({ + const session = buildSession("session-1", { status: "idle" }); + const { send } = installAdeMocks({ listError: new Error("refresh failed"), }); @@ -349,13 +354,12 @@ describe("AgentChatPane submit recovery", () => { text: "Ship the transcript cleanup.", displayText: "Ship the transcript cleanup.", })); - expect(list).toHaveBeenCalled(); expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(""); }); }); it("shows an optimistic queued bubble immediately for Cursor-style sends", async () => { - const session = buildSession("session-1"); + const session = buildSession("session-1", { status: "idle" }); let resolveSend!: () => void; const send = vi.fn().mockImplementation(() => new Promise((resolve) => { resolveSend = resolve; @@ -391,7 +395,7 @@ describe("AgentChatPane submit recovery", () => { it("keeps the draft cleared after steer succeeds even if session refresh fails", async () => { const session = buildSession("session-1"); - const { steer, list } = installAdeMocks({ + const { steer } = installAdeMocks({ transcript: buildStatusStartedTranscript(session.sessionId), listError: new Error("refresh failed"), }); @@ -407,13 +411,12 @@ describe("AgentChatPane submit recovery", () => { sessionId: session.sessionId, text: "Stop checking docs and just drive the browser.", }); - expect(list).toHaveBeenCalled(); expect((screen.getByRole("textbox") as HTMLTextAreaElement).value).toBe(""); }); }); it("restores the draft when the send itself fails", async () => { - const session = buildSession("session-1"); + const session = buildSession("session-1", { status: "idle" }); const { send } = installAdeMocks({ sendError: new Error("send failed"), }); @@ -432,6 +435,7 @@ describe("AgentChatPane submit recovery", () => { it("sends the selected Claude interaction mode with the next turn", async () => { const session = buildSession("session-1", { + status: "idle", provider: "claude", model: "claude-sonnet-4-6", modelId: "anthropic/claude-sonnet-4-6", @@ -481,6 +485,7 @@ describe("AgentChatPane submit recovery", () => { it("resyncs Claude composer permissions from refreshed session state", async () => { const session = buildSession("session-1", { + status: "idle", provider: "claude", model: "claude-sonnet-4-6", modelId: "anthropic/claude-sonnet-4-6", @@ -553,7 +558,7 @@ describe("AgentChatPane submit recovery", () => { }); it("keeps the committed model visible until the backend confirms the switch", async () => { - const session = buildSession("session-1"); + const session = buildSession("session-1", { status: "idle" }); const sessions = [session]; let resolveUpdateSession!: (value: AgentChatSessionSummary) => void; const updateSession = vi.fn().mockImplementation(() => new Promise((resolve) => { @@ -614,7 +619,7 @@ describe("AgentChatPane submit recovery", () => { }); it("keeps the committed model visible when the backend rejects a switch", async () => { - const session = buildSession("session-1"); + const session = buildSession("session-1", { status: "idle" }); const updateSession = vi.fn().mockRejectedValue(new Error("switch failed")); const warmupModel = vi.fn().mockResolvedValue(undefined); installAdeMocks({ @@ -715,7 +720,7 @@ describe("AgentChatPane submit recovery", () => { }); it("creates a sibling handoff chat and opens the returned work tab", async () => { - const session = buildSession("session-1"); + const session = buildSession("session-1", { status: "idle" }); const onSessionCreated = vi.fn().mockResolvedValue(undefined); const { handoff } = installAdeMocks({ handoffResult: { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index bb6c006b..4f088d94 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -78,6 +78,10 @@ const LEGACY_PROVIDER_KEY = "ade.chat.lastProvider"; const LEGACY_MODEL_KEY_PREFIX = "ade.chat.lastModel"; const COMPUTER_USE_SNAPSHOT_COOLDOWN_MS = 750; +const CHAT_HISTORY_READ_MAX_BYTES = 900_000; +const MAX_RETAINED_CHAT_SESSION_HISTORIES = 6; +const MAX_SELECTED_CHAT_SESSION_EVENTS = 1_200; +const MAX_BACKGROUND_CHAT_SESSION_EVENTS = 240; type AiStatusSnapshot = AiSettingsStatus & { runtimeConnections?: Record; @@ -498,6 +502,49 @@ export function resolveNextSelectedSessionId(args: { return rows[0]?.sessionId ?? null; } +function trimChatEventHistory(events: AgentChatEventEnvelope[], maxEvents: number): AgentChatEventEnvelope[] { + return events.length > maxEvents ? events.slice(-maxEvents) : events; +} + +function pruneSessionRecord(record: Record, keepIds: ReadonlySet): Record { + let changed = false; + const next: Record = {}; + for (const [sessionId, value] of Object.entries(record)) { + if (!keepIds.has(sessionId)) { + changed = true; + continue; + } + next[sessionId] = value; + } + return changed ? next : record; +} + +function buildRetainedChatSessionIds(args: { + rows: AgentChatSessionSummary[]; + selectedSessionId: string | null; + lockSessionId: string | null | undefined; + initialSessionId: string | null | undefined; + pendingSelectedSessionId: string | null; + optimisticSessionIds: ReadonlySet; +}): Set { + const keep = new Set(); + if (args.selectedSessionId) keep.add(args.selectedSessionId); + if (args.lockSessionId) keep.add(args.lockSessionId); + if (args.initialSessionId) keep.add(args.initialSessionId); + if (args.pendingSelectedSessionId) keep.add(args.pendingSelectedSessionId); + for (const sessionId of args.optimisticSessionIds) keep.add(sessionId); + + let recentAdded = 0; + for (const row of args.rows) { + if (keep.has(row.sessionId)) continue; + keep.add(row.sessionId); + recentAdded += 1; + if (recentAdded >= MAX_RETAINED_CHAT_SESSION_HISTORIES) break; + } + + return keep; +} + function resolveRegistryModelId(value: string | null | undefined): string | null { const normalized = (value ?? "").trim().toLowerCase(); if (!normalized.length) return null; @@ -657,7 +704,7 @@ export function AgentChatPane({ navigate("/settings?tab=ai#ai-providers"); }, [navigate]); const selectLane = useAppStore((s) => s.selectLane); - const lockedSingleSessionMode = Boolean(lockSessionId && hideSessionTabs && initialSessionSummary); + const lockedSingleSessionMode = Boolean(lockSessionId && hideSessionTabs); const forceDraft = forceDraftMode || forceNewSession; const preferDraftStart = !lockSessionId && !initialSessionId && !forceNewSession; const surfaceProfile: ChatSurfaceProfile = presentation?.profile ?? "standard"; @@ -734,6 +781,7 @@ export function AgentChatPane({ const computerUseSnapshotInFlightRef = useRef<{ sessionId: string; promise: Promise } | null>(null); const lastComputerUseSnapshotRef = useRef<{ sessionId: string; fetchedAt: number } | null>(null); const knownSessionIdsRef = useRef>(new Set()); + const seededInitialSummaryRef = useRef(false); const handoffRef = useRef(null); const localTouchBySessionRef = useRef>(new Map()); const cursorWarmupKeyRef = useRef(null); @@ -1120,26 +1168,81 @@ export function AgentChatPane({ }); }, []); + const refreshLockedSessionSummary = useCallback(async () => { + if (!lockSessionId) { + setSessions([]); + return null; + } + + let summary: AgentChatSessionSummary | null; + if (!seededInitialSummaryRef.current && initialSessionSummary?.sessionId === lockSessionId) { + summary = initialSessionSummary; + seededInitialSummaryRef.current = true; + } else { + summary = await window.ade.agentChat.getSummary({ sessionId: lockSessionId }); + } + + setSessions(summary ? [summary] : []); + setTurnActiveBySession((prev) => { + const nextRunning = Boolean(summary && summary.status === "active" && summary.awaitingInput !== true); + return prev[lockSessionId] === nextRunning + ? prev + : { ...prev, [lockSessionId]: nextRunning }; + }); + draftSelectionLockedRef.current = false; + setSelectedSessionId(lockSessionId); + return summary; + }, [initialSessionSummary, lockSessionId]); + const refreshSessions = useCallback(async () => { + if (lockedSingleSessionMode && lockSessionId) { + await refreshLockedSessionSummary(); + return; + } if (!laneId) { setSessions([]); + eventsBySessionRef.current = {}; + loadedHistoryRef.current.clear(); + setEventsBySession({}); + setTurnActiveBySession({}); + setPendingInputsBySession({}); + setPendingSteersBySession({}); return; } const rows = await window.ade.agentChat.list({ laneId }); const nextRows = sortSessionSummariesByRecency(rows, localTouchBySessionRef.current); setSessions(nextRows); + const retainedSessionIds = buildRetainedChatSessionIds({ + rows: nextRows, + selectedSessionId: selectedSessionIdRef.current, + lockSessionId, + initialSessionId, + pendingSelectedSessionId: pendingSelectedSessionIdRef.current, + optimisticSessionIds: optimisticSessionIdsRef.current, + }); + eventsBySessionRef.current = pruneSessionRecord(eventsBySessionRef.current, retainedSessionIds); + for (const sessionId of [...loadedHistoryRef.current]) { + if (!retainedSessionIds.has(sessionId)) { + loadedHistoryRef.current.delete(sessionId); + } + } + setEventsBySession((prev) => pruneSessionRecord(prev, retainedSessionIds)); setTurnActiveBySession((prev) => { - let next: Record | null = null; + const base = pruneSessionRecord(prev, retainedSessionIds); + let next: Record | null = base === prev ? null : base; for (const row of nextRows) { const shouldAppearRunning = row.status === "active" && row.awaitingInput !== true; - if ((prev[row.sessionId] ?? false) && !shouldAppearRunning) { - next ??= { ...prev }; + const source = next ?? base; + if ((source[row.sessionId] ?? false) && !shouldAppearRunning) { + next ??= { ...source }; next[row.sessionId] = false; } } - return next ?? prev; + return next ?? base; }); + setPendingInputsBySession((prev) => pruneSessionRecord(prev, retainedSessionIds)); + setPendingSteersBySession((prev) => pruneSessionRecord(prev, retainedSessionIds)); const nextSessionIds = new Set(nextRows.map((row) => row.sessionId)); for (const sessionId of [...localTouchBySessionRef.current.keys()]) { if (!nextSessionIds.has(sessionId) && !optimisticSessionIdsRef.current.has(sessionId)) { @@ -1176,7 +1279,7 @@ export function AgentChatPane({ } return nextSelectedSessionId; }); - }, [forceDraft, laneId, lockSessionId, preferDraftStart]); + }, [forceDraft, initialSessionId, laneId, lockSessionId, lockedSingleSessionMode, preferDraftStart, refreshLockedSessionSummary]); // Save/restore per-session drafts when switching sessions const prevSessionIdRef = useRef(undefined); @@ -1268,7 +1371,7 @@ export function AgentChatPane({ if (!summary || !isChatToolType(summary.toolType)) return; const raw = await window.ade.sessions.readTranscriptTail({ sessionId, - maxBytes: 1_800_000, + maxBytes: CHAT_HISTORY_READ_MAX_BYTES, raw: true }); const parsed = parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); @@ -1290,6 +1393,12 @@ export function AgentChatPane({ } else { merged = parsed; } + merged = trimChatEventHistory( + merged, + sessionId === selectedSessionIdRef.current || sessionId === lockSessionId + ? MAX_SELECTED_CHAT_SESSION_EVENTS + : MAX_BACKGROUND_CHAT_SESSION_EVENTS, + ); const derived = deriveRuntimeState(merged); const sessionSummary = sessionsRef.current.find((entry) => entry.sessionId === sessionId) @@ -1303,7 +1412,7 @@ export function AgentChatPane({ } catch { // Ignore transcript history failures. } - }, [initialSessionSummary]); + }, [initialSessionSummary, lockSessionId]); const clearSessionView = useCallback((sessionId: string) => { eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: [] }; @@ -1322,7 +1431,7 @@ export function AgentChatPane({ }, [lockSessionId]); useEffect(() => { - if (!lockedSingleSessionMode || !lockSessionId || !initialSessionSummary) return; + if (!lockedSingleSessionMode || !lockSessionId || initialSessionSummary?.sessionId !== lockSessionId) return; setSessions([initialSessionSummary]); draftSelectionLockedRef.current = false; setSelectedSessionId(lockSessionId); @@ -1417,15 +1526,7 @@ export function AgentChatPane({ } try { - if (lockedSingleSessionMode) { - if (!cancelled && initialSessionSummary) { - setSessions([initialSessionSummary]); - setSelectedSessionId(lockSessionId ?? initialSessionSummary.sessionId); - } - await refreshAvailableModels(); - } else { - await Promise.all([refreshAvailableModels(), refreshSessions()]); - } + await Promise.all([refreshAvailableModels(), refreshSessions()]); } finally { if (!cancelled) { setLoading(false); @@ -1438,7 +1539,7 @@ export function AgentChatPane({ return () => { cancelled = true; }; - }, [initialSessionSummary, lockSessionId, lockedSingleSessionMode, refreshAvailableModels, refreshSessions]); + }, [refreshAvailableModels, refreshSessions]); useEffect(() => { if (loading || !availableModelIds.length) return; @@ -1505,8 +1606,16 @@ export function AgentChatPane({ useEffect(() => { if (!selectedSessionId) return; + const selectedEventCount = eventsBySessionRef.current[selectedSessionId]?.length ?? 0; + const shouldForceReloadSelectedHistory = + !lockedSingleSessionMode + && selectedEventCount >= MAX_BACKGROUND_CHAT_SESSION_EVENTS + && selectedEventCount < MAX_SELECTED_CHAT_SESSION_EVENTS; if (!lockedSingleSessionMode) { - void loadHistory(selectedSessionId); + void loadHistory( + selectedSessionId, + shouldForceReloadSelectedHistory ? { force: true } : undefined, + ); return; } const handle = window.setTimeout(() => { @@ -1587,7 +1696,12 @@ export function AgentChatPane({ const sessionEvents = next === eventsBySessionRef.current ? (eventsBySessionRef.current[sessionId] ?? []) : (next[sessionId] ?? []); - const updated = [...sessionEvents, envelope]; + const updated = trimChatEventHistory( + [...sessionEvents, envelope], + sessionId === selectedSessionIdRef.current || sessionId === lockSessionId + ? MAX_SELECTED_CHAT_SESSION_EVENTS + : MAX_BACKGROUND_CHAT_SESSION_EVENTS, + ); if (next === eventsBySessionRef.current) { next = { ...eventsBySessionRef.current }; } @@ -1616,7 +1730,7 @@ export function AgentChatPane({ setTurnActiveBySession((activePrev) => ({ ...activePrev, ...activePatch })); setPendingInputsBySession((pendingPrev) => ({ ...pendingPrev, ...pendingInputPatch })); setPendingSteersBySession((steerPrev) => ({ ...steerPrev, ...pendingSteerPatch })); - }, []); + }, [lockSessionId]); const scheduleQueuedEventFlush = useCallback(() => { if (eventFlushTimerRef.current != null) return; diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 4775ec4e..1a0f3609 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -33,7 +33,7 @@ import type { import { MonacoDiffView } from "../lanes/MonacoDiffView"; import { LaneTerminalsPanel } from "../lanes/LaneTerminalsPanel"; import { useAppStore } from "../../state/appStore"; -import { replaceDirtyBuffersForWorkspace } from "../../lib/dirtyWorkspaceBuffers"; +import { clearDirtyBuffersForWorkspace, replaceDirtyBuffersForWorkspace } from "../../lib/dirtyWorkspaceBuffers"; import { PaneTilingLayout } from "../ui/PaneTilingLayout"; import { revealLabel } from "../../lib/platform"; import type { PaneConfig, PaneSplit } from "../ui/PaneTilingLayout"; @@ -84,6 +84,7 @@ type ExternalEditorTarget = "finder" | "vscode" | "cursor" | "zed"; type FilesPageSessionState = { workspaceId: string; + workspaceRootPath: string | null; allowPrimaryEdit: boolean; selectedNodePath: string | null; openTabs: OpenTab[]; @@ -94,11 +95,88 @@ type FilesPageSessionState = { }; const filesPageSessionByScope = new Map(); +const filesPageSessionLru: string[] = []; +const MAX_FILES_PAGE_CACHED_SCOPES = 8; const MAX_QUEUED_TREE_PARENT_REFRESHES = 24; function filesSessionKey(projectRoot: string, laneId: string | null): string { return `${projectRoot}::${laneId ?? "__primary__"}`; } + +function touchFilesPageSession(sessionKey: string): void { + const existingIndex = filesPageSessionLru.indexOf(sessionKey); + if (existingIndex >= 0) { + filesPageSessionLru.splice(existingIndex, 1); + } + filesPageSessionLru.push(sessionKey); +} + +function getFilesPageSession(sessionKey: string): FilesPageSessionState | undefined { + const session = filesPageSessionByScope.get(sessionKey); + if (session) touchFilesPageSession(sessionKey); + return session; +} + +function filesPageSessionHasUnsavedTabs(session: FilesPageSessionState | undefined): boolean { + return session?.openTabs.some((tab) => tab.content !== tab.savedContent) ?? false; +} + +function hasFilesPageSessionForWorkspaceRoot(workspaceRootPath: string, excludeSessionKey?: string): boolean { + for (const [key, session] of filesPageSessionByScope.entries()) { + if (excludeSessionKey && key === excludeSessionKey) continue; + if (session.workspaceRootPath === workspaceRootPath) { + return true; + } + } + return false; +} + +function setFilesPageSession(sessionKey: string, session: FilesPageSessionState): void { + filesPageSessionByScope.set(sessionKey, session); + touchFilesPageSession(sessionKey); + while (filesPageSessionLru.length > MAX_FILES_PAGE_CACHED_SCOPES) { + const evictIndex = filesPageSessionLru.findIndex((candidateKey) => { + if (candidateKey === sessionKey) return false; + return !filesPageSessionHasUnsavedTabs(filesPageSessionByScope.get(candidateKey)); + }); + if (evictIndex < 0) break; + const [evicted] = filesPageSessionLru.splice(evictIndex, 1); + if (!evicted) break; + const evictedSession = filesPageSessionByScope.get(evicted); + filesPageSessionByScope.delete(evicted); + if ( + evictedSession?.workspaceRootPath + && !hasFilesPageSessionForWorkspaceRoot(evictedSession.workspaceRootPath, evicted) + ) { + clearDirtyBuffersForWorkspace(evictedSession.workspaceRootPath); + } + } +} + +function snapshotFilesPageSessionState(args: { + workspaceId: string; + workspaceRootPath: string | null; + allowPrimaryEdit: boolean; + selectedNodePath: string | null; + openTabs: OpenTab[]; + activeTabPath: string | null; + mode: EditorViewMode; + searchQuery: string; + editorTheme: EditorThemeMode; +}): FilesPageSessionState { + return { + workspaceId: args.workspaceId, + workspaceRootPath: args.workspaceRootPath, + allowPrimaryEdit: args.allowPrimaryEdit, + selectedNodePath: args.selectedNodePath, + openTabs: args.openTabs, + activeTabPath: args.activeTabPath, + mode: args.mode, + searchQuery: args.searchQuery, + editorTheme: args.editorTheme, + }; +} + const FILES_EDITOR_THEME_KEY = "ade.files.editorTheme"; function readStoredEditorTheme(): EditorThemeMode { @@ -340,7 +418,7 @@ export function FilesPage() { const selectedLaneId = useAppStore((s) => s.selectedLaneId); const projectRootPath = useAppStore((s) => s.project?.rootPath ?? "__unknown_project__"); const sessionKey = filesSessionKey(projectRootPath, selectedLaneId); - const initialSession = filesPageSessionByScope.get(sessionKey); + const initialSession = getFilesPageSession(sessionKey); const [workspaces, setWorkspaces] = useState([]); const [workspaceId, setWorkspaceId] = useState(initialSession?.workspaceId ?? ""); @@ -408,22 +486,24 @@ export function FilesPage() { const prevSessionKeyRef = useRef(sessionKey); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional snapshot of outgoing session state; deps are omitted so we capture values at the moment sessionKey changes useEffect(() => { if (prevSessionKeyRef.current === sessionKey) return; // Save current state under the old scope before switching - filesPageSessionByScope.set(prevSessionKeyRef.current, { + setFilesPageSession(prevSessionKeyRef.current, snapshotFilesPageSessionState({ workspaceId, + workspaceRootPath: activeWorkspace?.rootPath ?? null, allowPrimaryEdit, selectedNodePath, - openTabs: openTabs.map((tab) => ({ ...tab })), + openTabs, activeTabPath, mode, searchQuery, editorTheme, - }); + })); prevSessionKeyRef.current = sessionKey; // Restore state for the new scope (project + lane) - const session = filesPageSessionByScope.get(sessionKey); + const session = getFilesPageSession(sessionKey); setWorkspaceId(session?.workspaceId ?? ""); setAllowPrimaryEdit(session?.allowPrimaryEdit ?? false); setSelectedNodePath(session?.selectedNodePath ?? null); @@ -440,17 +520,18 @@ export function FilesPage() { ); useEffect(() => { - filesPageSessionByScope.set(sessionKey, { + setFilesPageSession(sessionKey, snapshotFilesPageSessionState({ workspaceId, + workspaceRootPath: activeWorkspace?.rootPath ?? null, allowPrimaryEdit, selectedNodePath, - openTabs: openTabs.map((tab) => ({ ...tab })), + openTabs, activeTabPath, mode, searchQuery, - editorTheme - }); - }, [sessionKey, workspaceId, allowPrimaryEdit, selectedNodePath, openTabs, activeTabPath, mode, searchQuery, editorTheme]); + editorTheme, + })); + }, [sessionKey, workspaceId, activeWorkspace?.rootPath, allowPrimaryEdit, selectedNodePath, openTabs, activeTabPath, mode, searchQuery, editorTheme]); useEffect(() => { persistEditorTheme(editorTheme); diff --git a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx index a40716dc..ca115a1d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneTerminalsPanel.tsx @@ -378,19 +378,16 @@ export function LaneTerminalsPanel({ overrideLaneId }: { overrideLaneId?: string
{runningSessions.length > 0 ? (
- {runningSessions.map((session) => - session.ptyId ? ( - - ) : null - )} + {current?.status === "running" && current.ptyId ? ( + + ) : null} {current && current.status === "running" && current.ptyId ? null : (
diff --git a/apps/desktop/src/renderer/components/lanes/TilingLayout.tsx b/apps/desktop/src/renderer/components/lanes/TilingLayout.tsx index bd7a6bb3..759b1295 100644 --- a/apps/desktop/src/renderer/components/lanes/TilingLayout.tsx +++ b/apps/desktop/src/renderer/components/lanes/TilingLayout.tsx @@ -137,7 +137,7 @@ function TileRenderer({
{isChat ? ( - + ) : isRunning ? ( ) : ( diff --git a/apps/desktop/src/renderer/components/project/ProjectHomePage.tsx b/apps/desktop/src/renderer/components/project/ProjectHomePage.tsx index caa28453..28a13989 100644 --- a/apps/desktop/src/renderer/components/project/ProjectHomePage.tsx +++ b/apps/desktop/src/renderer/components/project/ProjectHomePage.tsx @@ -44,6 +44,7 @@ const DEFAULT_PROCESS_COMMAND = '["npm", "run", "dev"]'; const DEFAULT_PROCESS_COMMAND_LINE = "npm run dev"; const DEFAULT_TEST_COMMAND = '["npm", "run", "test"]'; const DEFAULT_ENV = "{}"; +const PROJECT_HOME_LOG_TAIL_MAX_BYTES = 220_000; function formatUptime(runtime: ProcessRuntime, nowMs: number): string { if (!runtime.startedAt) return "-"; @@ -149,6 +150,13 @@ function normalizeLog(raw: string): string { return raw.replace(/\u0000/g, ""); } +function appendLogChunk(previous: string, chunk: string): string { + const next = normalizeLog(`${previous}${chunk}`); + return next.length > PROJECT_HOME_LOG_TAIL_MAX_BYTES + ? next.slice(-PROJECT_HOME_LOG_TAIL_MAX_BYTES) + : next; +} + function filterLog(raw: string, query: string): string { const q = query.trim().toLowerCase(); if (!q) return raw; @@ -540,7 +548,7 @@ export function ProjectHomePage() { } if (ev.type === "log" && ev.laneId === effectiveLaneId && selectedProcessIdRef.current === ev.processId) { - setProcessLogRaw((prev) => normalizeLog(`${prev}${ev.chunk}`)); + setProcessLogRaw((prev) => appendLogChunk(prev, ev.chunk)); } }); @@ -563,7 +571,7 @@ export function ProjectHomePage() { } if (ev.type === "log" && selectedRunIdRef.current === ev.runId) { - setTestLogRaw((prev) => normalizeLog(`${prev}${ev.chunk}`)); + setTestLogRaw((prev) => appendLogChunk(prev, ev.chunk)); } }); @@ -588,7 +596,7 @@ export function ProjectHomePage() { } window.ade.processes - .getLogTail({ laneId: effectiveLaneId, processId: selectedProcessId, maxBytes: 220_000 }) + .getLogTail({ laneId: effectiveLaneId, processId: selectedProcessId, maxBytes: PROJECT_HOME_LOG_TAIL_MAX_BYTES }) .then((log) => setProcessLogRaw(normalizeLog(log))) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, [selectedProcessId, effectiveLaneId]); @@ -600,7 +608,7 @@ export function ProjectHomePage() { } window.ade.tests - .getLogTail({ runId: selectedRunId, maxBytes: 220_000 }) + .getLogTail({ runId: selectedRunId, maxBytes: PROJECT_HOME_LOG_TAIL_MAX_BYTES }) .then((log) => setTestLogRaw(normalizeLog(log))) .catch((err) => setError(err instanceof Error ? err.message : String(err))); }, [selectedRunId]); diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 3d3415f7..e36f6ec2 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -308,8 +308,8 @@ type ChecksSummary = { }; function summarizeChecks(checks: PrCheck[]): ChecksSummary { - const passing = checks.filter((check) => check.conclusion === "success").length; - const failing = checks.filter((check) => check.conclusion === "failure").length; + const passing = checks.filter((check) => check.conclusion === "success" || check.conclusion === "neutral" || check.conclusion === "skipped").length; + const failing = checks.filter((check) => check.conclusion === "failure" || check.conclusion === "cancelled").length; const pending = checks.filter((check) => check.status !== "completed").length; return { passing, @@ -626,6 +626,26 @@ export function PrDetailPane({ }; }, [applyConvergenceRuntime, loadConvergenceState, loadDetail, pr.id]); + // Poll actionRuns + activity + reviewThreads every 60s so CI data stays fresh. + // PrsContext polls checks/status/reviews/comments, but action runs are only loaded + // in PrDetailPane and would otherwise go stale after the initial fetch. + React.useEffect(() => { + const id = window.setInterval(() => { + const reqId = detailLoadSeqRef.current; + Promise.allSettled([ + window.ade.prs.getActionRuns(pr.id), + window.ade.prs.getActivity(pr.id), + window.ade.prs.getReviewThreads(pr.id), + ]).then(([arResult, actResult, thrResult]) => { + if (reqId !== detailLoadSeqRef.current) return; + if (arResult.status === "fulfilled") setActionRuns(arResult.value); + if (actResult.status === "fulfilled") setActivity(actResult.value); + if (thrResult.status === "fulfilled") setReviewThreads(thrResult.value); + }); + }, 60_000); + return () => window.clearInterval(id); + }, [pr.id]); + React.useEffect(() => { if (!issueResolverCopyNotice) return; const timer = window.setTimeout(() => setIssueResolverCopyNotice(null), 2500); @@ -1661,7 +1681,7 @@ export function PrDetailPane({ { id: "overview", label: "Overview", icon: Eye }, { id: "convergence", label: "Path to Merge", icon: Sparkle, count: newIssueCount > 0 ? newIssueCount : undefined }, { id: "files", label: "Files", icon: Code, count: files.length }, - { id: "checks", label: "CI / Checks", icon: Play, count: checks.length + actionRuns.reduce((sum, run) => sum + run.jobs.length, 0) }, + { id: "checks", label: "CI / Checks", icon: Play, count: buildUnifiedChecks(checks, actionRuns).length }, { id: "activity", label: "Activity", icon: ClockCounterClockwise, count: activity.length > 0 ? activity.length : (comments.length + reviews.length) }, ]; @@ -1824,7 +1844,7 @@ export function PrDetailPane({
{activeTab === "overview" && ( (mergeMethod); const [allowBlockedMerge, setAllowBlockedMerge] = React.useState(false); @@ -2235,8 +2256,22 @@ function OverviewTab(props: OverviewTabProps) { [comments], ); - // Checks summary - const checksSummary = summarizeChecks(checks); + // Unified checks: merge check-runs API data with action-runs API data so that + // merge readiness, stats sidebar, and convergence panel all reflect the same reality. + const allChecks: PrCheck[] = React.useMemo(() => { + const unified = buildUnifiedChecks(checks, actionRuns); + return unified.map((c): PrCheck => ({ + name: c.displayName, + status: (c.status === "queued" || c.status === "in_progress" || c.status === "completed") ? c.status : "completed", + conclusion: (c.conclusion === "success" || c.conclusion === "failure" || c.conclusion === "neutral" || c.conclusion === "skipped" || c.conclusion === "cancelled") ? c.conclusion : null, + detailsUrl: c.detailsUrl, + startedAt: null, + completedAt: null, + })); + }, [checks, actionRuns]); + + // Checks summary — uses unified checks (check-runs + action-runs) + const checksSummary = summarizeChecks(allChecks); const { someChecksFailing, checksRunning } = checksSummary; const checksRowVisuals = getChecksRowVisuals(checksSummary); @@ -2590,17 +2625,17 @@ function OverviewTab(props: OverviewTabProps) { title={checksRowVisuals.title} titleAccessory={checksRunning && checksSummary.total > 0 ? : undefined} description={checksRowVisuals.description} - expandable={checks.length > 0} + expandable={allChecks.length > 0} expanded={checksExpanded} onToggle={() => setChecksExpanded(!checksExpanded)} >
- {checks.map((check, idx) => { - const checkColor = check.conclusion === "success" ? COLORS.success : check.conclusion === "failure" ? COLORS.danger : check.status === "in_progress" ? COLORS.warning : COLORS.textMuted; + {allChecks.map((check, idx) => { + const checkColor = check.conclusion === "success" ? COLORS.success : check.conclusion === "failure" ? COLORS.danger : check.status === "in_progress" ? COLORS.warning : check.conclusion === "skipped" || check.conclusion === "neutral" ? COLORS.textDim : COLORS.textMuted; return (
{check.name} @@ -2860,7 +2895,7 @@ function OverviewTab(props: OverviewTabProps) {
- c.conclusion === "success").length}/${checks.length} passing`} /> + c.conclusion === "success" || c.conclusion === "neutral" || c.conclusion === "skipped").length}/${allChecks.length} passing`} /> r.state === "approved").length} approved`} /> diff --git a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx index 3c2d2aac..77309dff 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrConvergencePanel.tsx @@ -1034,7 +1034,7 @@ export function PrConvergencePanel({ const runningChecks = checks.filter((c) => c.status === "in_progress"); const queuedChecks = checks.filter((c) => c.status === "queued"); const checksStillRunning = queuedChecks.length > 0 || runningChecks.length > 0; - const allChecksPassing = checks.length > 0 && checks.every((c) => c.conclusion === "success"); + const allChecksPassing = checks.length > 0 && checks.every((c) => c.conclusion === "success" || c.conclusion === "neutral" || c.conclusion === "skipped"); const passingChecks = checks.filter((c) => c.conclusion === "success"); const otherChecks = checks.filter( (c) => c.conclusion !== "failure" && c.conclusion !== "success" && c.status !== "in_progress", diff --git a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx index 9cbd212a..d5599a95 100644 --- a/apps/desktop/src/renderer/components/settings/GeneralSection.tsx +++ b/apps/desktop/src/renderer/components/settings/GeneralSection.tsx @@ -15,7 +15,7 @@ import { const TERMINAL_FONT_SIZE_OPTIONS = [11, 11.5, 12, 12.5, 13, 13.5, 14, 15]; const TERMINAL_LINE_HEIGHT_OPTIONS = [1.1, 1.15, 1.2, 1.25, 1.3, 1.35]; -const TERMINAL_SCROLLBACK_OPTIONS = [5000, 10000, 20000, 50000]; +const TERMINAL_SCROLLBACK_OPTIONS = [5000, 10000, 20000, 30000]; const THEME_META: Record< ThemeId, diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx index 7323180b..a228e075 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx @@ -9,6 +9,8 @@ const mockState = vi.hoisted(() => ({ nextFitDims: { cols: 120, rows: 40 }, shouldThrowWebglAddon: false, lastContextLossHandler: null as (() => void) | null, + ptyDataListeners: new Set<(event: { ptyId: string; sessionId?: string; data: string }) => void>(), + ptyExitListeners: new Set<(event: { ptyId: string; sessionId?: string; exitCode: number | null }) => void>(), theme: "dark" as const, terminalPreferences: { fontSize: 12.5, @@ -132,8 +134,18 @@ function installWindowAde() { pty: { resize: vi.fn().mockResolvedValue(undefined), write: vi.fn().mockResolvedValue(undefined), - onData: vi.fn(() => () => {}), - onExit: vi.fn(() => () => {}), + onData: vi.fn((listener: (event: { ptyId: string; sessionId?: string; data: string }) => void) => { + mockState.ptyDataListeners.add(listener); + return () => { + mockState.ptyDataListeners.delete(listener); + }; + }), + onExit: vi.fn((listener: (event: { ptyId: string; sessionId?: string; exitCode: number | null }) => void) => { + mockState.ptyExitListeners.add(listener); + return () => { + mockState.ptyExitListeners.delete(listener); + }; + }), }, sessions: { readTranscriptTail: vi.fn().mockResolvedValue(""), @@ -251,6 +263,8 @@ describe("TerminalView", () => { mockState.nextFitDims = { cols: 120, rows: 40 }; mockState.shouldThrowWebglAddon = false; mockState.lastContextLossHandler = null; + mockState.ptyDataListeners.clear(); + mockState.ptyExitListeners.clear(); mockState.theme = "dark"; mockState.terminalPreferences = { fontSize: 12.5, @@ -390,4 +404,50 @@ describe("TerminalView", () => { expect(terminal?.options.lineHeight).toBe(1.3); expect(terminal?.options.scrollback).toBe(20_000); }); + + it("keeps live parked runtimes available so switching away does not discard TUI state", async () => { + const view = render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + dispose: ReturnType; + } | undefined; + + expect(getTerminalRuntimeSnapshot("session-live")).not.toBeNull(); + + view.unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(14_000); + }); + expect(getTerminalRuntimeSnapshot("session-live")).not.toBeNull(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_100); + }); + expect(getTerminalRuntimeSnapshot("session-live")).not.toBeNull(); + expect(terminal?.dispose).not.toHaveBeenCalled(); + }); + + it("buffers PTY output while unmounted instead of writing into a parked runtime", async () => { + const firstView = render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + + terminal?.write.mockClear(); + firstView.unmount(); + + for (const listener of mockState.ptyDataListeners) { + listener({ ptyId: "pty-buffered", sessionId: "session-buffered", data: "hello from background\n" }); + } + await flushAnimationFrame(); + expect(terminal?.write).not.toHaveBeenCalled(); + + render(); + await flushAnimationFrame(); + expect(terminal?.write).toHaveBeenCalledWith("hello from background\n"); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index acb828eb..42c3fc5a 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -494,11 +494,18 @@ function enqueueFrameWrite(runtime: CachedRuntime, chunk: string) { runtime.frameWriteBytes -= dropped?.length ?? 0; incrementHealth(runtime, "droppedChunks"); } + if (runtime.refs === 0) return; + scheduleFrameWriteFlush(runtime); +} + +function scheduleFrameWriteFlush(runtime: CachedRuntime) { if (runtime.flushRafId != null) return; runtime.flushRafId = requestAnimationFrame(() => { runtime.flushRafId = null; if (runtime.disposed) return; if (runtime.frameWriteChunks.length === 0) return; + // Keep writes buffered while no view is mounted; the remount path reschedules the flush. + if (runtime.refs === 0) return; const merged = runtime.frameWriteChunks.join(""); runtime.frameWriteChunks.length = 0; runtime.frameWriteBytes = 0; @@ -944,6 +951,7 @@ export function TerminalView({ if (runtime.host.parentElement !== el) { el.replaceChildren(runtime.host); } + scheduleFrameWriteFlush(runtime); const schedule = (forceResize = false) => scheduleFit(runtime, forceResize); @@ -1083,8 +1091,8 @@ export function TerminalView({ } runtime.refs = Math.max(0, runtime.refs - 1); - // Keep live runtimes parked until the PTY exits. Restoring a full-screen - // TUI from transcript tail is brittle, especially once transcript capture truncates. + // Keep live runtimes parked until the PTY exits so switching away from a + // running terminal does not discard in-memory TUI state. if (runtime.refs === 0 && runtime.exitCode != null) { scheduleRuntimeDispose(runtime, EXITED_RUNTIME_KEEPALIVE_MS); } diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 651cdd67..9b348dba 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -32,16 +32,49 @@ function isRunningPtySession( function SessionSurface({ session, isActive, + suspended = false, layoutVariant = "standard", terminalVisible = isActive, onOpenChatSession, }: { session: TerminalSessionSummary; isActive: boolean; + suspended?: boolean; layoutVariant?: "standard" | "grid-tile"; terminalVisible?: boolean; onOpenChatSession: (session: AgentChatSession) => void | Promise; }) { + if (suspended) { + const secondary = secondarySessionLabel(session); + const status = sessionStatusDot(session); + const preview = session.summary?.trim() + || session.lastOutputPreview?.trim() + || (isChatToolType(session.toolType) ? "Select this tile to resume the live chat view." : "Select this tile to resume the live terminal view."); + return ( +
+
+
+
+ + {primarySessionLabel(session)} +
+
+ {session.laneName} + {secondary ? ` • ${truncateSessionLabel(secondary, 40)}` : ""} +
+
+ +
+
+ {preview} +
+
+ ); + } + const isChat = isChatToolType(session.toolType); if (isChat) { return ( @@ -49,6 +82,7 @@ function SessionSurface({ laneId={session.laneId} laneLabel={session.laneName} lockSessionId={session.id} + hideSessionTabs onSessionCreated={onOpenChatSession} layoutVariant={layoutVariant} /> @@ -257,6 +291,7 @@ export function WorkViewArea({ (); +const MAX_CACHED_SESSION_DELTAS = 128; + +function touchDeltaCacheEntry(sessionId: string, value: SessionDeltaSummary | null): void { + if (deltaCache.has(sessionId)) { + deltaCache.delete(sessionId); + } + deltaCache.set(sessionId, value); + while (deltaCache.size > MAX_CACHED_SESSION_DELTAS) { + const oldestKey = deltaCache.keys().next().value; + if (!oldestKey) break; + deltaCache.delete(oldestKey); + } +} export function useSessionDelta(sessionId: string | null, enabled: boolean) { const [delta, setDelta] = useState( @@ -15,7 +28,9 @@ export function useSessionDelta(sessionId: string | null, enabled: boolean) { } if (deltaCache.has(sessionId)) { - setDelta(deltaCache.get(sessionId) ?? null); + const cached = deltaCache.get(sessionId) ?? null; + touchDeltaCacheEntry(sessionId, cached); + setDelta(cached); return; } @@ -24,12 +39,12 @@ export function useSessionDelta(sessionId: string | null, enabled: boolean) { .getDelta(sessionId) .then((result) => { if (cancelled) return; - deltaCache.set(sessionId, result); + touchDeltaCacheEntry(sessionId, result); setDelta(result); }) .catch(() => { // Cache the miss so we don't re-fetch on every render - if (!cancelled) deltaCache.set(sessionId, null); + if (!cancelled) touchDeltaCacheEntry(sessionId, null); }); return () => { diff --git a/apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.ts b/apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.ts index 8c54e0d0..dccaf5e6 100644 --- a/apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.ts +++ b/apps/desktop/src/renderer/lib/dirtyWorkspaceBuffers.ts @@ -6,13 +6,7 @@ import path from "path-browserify"; */ const dirtyByAbsPath = new Map(); -/** - * Replace dirty entries for paths under `rootPath` using the current open tab set for that workspace. - */ -export function replaceDirtyBuffersForWorkspace( - rootPath: string, - tabs: ReadonlyArray<{ path: string; content: string; savedContent: string }>, -): void { +function clearWorkspaceEntries(rootPath: string): void { const rootNorm = path.normalize(rootPath); const prefix = rootNorm.endsWith(path.sep) ? rootNorm : `${rootNorm}${path.sep}`; for (const key of [...dirtyByAbsPath.keys()]) { @@ -21,6 +15,17 @@ export function replaceDirtyBuffersForWorkspace( dirtyByAbsPath.delete(key); } } +} + +/** + * Replace dirty entries for paths under `rootPath` using the current open tab set for that workspace. + */ +export function replaceDirtyBuffersForWorkspace( + rootPath: string, + tabs: ReadonlyArray<{ path: string; content: string; savedContent: string }>, +): void { + const rootNorm = path.normalize(rootPath); + clearWorkspaceEntries(rootNorm); for (const tab of tabs) { const abs = path.isAbsolute(tab.path) ? path.normalize(tab.path) @@ -31,6 +36,10 @@ export function replaceDirtyBuffersForWorkspace( } } +export function clearDirtyBuffersForWorkspace(rootPath: string): void { + clearWorkspaceEntries(rootPath); +} + /** Called from main via executeJavaScript — must stay synchronous. */ export function getDirtyFileTextForWindow(absPath: string): string | undefined { const n = path.normalize(absPath.trim()); diff --git a/apps/desktop/src/renderer/state/appStore.test.ts b/apps/desktop/src/renderer/state/appStore.test.ts index 78f7c018..c7126718 100644 --- a/apps/desktop/src/renderer/state/appStore.test.ts +++ b/apps/desktop/src/renderer/state/appStore.test.ts @@ -130,6 +130,14 @@ describe("appStore", () => { scrollback: 2000, }); }); + + it("caps scrollback at the renderer safety limit", () => { + useAppStore.getState().setTerminalPreferences({ + scrollback: 250_000, + }); + + expect(useAppStore.getState().terminalPreferences.scrollback).toBe(30_000); + }); }); // ───────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 9d927b45..212f12bd 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -244,7 +244,7 @@ function clampTerminalLineHeight(value: unknown): number { function clampTerminalScrollback(value: unknown): number { const next = typeof value === "number" ? value : Number(value); if (!Number.isFinite(next)) return DEFAULT_TERMINAL_PREFERENCES.scrollback; - return Math.max(2000, Math.min(100_000, Math.round(next / 1000) * 1000)); + return Math.max(2000, Math.min(30_000, Math.round(next / 1000) * 1000)); } function normalizeTerminalPreferences(value: unknown): TerminalPreferences { diff --git a/apps/desktop/src/shared/types/feedback.ts b/apps/desktop/src/shared/types/feedback.ts index a444d7a3..801f5abd 100644 --- a/apps/desktop/src/shared/types/feedback.ts +++ b/apps/desktop/src/shared/types/feedback.ts @@ -5,6 +5,7 @@ export type FeedbackSubmission = { category: FeedbackCategory; userDescription: string; modelId: string; + reasoningEffort?: string | null; status: "pending" | "generating" | "posting" | "posted" | "failed"; generatedTitle: string | null; generatedBody: string | null; @@ -20,6 +21,7 @@ export type FeedbackSubmitArgs = { category: FeedbackCategory; userDescription: string; modelId: string; + reasoningEffort?: string | null; }; export type FeedbackSubmissionEvent = { diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index 317a0221..af284b86 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -1119,7 +1119,9 @@ export type PrConvergenceStatePatch = Partial