From a4a9c40c72c99a5305d6244a5fe15718f4156b9c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:39:11 -0400 Subject: [PATCH 1/3] feat(chat): name lanes and chats from the ADE provider Problem: background titles, status lines, and commit suggestions used Settings model pickers, and Cursor one-shots timed out because empty tool lists were dropped. Cause: helpers keyed off registry family and Settings; Cursor SDK treated tools: [] as unset. Change and boundary: cheap helper by ADE provider (Haiku / Luna / Composer), then the session model; Settings pickers, Graph PR AI draft, PR summary card, and Graph conflict flyout are gone. Manual PR create is title plus optional body. Cursor one-shots keep tools: []. Desktop, CLI, iOS, and docs move together. Unused prs.draftDescription IPC is left for compatibility. Verification: live PONG one-shots for Claude Haiku, Codex Luna, and Cursor Composer 2.5; focused desktop/CLI tests including empty-tools, last-turned commit model, generate-names, and Settings picker absence. Co-authored-by: Cursor --- apps/ade-cli/src/bootstrap.ts | 2 +- apps/ade-cli/src/cli.test.ts | 35 ++ apps/ade-cli/src/cli.ts | 24 +- .../ade-cli-control-plane/SKILL.md | 11 + apps/desktop/src/main/main.ts | 2 +- .../main/services/ai/aiIntegrationService.ts | 3 +- .../services/ai/tools/ctoOperatorTools.ts | 8 +- .../services/chat/agentChatService.test.ts | 29 +- .../main/services/chat/agentChatService.ts | 156 +++--- .../services/chat/cursorSdkPolicy.test.ts | 21 +- .../src/main/services/chat/cursorSdkPolicy.ts | 28 +- .../src/main/services/chat/cursorSdkWorker.ts | 4 +- .../main/services/chat/sessionNaming.test.ts | 36 +- .../src/main/services/chat/sessionNaming.ts | 24 +- .../src/main/services/cto/ctoStateService.ts | 8 +- .../services/git/gitOperationsService.test.ts | 200 +++++-- .../main/services/git/gitOperationsService.ts | 73 ++- .../src/main/services/ipc/registerIpc.ts | 3 + .../src/main/services/pty/ptyService.ts | 163 ++---- .../main/services/sessions/sessionService.ts | 6 + .../automations/adeActionSchemas.ts | 12 - .../components/chat/AgentChatPane.tsx | 57 +- .../components/graph/WorkspaceGraphPage.tsx | 174 ++---- .../graph/graphDialogs/ConflictPanel.tsx | 309 ----------- .../renderer/components/graph/graphTypes.ts | 20 - .../lanes/LaneGitActionsPane.test.tsx | 28 +- .../components/lanes/LaneGitActionsPane.tsx | 69 +-- .../components/prs/detail/PrDetailPane.tsx | 20 - .../prs/detail/PrDetailTimelineRails.test.tsx | 4 - .../prs/detail/PrDetailTimelineRails.tsx | 14 - .../prs/shared/PrAiSummaryCard.test.tsx | 96 ---- .../components/prs/shared/PrAiSummaryCard.tsx | 254 --------- .../components/prs/shared/PrTimeline.test.tsx | 23 +- .../components/prs/shared/PrTimeline.tsx | 21 - .../components/prs/state/PrsContext.test.tsx | 34 +- .../components/prs/state/PrsContext.tsx | 55 -- .../settings/AiFeaturesSection.test.tsx | 109 +--- .../components/settings/AiFeaturesSection.tsx | 497 +----------------- .../desktop/src/shared/adeCliGuidance.test.ts | 5 +- apps/desktop/src/shared/adeCliGuidance.ts | 2 + .../src/shared/backgroundUtilityModel.test.ts | 40 ++ .../src/shared/backgroundUtilityModel.ts | 75 +++ apps/desktop/src/shared/types/chat.ts | 11 +- apps/ios/ADE/Models/RemoteModels.swift | 5 + apps/ios/ADE/Services/SyncService.swift | 14 +- .../ADE/Views/Work/WorkRootComponents.swift | 199 ++++--- .../Views/Work/WorkRootScreen+Actions.swift | 22 + apps/ios/ADE/Views/Work/WorkRootScreen.swift | 21 +- docs/features/chat/README.md | 15 +- docs/features/chat/agent-routing.md | 63 +-- docs/features/conflicts/README.md | 11 +- docs/features/conflicts/simulation.md | 9 +- .../onboarding-and-settings/README.md | 24 +- .../configuration-schema.md | 29 +- docs/features/pull-requests/README.md | 40 +- .../sync-and-multi-device/remote-commands.md | 2 +- .../features/terminals-and-sessions/README.md | 4 +- .../pty-and-sessions.md | 40 +- docs/features/workspace-graph/README.md | 39 +- docs/features/workspace-graph/data-sources.md | 9 +- 60 files changed, 1047 insertions(+), 2264 deletions(-) delete mode 100644 apps/desktop/src/renderer/components/graph/graphDialogs/ConflictPanel.tsx delete mode 100644 apps/desktop/src/renderer/components/prs/shared/PrAiSummaryCard.test.tsx delete mode 100644 apps/desktop/src/renderer/components/prs/shared/PrAiSummaryCard.tsx create mode 100644 apps/desktop/src/shared/backgroundUtilityModel.test.ts create mode 100644 apps/desktop/src/shared/backgroundUtilityModel.ts diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index ce66574d3a..1ca43c2900 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1152,8 +1152,8 @@ export async function createAdeRuntime(args: { const gitService = createGitOperationsService({ laneService, operationService, - projectConfigService, aiIntegrationService, + sessionService, logger }); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 10bdd43f07..d2ece04376 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -4081,6 +4081,7 @@ describe("ADE CLI", () => { if (help.kind === "help") { expect(help.text).toContain("ade chat note"); expect(help.text).toContain("ade chat ask"); + expect(help.text).toContain("ade chat generate-names"); expect(help.text).toContain("ade chat demote"); expect(help.text).toContain("ade chat promote"); // Settling is user-/PR-merge-driven only; the help must say so rather @@ -4117,6 +4118,40 @@ describe("ADE CLI", () => { }, ); + it("passes --session through for chat generate-names and defaults to all fields", () => { + const plan = expectExecutePlan(buildCliPlan([ + "chat", + "generate-names", + "--session", + "session-x", + ])); + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "regenerateSessionMetadata", + args: { sessionId: "session-x" }, + }, + }); + }); + + it("forwards generate-names field flags", () => { + const plan = expectExecutePlan(buildCliPlan([ + "chat", + "generate-names", + "--title", + "--status", + "--session", + "session-x", + ])); + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "regenerateSessionMetadata", + args: { sessionId: "session-x", fields: ["title", "statusLine"] }, + }, + }); + }); + describe("session lifecycle commands", () => { const NOW = Date.parse("2026-07-26T12:00:00.000Z"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 1f183333f2..96902953d6 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -2124,6 +2124,9 @@ const HELP_BY_COMMAND: Record = { 'note' and 'ask' default to the caller and accept --session . 'chat settle' / 'chat unsettle' were removed: only the user (or a merged PR) settles a session — report your outcome with 'chat note'. + $ ade chat generate-names Regenerate chat title, lane name, and status line + $ ade chat generate-names --title --status Limit to those fields; omit flags for all three + Also: ade chat update --title, ade lanes rename. $ ade chat steer --personal --text "focus on the tradeoffs" $ ade chat models --personal --provider codex $ ade chat update --personal --title "Trip planning" @@ -7575,7 +7578,8 @@ function buildChatPlan(args: string[]): CliPlan { : null; // `ask` / `note` take free text, not a session positional — they default to // the caller's own $ADE_CHAT_SESSION_ID and accept --session . - const selfLifecycleSub = sub === "ask" || sub === "note"; + const selfLifecycleSub = sub === "ask" || sub === "note" + || sub === "generate-names" || sub === "generate_names" || sub === "names"; const explicitSessionId = readValue(args, ["--session", "--session-id"]); const sessionId = explicitSessionId ?? @@ -7621,6 +7625,24 @@ function buildChatPlan(args: string[]): CliPlan { ], }; } + if (sub === "generate-names" || sub === "generate_names" || sub === "names") { + const fields: string[] = []; + if (readFlag(args, ["--title"])) fields.push("title"); + if (readFlag(args, ["--lane", "--lane-name"])) fields.push("laneName"); + if (readFlag(args, ["--status", "--status-line"])) fields.push("statusLine"); + return { + kind: "execute", + label: "chat generate-names", + steps: [ + actionStep( + "result", + "chat", + "regenerateSessionMetadata", + withSession(fields.length ? { fields } : {}), + ), + ], + }; + } if (sub === "list" || sub === "ls") { const includeArchived = readFlag(args, ["--archived", "--include-archived"]); const excludeArchived = readFlag(args, [ diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index 4706705cef..83a0b2d57b 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -222,6 +222,17 @@ sessions. Running a removed command fails with that explanation. What to do instead when you finish: say so in your final message, and use `ade chat note ""` to leave a durable status line on the Work row. If you are blocked, `ade chat ask ""` raises the row's hand. +Update the note along the way as the state changes; do not wait until the end. + +If you realize the lane, branch, or chat name is wrong, rename it rather than +living with a bad label: + +```bash +ade chat generate-names # title, lane name, and status line +ade chat generate-names --title --status # subset of fields +ade chat update --title "Better chat title" # defaults to $ADE_CHAT_SESSION_ID +ade lanes rename --name "Better name" +``` #### What `note` and `ask` do to the Work row diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index dad5e02edc..79a229be65 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -4007,8 +4007,8 @@ app.whenReady().then(async () => { const gitService = createGitOperationsService({ laneService, operationService, - projectConfigService, aiIntegrationService, + sessionService, logger, onWorktreeChanged: ({ laneId, reason }) => { jobEngine.onLaneDirtyChanged({ laneId, reason }); diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index f6cf4b4915..ca5c7e20b9 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -263,6 +263,7 @@ const SESSION_INTELLIGENCE_TASK_TYPES: ReadonlySet = new Set([ const EXPLICIT_MODEL_ONLY_TASK_TYPES: ReadonlySet = new Set([ ...SESSION_INTELLIGENCE_TASK_TYPES, "context_compaction", + "commit_message", ]); export function readConfiguredFeatureModel(aiConfig: unknown, feature: AiFeatureKey): string | null { @@ -1706,7 +1707,7 @@ export function createAiIntegrationService(args: { throw new Error("No AI provider is available. Install and authenticate Claude Code and/or Codex CLI."); } - if (args.taskType !== "session_title" && !getFeatureFlag(args.feature)) { + if (args.taskType !== "session_title" && args.taskType !== "commit_message" && !getFeatureFlag(args.feature)) { logger.warn("ai.task.skipped_feature_disabled", { requestId, taskType: args.taskType, diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index c6a5a91d6e..0f6c888f52 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -1636,7 +1636,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record conflictGuard(async () => { const proposals = await deps.conflictService!.listProposals({ laneId }); @@ -1644,12 +1644,6 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record conflictGuard(() => deps.conflictService!.requestProposal({ laneId, filePath: filePath?.trim() || undefined })), - }); - tools.applyConflictProposal = tool({ description: "Apply an AI-generated conflict resolution proposal.", inputSchema: z.object({ laneId: z.string().min(1), proposalId: z.string().min(1) }), diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 613d60448a..9877256557 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1541,6 +1541,7 @@ function createMockSessionService() { if (row) row.statusNote = note; return Boolean(row); }), + getStatusNoteUpdatedAt: vi.fn(() => null), setHeadShaStart: vi.fn(), setHeadShaEnd: vi.fn(), setLastOutputPreview: vi.fn(), @@ -1784,6 +1785,7 @@ function createService(overrides: Record = {}) { aiIntegrationService: aiIntegrationService as any, logger: logger as any, appVersion: "0.0.1-test", + nativeTitleWaitMs: 0, getDirtyFileTextForPath: () => undefined, ...overrides, }); @@ -47149,21 +47151,27 @@ describe("suggestLaneNameFromPrompt", () => { ); }); - it("uses the deterministic prompt fallback when title generation is disabled", async () => { + it("still names with AI when title generation is disabled in Settings", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "claude", authenticated: true, path: "/usr/bin/claude", verified: true }, ]); const { service, aiIntegrationService } = createSuggestService({ titleGenerationEnabled: false }); + vi.mocked(aiIntegrationService.summarizeTerminal).mockResolvedValue({ + text: "Login Bug Fix", + inputTokens: 10, + outputTokens: 5, + } as any); const result = await service.suggestLaneNameFromPrompt({ prompt: "Fix the authentication login failure in the dashboard", modelId: "anthropic/claude-haiku-4-5", + provider: "claude", laneId: "lane-1", fallbackName: "chat-20260514-010203", }); - expect(result).toBe("fix-authentication-login-failure-dashboard"); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(result).toBe("login-bug-fix"); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled(); }); it("preserves the generated suffix when the prompt fallback is generic", async () => { @@ -47198,7 +47206,7 @@ describe("suggestLaneNameFromPrompt", () => { expect(result).toBe("login-bug-fix"); }); - it("prefers the configured title model over the requested composer model", async () => { + it("prefers the cheap helper for the ADE provider over the requested session model", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, ]); @@ -47212,13 +47220,14 @@ describe("suggestLaneNameFromPrompt", () => { const result = await service.suggestLaneNameFromPrompt({ prompt: "Fix auto create lane routing and naming", modelId: "openai/gpt-5.5", + provider: "codex", laneId: "lane-1", fallbackName: "chat-20260514-010203", }); expect(result).toBe("auto-create-lane-fix"); expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ - model: "openai/gpt-5.4-mini", + model: "openai/gpt-5.6-luna", taskType: "session_title", })); expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(1); @@ -47480,7 +47489,7 @@ describe("suggestLaneNameFromPrompt", () => { expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(1); }); - it("uses the launched chat model when the title setting answers unusably", async () => { + it("uses the launched chat model when the cheap helper answers unusably", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, ]); @@ -47496,6 +47505,7 @@ describe("suggestLaneNameFromPrompt", () => { const result = await service.generateAutoLaneIdentity({ prompt: "The Claude auth login button hangs after OAuth redirects.", modelId: "openai/gpt-5.4", + provider: "codex", laneId: "lane-1", temporaryBranch: "ade/1a2b3c4d", }); @@ -47506,7 +47516,7 @@ describe("suggestLaneNameFromPrompt", () => { source: "ai", }); expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ - model: "openai/gpt-5.4-mini", + model: "openai/gpt-5.6-luna", })); expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(2, expect.objectContaining({ model: "openai/gpt-5.4", @@ -47514,7 +47524,7 @@ describe("suggestLaneNameFromPrompt", () => { expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(2); }); - it("uses the configured naming model before the launched model", async () => { + it("uses the cheap helper before the launched model", async () => { vi.mocked(detectAllAuth).mockResolvedValue([ { type: "cli-subscription" as any, cli: "codex", authenticated: true, path: "/usr/bin/codex", verified: true }, ]); @@ -47526,12 +47536,13 @@ describe("suggestLaneNameFromPrompt", () => { await service.generateAutoLaneIdentity({ prompt: "Rename automatic lanes", modelId: "openai/gpt-5.4", + provider: "codex", laneId: "lane-1", temporaryBranch: "ade/1a2b3c4d", }); expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith(1, expect.objectContaining({ - model: "openai/gpt-5.4-mini", + model: "openai/gpt-5.6-luna", })); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 658b043b59..93ec5b1b31 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -215,6 +215,11 @@ import { resolvePathWithinRoot, stableStringify, } from "../shared/utils"; +import { + backgroundUtilityReasoningEffort, + NATIVE_TITLE_POLL_MS, + NATIVE_TITLE_WAIT_MS, +} from "../../../shared/backgroundUtilityModel"; import { exceedsProviderInlineLimit, inlineAttachmentHintPart, @@ -8344,6 +8349,11 @@ export function createAgentChatService(args: { ) | null; aiIntegrationService: ReturnType; logger: Logger; + /** + * How long ADE waits for a native provider title before naming the chat + * itself. Tests pass 0 so auto-title assertions do not sleep 8s. + */ + nativeTitleWaitMs?: number; appVersion: string; getAdeCliAgentEnv?: (baseEnv?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; /** Resolves credentials owned by this runtime only; never supplied by a handoff capsule. */ @@ -8457,6 +8467,7 @@ export function createAgentChatService(args: { onLinearIssueChatLinked, getDirtyFileTextForPath, } = args; + const nativeTitleWaitMs = Math.max(0, args.nativeTitleWaitMs ?? NATIVE_TITLE_WAIT_MS); const resolveCodexComputerUseMcp = resolveCodexComputerUseMcpOverride ?? resolveCodexComputerUseMcpConfig; const resolveCodexConfiguredMcpServerNames = resolveCodexConfiguredMcpServerNamesOverride @@ -8878,13 +8889,8 @@ export function createAgentChatService(args: { jsonSchema?: unknown; taskType: "session_title" | "session_summary" | "handoff_summary" | "continuity_summary"; }) => { - const config = resolveChatConfig(); const reasoningEffort = args.reasoningEffort - ?? (args.taskType === "session_title" - ? config.titleReasoningEffort - : args.taskType === "session_summary" - ? config.summaryReasoningEffort - : null); + ?? backgroundUtilityReasoningEffort(args.modelId); return await aiIntegrationService.summarizeTerminal({ cwd: args.cwd, model: args.modelId, @@ -11853,7 +11859,7 @@ export function createAgentChatService(args: { const availableModels = await getAvailableRegistryModels(auth); const candidateModelIds = buildSessionIntelligenceModelCandidates({ availableModels, - settingModelId: resolveChatConfig().summaryModelId, + provider: managed.session.provider, sessionModelId: managed.session.modelId, sessionModel: managed.session.model, }); @@ -12285,7 +12291,7 @@ export function createAgentChatService(args: { const availableModels = await getAvailableRegistryModels(auth); const candidateModelIds = buildSessionIntelligenceModelCandidates({ availableModels, - settingModelId: resolveChatConfig().summaryModelId, + provider: args.managed.session.provider, sessionModelId: args.managed.session.modelId, sessionModel: args.managed.session.model, }); @@ -12627,25 +12633,35 @@ export function createAgentChatService(args: { ): Promise => { if (managed.deleted) return; if (cursorOwnsSessionName(managed.session.cursorCloudAgentId)) return; - const config = resolveChatConfig(); - if (!config.titleGenerationEnabled) return; if (sessionIsManuallyNamed(managed)) return; if (managed.runtimeTitleAdopted) return; if (managed.autoTitleInFlight) return; if (args.stage === "initial" && managed.autoTitleStage !== "none") return; - if (args.stage === "final") { - if (!config.titleRefreshOnComplete) return; - if (managed.autoTitleStage === "final") return; - } + if (args.stage === "final" && managed.autoTitleStage === "final") return; const seed = sanitizeAutoTitle(args.latestUserText ?? managed.autoTitleSeed ?? "", 180); if (!seed) return; + if (args.stage === "initial" && nativeTitleWaitMs > 0) { + const deadline = Date.now() + nativeTitleWaitMs; + while (Date.now() < deadline) { + if (managed.deleted || sessionIsManuallyNamed(managed) || managed.runtimeTitleAdopted) return; + const liveTitle = sessionService.get(managed.session.id)?.title ?? null; + if (hasCustomChatSessionTitle(liveTitle, managed.session.provider)) return; + await new Promise((resolve) => { + setTimeout(resolve, Math.min(NATIVE_TITLE_POLL_MS, Math.max(0, deadline - Date.now()))); + }); + } + } + if (managed.deleted || sessionIsManuallyNamed(managed) || managed.runtimeTitleAdopted) return; + const titleAfterWait = sessionService.get(managed.session.id)?.title ?? null; + if (hasCustomChatSessionTitle(titleAfterWait, managed.session.provider)) return; + const auth = await detectAuth(); const availableModels = await getAvailableRegistryModels(auth); const candidateModelIds = buildSessionIntelligenceModelCandidates({ availableModels, - settingModelId: config.titleModelId, + provider: managed.session.provider, sessionModelId: managed.session.modelId, sessionModel: managed.session.model, }); @@ -12763,10 +12779,9 @@ export function createAgentChatService(args: { resolveModelCandidates: async (managed: ManagedChatSession) => { const auth = await detectAuth(); const availableModels = await getAvailableRegistryModels(auth); - const config = resolveChatConfig(); return buildSessionIntelligenceModelCandidates({ availableModels, - settingModelId: config.titleModelId, + provider: managed.session.provider, sessionModelId: managed.session.modelId, sessionModel: managed.session.model, }); @@ -13548,16 +13563,14 @@ export function createAgentChatService(args: { try { if (prompt.length) { - const config = resolveChatConfig(); - if (config.titleGenerationEnabled !== false) { - const auth = await detectAuth(); - const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); - const candidateModelIds = buildSessionIntelligenceModelCandidates({ - availableModels, - settingModelId: config.titleModelId, - sessionModelId: chatModelId, - sessionModel: requestedModelId, - }); + const auth = await detectAuth(); + const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); + const candidateModelIds = buildSessionIntelligenceModelCandidates({ + availableModels, + provider: args.provider, + sessionModelId: chatModelId, + sessionModel: requestedModelId, + }); // Naming runs in the background, but it still must not walk the whole // registry when everything is down. @@ -13596,7 +13609,6 @@ export function createAgentChatService(args: { identity = attempt.result; source = "ai"; } - } } } catch (error) { logger.warn("agent_chat.suggest_lane_name_unavailable", { @@ -13645,13 +13657,11 @@ export function createAgentChatService(args: { const fallback = () => uniquePromptFallbackLaneName(fallbackLaneNameFromPrompt(prompt), explicitFallback); if (!prompt.length) return fallback(); try { - const config = resolveChatConfig(); - if (config.titleGenerationEnabled === false) return fallback(); const auth = await detectAuth(); const availableModels = getRegistryModels(auth).filter((descriptor) => !descriptor.deprecated); const candidateModelIds = buildSessionIntelligenceModelCandidates({ availableModels, - settingModelId: config.titleModelId, + provider: args.provider, sessionModelId: requestedModelId, }); const { result: suggested } = await runNamingAcrossProviders(candidateModelIds, { @@ -15196,8 +15206,33 @@ export function createAgentChatService(args: { } }; + const maybeRefreshIdleStatusLine = ( + managed: ManagedChatSession, + turnStartedAt: string | null | undefined, + ): void => { + if (!turnStartedAt) return; + if (managed.deleted) return; + const noteUpdatedAt = sessionService.getStatusNoteUpdatedAt?.(managed.session.id); + if (noteUpdatedAt) { + const noteMs = Date.parse(noteUpdatedAt); + const turnMs = Date.parse(turnStartedAt); + if (Number.isFinite(noteMs) && Number.isFinite(turnMs) && noteMs >= turnMs) return; + } + void regenerateSessionMetadata({ + sessionId: managed.session.id, + fields: ["statusLine"], + }).catch((error) => { + logger.warn("agent_chat.idle_status_line_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + const markSessionIdleWithFreshCache = (managed: ManagedChatSession): void => { + const turnStartedAt = managed.session.currentTurnStartedAt; setSessionIdle(managed, { idleSinceAt: nowIso() }); + maybeRefreshIdleStatusLine(managed, turnStartedAt); }; const recoverDetachedChatAfterRestart = ( @@ -19166,71 +19201,12 @@ export function createAgentChatService(args: { deterministicSummary: string | null ): Promise => { if (managed.deleted) return; - const config = resolveChatConfig(); - if (!config.summaryEnabled) return; - if (managed.summaryInFlight) return; - - // Set the deterministic summary first (always available immediately) const session = sessionService.get(managed.session.id); if (!session) return; - const deterministicText = deterministicSummary?.trim() || managed.preview?.trim() || null; if (deterministicText && !session.summary) { sessionService.setSummary(managed.session.id, deterministicText); } - - // Fire-and-forget AI summary enhancement - const auth = await detectAuth(); - const availableModels = await getAvailableRegistryModels(auth); - const candidateModelIds = buildSessionIntelligenceModelCandidates({ - availableModels, - settingModelId: config.summaryModelId, - sessionModelId: managed.session.modelId, - sessionModel: managed.session.model, - }); - if (!candidateModelIds.length) return; - - const baseSummary = session.summary ?? deterministicText ?? ""; - const userRequest = managed.autoTitleSeed?.trim() ?? ""; - const prompt = [ - "You are ADE's session summary assistant.", - "Rewrite this chat session into a concise 1-3 sentence summary describing what was accomplished and any outcome.", - "Do not invent actions or outcomes not mentioned. Return only the summary text.", - "", - `Session title: ${session.title}`, - session.goal ? `Goal: ${session.goal}` : null, - userRequest ? `User request: ${userRequest}` : null, - baseSummary ? `Current summary: ${baseSummary}` : null, - session.lastOutputPreview ? `Latest output: ${session.lastOutputPreview}` : null, - ].filter(Boolean).join("\n"); - - managed.summaryInFlight = true; - try { - const { result } = await runNamingAcrossProviders(candidateModelIds, { - run: async (descriptor) => { - const response = await runSessionIntelligencePrompt({ - cwd: managed.laneWorktreePath, - modelId: descriptor.id, - prompt, - taskType: "session_summary", - }); - const text = response.text.trim(); - return text.length ? text : null; - }, - onFailure: (failure) => { - logger.warn("agent_chat.session_summary_failed", { - sessionId: managed.session.id, - modelId: failure.descriptor.id, - error: failure.error instanceof Error ? failure.error.message : String(failure.error), - }); - }, - }); - if (result) { - sessionService.setSummary(managed.session.id, result); - } - } finally { - managed.summaryInFlight = false; - } }; const finishSession = async ( diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts index fb463fa483..cc184cf657 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.test.ts @@ -17,11 +17,22 @@ import { import { cursorProjectSlug } from "../../../shared/cursorProjectSlug"; describe("Cursor SDK policy", () => { - it("runs every one-shot under the read-only ask policy", () => { - // A one-shot is a tool-less text task, so its policy is fixed rather than - // derived from a caller's permission mode. - expect(CURSOR_SDK_ONESHOT_POLICY).toEqual(resolveCursorSdkPolicy({ cursorModeId: "ask" })); - expect(CURSOR_SDK_ONESHOT_POLICY.fullAuto).toBe(false); + it("runs every one-shot with an empty tool allowlist so the SDK does not advertise tools", () => { + expect(CURSOR_SDK_ONESHOT_POLICY).toMatchObject({ + chatMode: "ask", + approvalPolicy: "read-only", + sandbox: "off", + fullAuto: false, + autoReview: false, + tools: [], + }); + expect(buildCursorSdkLocalRunOptions(CURSOR_SDK_ONESHOT_POLICY).tools).toEqual([]); + expect(cursorSdkLocalAgentMode(CURSOR_SDK_ONESHOT_POLICY)).toBe("plan"); + }); + + it("passes an empty tools allowlist through instead of dropping it", () => { + const policy = resolveCursorSdkPolicy({ cursorModeId: "ask" }); + expect(buildCursorSdkLocalRunOptions({ ...policy, tools: [] }).tools).toEqual([]); }); it("maps Cursor modes to ADE permission policies", () => { diff --git a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts index f259849e8e..a0b3e6d640 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPolicy.ts @@ -114,8 +114,10 @@ export function buildCursorSdkLocalRunOptions( : "inherit"; return { mode: cursorSdkLocalAgentMode(policy), - ...(policy.tools?.length ? { tools: [...policy.tools] } : {}), - ...(policy.disallowedTools?.length ? { disallowedTools: [...policy.disallowedTools] } : {}), + // `[]` is a real allowlist (no tools). Omitting the key is the SDK default + // toolset, so empty must not collapse to "unset". + ...(policy.tools !== undefined ? { tools: [...policy.tools] } : {}), + ...(policy.disallowedTools !== undefined ? { disallowedTools: [...policy.disallowedTools] } : {}), autoReview: policy.autoReview, sandboxDirective, }; @@ -196,18 +198,24 @@ export function resolveCursorSdkPolicy(session: CursorSessionModeInput): CursorS * The one policy every ADE one-shot Cursor prompt runs under. * * A one-shot is a tool-less text task — a title, a lane name, a status line, a - * summary, a commit message, a pull request description — and - * `runCursorSdkLocalPrompt` denies every tool call it makes. Deriving a policy - * from the caller's permission mode therefore decided nothing except the SDK - * chat mode, and it told a `full-auto` caller's model it had tools that the - * bridge then refused. One fixed read-only policy states what actually happens. + * commit message. Ask-mode used to advertise the read-only tool allowlist, then + * `runCursorSdkLocalPrompt` denied every call, so Composer looped until the + * 120s timeout and naming fell through to a slug. An empty `tools` array is + * the actual allowlist: the SDK must not see its default toolset. * * It is constant on purpose: the warm one-shot worker is shared across * features, and a pooled worker keeps the policy it was created with. */ -export const CURSOR_SDK_ONESHOT_POLICY: CursorSdkPermissionPolicy = Object.freeze( - resolveCursorSdkPolicy({ cursorModeId: "ask" }), -); +export const CURSOR_SDK_ONESHOT_POLICY: CursorSdkPermissionPolicy = Object.freeze({ + chatMode: "ask", + approvalPolicy: "read-only", + sandbox: "off", + fullAuto: false, + hardGuards: true, + orchestrationLead: false, + autoReview: false, + tools: [], +}); /** * Ambient Cursor setting layers an agent may load (`local.settingSources`). diff --git a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts index c94dd9db43..9590dfa2a1 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts @@ -251,8 +251,8 @@ function buildLocalAgentOptions(init: CursorSdkWorkerInit): AgentOptionsWithAdeM model: buildCursorModelSelection(init.modelSdkId, init.modelParams), mode: local.mode, name: init.agentName ?? undefined, - ...(local.tools ? { tools: local.tools as AgentOptions["tools"] } : {}), - ...(local.disallowedTools ? { disallowedTools: local.disallowedTools as AgentOptions["disallowedTools"] } : {}), + ...(local.tools !== undefined ? { tools: local.tools as AgentOptions["tools"] } : {}), + ...(local.disallowedTools !== undefined ? { disallowedTools: local.disallowedTools as AgentOptions["disallowedTools"] } : {}), local: { cwd: init.laneRoot, settingSources: cursorSdkSettingSources(init.policy), diff --git a/apps/desktop/src/main/services/chat/sessionNaming.test.ts b/apps/desktop/src/main/services/chat/sessionNaming.test.ts index 99be25073b..11fc4bcdcc 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.test.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it, vi } from "vitest"; +import { + BACKGROUND_UTILITY_CLAUDE_MODEL_ID, + BACKGROUND_UTILITY_CODEX_MODEL_ID, + BACKGROUND_UTILITY_CURSOR_MODEL_ID, +} from "../../../shared/backgroundUtilityModel"; import { getAvailableModels, type ModelDescriptor } from "../../../shared/modelRegistry"; import { buildNamingModelCandidates, @@ -96,12 +101,33 @@ describe("buildNamingModelCandidates", () => { }); describe("buildSessionIntelligenceModelCandidates", () => { - it("uses the setting first and the session model second", () => { + it("uses the cheap ADE-provider helper first and the session model second", () => { expect(buildSessionIntelligenceModelCandidates({ availableModels: ALL_MODELS, - settingModelId: ANTHROPIC_MODELS[0]?.id, + provider: "claude", sessionModelId: OPENAI_MODELS[0]?.id, - })).toEqual([ANTHROPIC_MODELS[0]?.id, OPENAI_MODELS[0]?.id]); + })).toEqual([BACKGROUND_UTILITY_CLAUDE_MODEL_ID, OPENAI_MODELS[0]?.id]); + expect(buildSessionIntelligenceModelCandidates({ + availableModels: ALL_MODELS, + provider: "codex", + sessionModelId: ANTHROPIC_MODELS[0]?.id, + })).toEqual([BACKGROUND_UTILITY_CODEX_MODEL_ID, ANTHROPIC_MODELS[0]?.id]); + }); + + it("does not spawn a Claude helper for OpenCode-wrapped Anthropic", () => { + expect(buildSessionIntelligenceModelCandidates({ + availableModels: ALL_MODELS, + provider: "opencode", + sessionModelId: ANTHROPIC_MODELS[0]?.id, + })).toEqual([ANTHROPIC_MODELS[0]?.id]); + }); + + it("injects Composer 2.5 for Cursor even when the auth snapshot has no Cursor inventory", () => { + expect(buildSessionIntelligenceModelCandidates({ + availableModels: [], + provider: "cursor", + sessionModelId: "cursor/grok-4-5", + })).toEqual([BACKGROUND_UTILITY_CURSOR_MODEL_ID, "cursor/grok-4-5"]); }); it("keeps the session model even when the auth snapshot is empty", () => { @@ -125,9 +151,9 @@ describe("buildSessionIntelligenceModelCandidates", () => { })).toEqual(["anthropic/claude-sonnet-5"]); expect(buildSessionIntelligenceModelCandidates({ availableModels: ALL_MODELS, - settingModelId: "anthropic/claude-sonnet-5", + provider: "claude", sessionModel: "sonnet", - })).toEqual(["anthropic/claude-sonnet-5"]); + })).toEqual([BACKGROUND_UTILITY_CLAUDE_MODEL_ID, "anthropic/claude-sonnet-5"]); }); }); diff --git a/apps/desktop/src/main/services/chat/sessionNaming.ts b/apps/desktop/src/main/services/chat/sessionNaming.ts index 0b6c1f9e8f..c16838ba54 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.ts @@ -5,9 +5,14 @@ * * Those callers used to carry their own hand-copied chain and retry loop, which * had already drifted apart. They live here so "the same chain" is a fact - * rather than a comment: the user's title setting, then this session's model, - * then a deterministic name. No hardcoded Haiku/mini namer. + * rather than a comment: the cheap ADE-provider helper, then this session's + * model, then a deterministic name. Settings pickers are not part of the chain. */ +import { + adeBackgroundUtilityProvider, + adeBackgroundUtilityProviderFromToolType, + backgroundUtilityModelId, +} from "../../../shared/backgroundUtilityModel"; import { deriveDeterministicLaneTitleFromPrompt, GENERIC_LANE_FALLBACK_TITLE, @@ -502,17 +507,22 @@ export function buildNamingModelCandidates(args: { } /** - * Session intelligence picks a model in this order only: the user's setting, - * then this session's model. There is no hardcoded Haiku/mini/"first available" - * namer. Callers fall through to a deterministic title/summary when both miss. + * Session intelligence picks a model in this order only: the cheap helper for + * this ADE provider (`claude`/`codex`/`cursor`), then this session's model. + * OpenCode, Droid, Pi, and ACP skip the cheap helper and use the session model. + * Callers fall through to a deterministic title when both miss. */ export function buildSessionIntelligenceModelCandidates(args: { availableModels: ModelDescriptor[]; - settingModelId?: string | null; + provider?: string | null; + toolType?: string | null; sessionModelId?: string | null; sessionModel?: string | null; }): string[] { - const preferred = [args.settingModelId, args.sessionModelId, args.sessionModel]; + const utilityProvider = adeBackgroundUtilityProvider(args.provider) + ?? adeBackgroundUtilityProviderFromToolType(args.toolType); + const cheapModelId = utilityProvider ? backgroundUtilityModelId(utilityProvider) : null; + const preferred = [cheapModelId, args.sessionModelId, args.sessionModel]; return buildNamingModelCandidates({ availableModels: withSessionModelDescriptors(args.availableModels, preferred), preferred, diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index da80d5015a..60ad273c5b 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -159,10 +159,10 @@ function buildCtoEnvironmentKnowledge(): string { "", "PTY Terminal: A shell terminal session (runs any CLI command). Created with createTerminal({ laneId, title?, startupCommand? }). No ADE tool integration — use for raw shell commands only.", "", - "Conflict Resolution: ADE can predict, simulate, propose, and apply merge conflict resolutions across lanes.", + "Conflict Resolution: ADE can predict and simulate merge conflicts across lanes.", " - Risk matrix shows potential conflicts before they happen.", - " - AI-generated proposals can be applied or undone.", - " - Tools: getConflictStatus, getConflictRiskMatrix, simulateMerge, requestConflictProposal, applyConflictProposal.", + " - Edge clicks on Graph run a merge simulation (clean vs conflict, overlapping files).", + " - Tools: getConflictStatus, getConflictRiskMatrix, simulateMerge.", "", "## ADE Pages & Navigation", "", @@ -248,7 +248,7 @@ function buildCtoEnvironmentKnowledge(): string { " 'Run the tests' → listTestSuites to find available suites, then runTests.", " 'Commit and push' → gitCommit then gitPush.", " 'Check for conflicts' → getConflictStatus or getConflictRiskMatrix.", - " 'Resolve merge conflicts' → getConflictStatus, requestConflictProposal, applyConflictProposal.", + " 'Resolve merge conflicts' → getConflictStatus, then work the conflicting files.", " 'Steer an active agent' → steerChat({ sessionId, instruction }).", " 'How is the project doing?' → getProjectHealthSummary.", " 'What happened recently?' → getRecentEvents.", diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index 45eb749ac1..3b689aec4e 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -19,13 +19,15 @@ vi.mock("./git", () => ({ })); import { createGitOperationsService } from "./gitOperationsService"; -import { missingFeatureModelMessage } from "../ai/aiIntegrationService"; +import { + BACKGROUND_UTILITY_CODEX_MODEL_ID, +} from "../../../shared/backgroundUtilityModel"; const STASH_LIST_FORMAT = "--format=%H%x1f%gd%x1f%cI%x1f%gs"; function createTestGitOperationsService( branchRef = "feature/stash-test", - overrides: { worktreePath?: string } = {}, + overrides: { worktreePath?: string; sessionService?: unknown } = {}, ) { const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); const mockFinish = vi.fn(); @@ -55,14 +57,12 @@ function createTestGitOperationsService( list: mockList, listHeadChanges: mockListHeadChanges, } as any, - projectConfigService: { - get: () => ({ effective: { ai: {} } }), - } as any, aiIntegrationService: { getFeatureFlag: () => false, getStatus: vi.fn(async () => ({ availableModelIds: [] })), generateCommitMessage: vi.fn(), } as any, + ...(overrides.sessionService ? { sessionService: overrides.sessionService as any } : {}), logger: mockLogger as any, }); @@ -77,6 +77,15 @@ function createTestGitOperationsService( }; } +function turnedLaneSession(toolType: string, model?: string) { + return { + toolType, + lastActivityAt: "2026-09-01T12:00:00.000Z", + lastOutputPreview: "done", + resumeMetadata: { launch: { model: model ?? null } }, + }; +} + describe("gitOperationsService lane worktree guard", () => { beforeEach(() => { vi.clearAllMocks(); @@ -875,9 +884,6 @@ describe("gitOperationsService.commit", () => { start: mockStart, finish: vi.fn(), } as any, - projectConfigService: { - get: () => ({ effective: { ai: {} } }), - } as any, aiIntegrationService: { getFeatureFlag: () => false, getStatus: vi.fn(async () => ({ availableModelIds: [] })), @@ -1084,17 +1090,6 @@ describe("gitOperationsService.generateCommitMessage", () => { start: vi.fn(), finish: vi.fn(), } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - featureModelOverrides: { - commit_messages: "anthropic/claude-haiku-4-5", - }, - }, - }, - }), - } as any, aiIntegrationService: { getFeatureFlag: () => true, getStatus: vi.fn(async () => ({ @@ -1102,6 +1097,9 @@ describe("gitOperationsService.generateCommitMessage", () => { })), generateCommitMessage, } as any, + sessionService: { + list: () => [turnedLaneSession("claude-chat")], + } as any, logger: makeStubLogger(), }); @@ -1113,8 +1111,14 @@ describe("gitOperationsService.generateCommitMessage", () => { expect(generateCommitMessage).not.toHaveBeenCalled(); }); - it("refuses to call AI when no Commit Messages model is configured", async () => { + it("returns an empty message when no session on the lane has turned", async () => { const generateCommitMessage = vi.fn(); + mockGit.runGit.mockImplementation(async (args: string[]) => { + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--show-toplevel") { + return { exitCode: 0, stdout: "/tmp/ade-lane\n", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: `unexpected git command: ${args.join(" ")}` }; + }); const service = createGitOperationsService({ laneService: { getLaneBaseAndBranch: () => ({ @@ -1128,20 +1132,21 @@ describe("gitOperationsService.generateCommitMessage", () => { start: vi.fn(), finish: vi.fn(), } as any, - projectConfigService: { - get: () => ({ effective: { ai: {} } }), - } as any, aiIntegrationService: { getFeatureFlag: () => true, getStatus: vi.fn(async () => ({ availableModelIds: ["openai/gpt-5.4"] })), generateCommitMessage, } as any, + sessionService: { + list: () => [], + } as any, logger: makeStubLogger(), }); - await expect(service.generateCommitMessage({ laneId: "lane-1" })).rejects.toThrow( - missingFeatureModelMessage("commit_messages"), - ); + await expect(service.generateCommitMessage({ laneId: "lane-1" })).resolves.toEqual({ + message: "", + model: null, + }); expect(generateCommitMessage).not.toHaveBeenCalled(); }); @@ -1187,17 +1192,6 @@ describe("gitOperationsService.generateCommitMessage", () => { start: vi.fn(), finish: vi.fn(), } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - featureModelOverrides: { - commit_messages: "anthropic/claude-haiku-4-5", - }, - }, - }, - }), - } as any, aiIntegrationService: { getFeatureFlag: () => true, getStatus: vi.fn(async () => ({ @@ -1218,6 +1212,9 @@ describe("gitOperationsService.generateCommitMessage", () => { }; }), } as any, + sessionService: { + list: () => [turnedLaneSession("claude-chat")], + } as any, logger: { info: vi.fn(), warn: vi.fn(), @@ -1249,6 +1246,116 @@ describe("gitOperationsService.generateCommitMessage", () => { ]); }); + it("picks the cheap helper from the last turned session's ADE provider", async () => { + mockGit.runGit.mockImplementation(async (args: string[]) => { + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--show-toplevel") { + return { exitCode: 0, stdout: "/tmp/ade-lane\n", stderr: "" }; + } + if (args[0] === "diff") { + return { exitCode: 0, stdout: "M\tapps/desktop/src/main/foo.ts\n", stderr: "" }; + } + if (args[0] === "show") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: `unexpected git command: ${args.join(" ")}` }; + }); + + const generateCommitMessage = vi.fn(async (args: { model?: string; reasoningEffort?: string | null }) => ({ + text: "Update git service.", + structuredOutput: null, + provider: "openai", + model: args.model ?? null, + sessionId: null, + inputTokens: null, + outputTokens: null, + durationMs: 5, + })); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: () => ({ + baseRef: "main", + branchRef: "feature/commit-messages", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + } as any, + operationService: { start: vi.fn(), finish: vi.fn() } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [BACKGROUND_UTILITY_CODEX_MODEL_ID] })), + generateCommitMessage, + } as any, + sessionService: { + list: () => [turnedLaneSession("codex-chat", "openai/gpt-5.4")], + } as any, + logger: makeStubLogger(), + }); + + const result = await service.generateCommitMessage({ laneId: "lane-1" }); + expect(result.model).toBe(BACKGROUND_UTILITY_CODEX_MODEL_ID); + expect(generateCommitMessage).toHaveBeenCalledWith(expect.objectContaining({ + model: BACKGROUND_UTILITY_CODEX_MODEL_ID, + reasoningEffort: "low", + })); + }); + + it("uses the last turned session model when that session is not Claude, Codex, or Cursor", async () => { + mockGit.runGit.mockImplementation(async (args: string[]) => { + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--show-toplevel") { + return { exitCode: 0, stdout: "/tmp/ade-lane\n", stderr: "" }; + } + if (args[0] === "diff") { + return { exitCode: 0, stdout: "M\tapps/desktop/src/main/foo.ts\n", stderr: "" }; + } + if (args[0] === "show") { + return { exitCode: 0, stdout: "", stderr: "" }; + } + return { exitCode: 1, stdout: "", stderr: `unexpected git command: ${args.join(" ")}` }; + }); + + const generateCommitMessage = vi.fn(async (args: { model?: string }) => ({ + text: "Update git service.", + structuredOutput: null, + provider: "opencode", + model: args.model ?? null, + sessionId: null, + inputTokens: null, + outputTokens: null, + durationMs: 5, + })); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: () => ({ + baseRef: "main", + branchRef: "feature/commit-messages", + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + } as any, + operationService: { start: vi.fn(), finish: vi.fn() } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: ["opencode/glm-4.6"] })), + generateCommitMessage, + } as any, + sessionService: { + list: () => [ + { ...turnedLaneSession("claude-chat"), lastActivityAt: "2026-09-01T11:00:00.000Z" }, + { ...turnedLaneSession("opencode-chat", "opencode/glm-4.6"), lastActivityAt: "2026-09-01T13:00:00.000Z" }, + ], + } as any, + logger: makeStubLogger(), + }); + + const result = await service.generateCommitMessage({ laneId: "lane-1" }); + expect(result.model).toBe("opencode/glm-4.6"); + expect(generateCommitMessage).toHaveBeenCalledWith(expect.objectContaining({ + model: "opencode/glm-4.6", + })); + }); + it("prefixes generated commit messages with a Linear reference for linked lanes", async () => { mockGit.runGit.mockImplementation(async (args: string[]) => { if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--show-toplevel") { @@ -1301,17 +1408,6 @@ describe("gitOperationsService.generateCommitMessage", () => { start: vi.fn(), finish: vi.fn(), } as any, - projectConfigService: { - get: () => ({ - effective: { - ai: { - featureModelOverrides: { - commit_messages: "anthropic/claude-haiku-4-5", - }, - }, - }, - }), - } as any, aiIntegrationService: { getFeatureFlag: () => true, getStatus: vi.fn(async () => ({ @@ -1328,6 +1424,9 @@ describe("gitOperationsService.generateCommitMessage", () => { durationMs: 5, })), } as any, + sessionService: { + list: () => [turnedLaneSession("claude-chat")], + } as any, logger: { info: vi.fn(), warn: vi.fn(), @@ -1590,9 +1689,6 @@ function makeServiceWithLanes(opts: { start: vi.fn().mockReturnValue({ operationId: "op-1" }), finish: vi.fn(), } as any, - projectConfigService: { - get: () => ({ effective: { ai: {} } }), - } as any, aiIntegrationService: { getFeatureFlag: () => false, getStatus: vi.fn(async () => ({ availableModelIds: [] })), @@ -1789,7 +1885,6 @@ describe("gitOperationsService.checkoutBranch", () => { switchBranch, } as any, operationService: { start: operationStart, finish: operationFinish } as any, - projectConfigService: { get: () => ({ effective: { ai: {} } }) } as any, aiIntegrationService: { getFeatureFlag: () => false, getStatus: vi.fn(async () => ({ availableModelIds: [] })), @@ -1849,7 +1944,6 @@ describe("gitOperationsService.checkoutBranch", () => { switchBranch, } as any, operationService: { start: operationStart, finish: vi.fn() } as any, - projectConfigService: { get: () => ({ effective: { ai: {} } }) } as any, aiIntegrationService: { getFeatureFlag: () => false, getStatus: vi.fn(async () => ({ availableModelIds: [] })), diff --git a/apps/desktop/src/main/services/git/gitOperationsService.ts b/apps/desktop/src/main/services/git/gitOperationsService.ts index cc129fb4e6..5c1e6d951c 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.ts @@ -41,8 +41,12 @@ import { ensureLinearCommitReference } from "../../../shared/linearMagicWords"; import type { Logger } from "../logging/logger"; import type { createLaneService } from "../lanes/laneService"; import type { createOperationService } from "../history/operationService"; -import type { createProjectConfigService } from "../config/projectConfigService"; -import { missingFeatureModelMessage, readConfiguredFeatureModel, type createAiIntegrationService } from "../ai/aiIntegrationService"; +import type { createAiIntegrationService } from "../ai/aiIntegrationService"; +import { + adeBackgroundUtilityProviderFromToolType, + backgroundUtilityModelId, + backgroundUtilityReasoningEffort, +} from "../../../shared/backgroundUtilityModel"; import { isRecord, safeJsonParse } from "../shared/utils"; type LaneInfo = { @@ -201,16 +205,23 @@ async function getAbsoluteGitDir(worktreePath: string): Promise { export function createGitOperationsService({ laneService, operationService, - projectConfigService, aiIntegrationService, + sessionService, logger, onHeadChanged, onWorktreeChanged }: { laneService: ReturnType; operationService: ReturnType; - projectConfigService: ReturnType; aiIntegrationService: ReturnType; + sessionService?: { + list(args?: { laneId?: string; limit?: number | null }): Array<{ + toolType?: string | null; + lastActivityAt?: string | null; + lastOutputPreview?: string | null; + resumeMetadata?: { launch?: { model?: string | null } | null } | null; + }>; + }; logger: Logger; onHeadChanged?: (args: { laneId: string; @@ -338,22 +349,37 @@ export function createGitOperationsService({ return current.ref; } - async function assertCommitMessageGenerationEnabled(): Promise { - if (!aiIntegrationService.getFeatureFlag("commit_messages")) { - throw new Error("AI commit messages are off. Enable Commit Messages in Settings or type a commit message manually."); - } - - const model = readConfiguredFeatureModel(projectConfigService.get().effective.ai, "commit_messages"); - if (!model) { - throw new Error(missingFeatureModelMessage("commit_messages")); - } - - const aiStatus = await aiIntegrationService.getStatus().catch(() => null); - if (aiStatus?.availableModelIds?.length && !aiStatus.availableModelIds.includes(model)) { - throw new Error(`The configured Commit Messages model '${model}' is not currently available. Update Settings or type a commit message manually.`); + function pickLastTurnedLaneSession(laneId: string): { + toolType?: string | null; + lastActivityAt?: string | null; + lastOutputPreview?: string | null; + resumeMetadata?: { launch?: { model?: string | null } | null } | null; + } | null { + const rows = sessionService?.list({ laneId, limit: 80 }) ?? []; + let best: (typeof rows)[number] | null = null; + let bestMs = Number.NEGATIVE_INFINITY; + for (const row of rows) { + const hasOutput = Boolean(row.lastActivityAt || String(row.lastOutputPreview ?? "").trim()); + if (!hasOutput) continue; + const activityMs = row.lastActivityAt ? Date.parse(row.lastActivityAt) : 0; + const rank = Number.isFinite(activityMs) ? activityMs : 0; + if (!best || rank > bestMs) { + best = row; + bestMs = rank; + } } + return best; + } - return model; + async function resolveCommitMessageModel(laneId: string): Promise { + const session = pickLastTurnedLaneSession(laneId); + if (!session) return null; + const provider = adeBackgroundUtilityProviderFromToolType(session.toolType); + const cheap = provider ? backgroundUtilityModelId(provider) : null; + const sessionModel = typeof session.resumeMetadata?.launch?.model === "string" + ? session.resumeMetadata.launch.model.trim() + : ""; + return cheap || sessionModel || null; } async function loadCommitMessagePromptContext(lane: LaneInfo): Promise { @@ -414,7 +440,7 @@ export function createGitOperationsService({ function toCommitMessageGenerationError(error: unknown): Error { const message = error instanceof Error ? error.message : String(error); if (/No AI provider is available/i.test(message)) { - return new Error("No AI provider is available for Commit Messages. Configure the model/provider in Settings or type a commit message manually."); + return new Error("No AI provider is available to suggest a commit message. Type one, then commit."); } return error instanceof Error ? error : new Error(message); } @@ -771,20 +797,25 @@ export function createGitOperationsService({ }, async generateCommitMessage(args: GitGenerateCommitMessageArgs): Promise { - const model = await assertCommitMessageGenerationEnabled(); const lane = laneService.getLaneBaseAndBranch(args.laneId); await assertLaneWorktreeRoot(lane); + const model = await resolveCommitMessageModel(args.laneId); + if (!model) { + return { message: "", model: null }; + } const promptContext = await loadCommitMessagePromptContext(lane); if (!promptContext.hasStagedChanges && !args.amend) { throw new Error("Stage changes before generating a commit message."); } const prompt = buildCommitMessagePrompt(lane, args, promptContext); + const reasoningEffort = backgroundUtilityReasoningEffort(model); try { const result = await aiIntegrationService.generateCommitMessage({ cwd: lane.worktreePath, prompt, - model + model, + ...(reasoningEffort ? { reasoningEffort } : {}), }); const normalized = normalizeCommitMessage(result.text); return { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index a7f6901fb6..3454d9b686 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -7287,6 +7287,9 @@ export function registerIpc({ ...(typeof record.chatModelId === "string" && record.chatModelId.trim().length ? { chatModelId: record.chatModelId.trim() } : {}), + ...(typeof record.provider === "string" && record.provider.trim().length + ? { provider: record.provider.trim() } + : {}), ...(typeof record.fallbackName === "string" && record.fallbackName.trim().length ? { fallbackName: record.fallbackName.trim() } : {}), diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 2a7ae8b65f..b4a52e68d7 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -98,6 +98,11 @@ import { isTrackedAgentCliToolType, PTY_SEND_PRE_DELIVERY_ERROR_CODE, } from "../../../shared/types"; +import { + adeBackgroundUtilityProviderFromToolType, + backgroundUtilityModelId, + backgroundUtilityReasoningEffort, +} from "../../../shared/backgroundUtilityModel"; import { isProviderSlashCommandInput } from "../../../shared/chatSlashCommands"; import { CURSOR_CLI_EXECUTABLES } from "../../../shared/providerCliExecutables"; import { @@ -2499,21 +2504,14 @@ export function createPtyService({ applyLivePtyResize(entry, cols, rows); }; - const isTitleGenerationEnabled = (): boolean => { - const si = getSessionIntelligence(); - return si?.titles?.enabled ?? true; - }; - - const resolveTitleModelId = (): string | undefined => { - const si = getSessionIntelligence(); - const raw = si?.titles?.modelId; - return typeof raw === "string" && raw.trim().length ? raw.trim() : undefined; - }; + const isTitleGenerationEnabled = (): boolean => true; - const resolveTitleReasoningEffort = (): string | null => { - const si = getSessionIntelligence(); - const raw = si?.titles?.reasoningEffort; - return typeof raw === "string" && raw.trim().length ? raw.trim() : null; + const resolveCliTitleModelIds = ( + session: { toolType?: string | null; resumeMetadata?: TerminalResumeMetadata | null } | null | undefined, + ): string[] => { + const provider = adeBackgroundUtilityProviderFromToolType(session?.toolType); + const cheap = provider ? backgroundUtilityModelId(provider) : undefined; + return uniqueCliModelIds(cheap, resolveSessionLaunchModelId(session)); }; const resolveSessionLaunchModelId = ( @@ -2566,8 +2564,7 @@ export function createPtyService({ } const laneName = session.laneName?.trim() || "Current lane"; const outputSlice = stripAnsi(entry.recentOutputTail).replace(/\r/g, "\n").trim().slice(-4000); - const titleModelIds = uniqueCliModelIds(resolveTitleModelId(), resolveSessionLaunchModelId(session)); - const titleReasoningEffort = resolveTitleReasoningEffort(); + const titleModelIds = resolveCliTitleModelIds(session); if (!titleModelIds.length) return; const prompt = [ "Write a concise title for this CLI coding session.", @@ -2582,13 +2579,14 @@ export function createPtyService({ ].join("\n"); const capturedAi = aiIntegrationService; const title = await tryCliAiModels(titleModelIds, async (modelId) => { + const reasoningEffort = backgroundUtilityReasoningEffort(modelId); const result = await capturedAi.summarizeTerminal({ cwd: entry.boundCwd || entry.laneWorktreePath, prompt, taskType: "session_title", timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, model: modelId, - ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), }); if (entry.disposed) return null; return sanitizeGeneratedCliTitle(result.text); @@ -2830,64 +2828,11 @@ export function createPtyService({ sessionService.setSummary(sessionId, summary); - const si = getSessionIntelligence(); const hasAi = Boolean(aiIntegrationService && aiIntegrationService.getMode() !== "guest"); - // AI-enhanced summary (only when summaries are enabled and AI is available) - if (si?.summaries?.enabled !== false && hasAi) { - try { - const prompt = [ - "You are ADE's terminal summary assistant.", - "Rewrite this terminal session into a concise 1-3 sentence summary with outcome and next action.", - "Do not invent commands or outcomes.", - "", - "Deterministic summary:", - summary, - "", - "Terminal transcript tail:", - transcript.slice(-18_000) - ].join("\n"); - - const summarySetting = typeof si?.summaries?.modelId === "string" && si.summaries.modelId.trim().length - ? si.summaries.modelId.trim() - : undefined; - const summaryModelIds = uniqueCliModelIds(summarySetting, resolveSessionLaunchModelId(session)); - const summaryReasoningEffort = typeof si?.summaries?.reasoningEffort === "string" && si.summaries.reasoningEffort.trim().length - ? si.summaries.reasoningEffort.trim() - : undefined; - - const aiSummary = await tryCliAiModels(summaryModelIds, async (modelId) => { - const result = await aiIntegrationService!.summarizeTerminal({ - cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, - prompt, - model: modelId, - ...(summaryReasoningEffort ? { reasoningEffort: summaryReasoningEffort } : {}), - }); - const text = result.text.trim(); - return text.length ? text : null; - }, (modelId, err) => { - logger.warn("pty.ai_summary_failed", { - sessionId, - modelId, - error: err instanceof Error ? err.message : String(err), - }); - }); - if (aiSummary) { - sessionService.setSummary(sessionId, aiSummary); - } - } catch (err) { - logger.warn("pty.ai_summary_failed", { - sessionId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - // Refresh title on complete — runs independently of AI summaries toggle + // Refresh title on complete. CLI sessions are always ADE-named. if (hasAi) { - const refreshOnComplete = getSessionIntelligence()?.titles?.refreshOnComplete ?? true; - if (refreshOnComplete && isTitleGenerationEnabled()) { - try { + try { if (isSessionManuallyNamed(sessionService, sessionId)) { logger.info("pty.session_title_refresh_skipped_user_renamed", { sessionId }); } else { @@ -2904,16 +2849,16 @@ export function createPtyService({ transcript.slice(-2000), ].filter(Boolean).join("\n"); - const titleModelIds = uniqueCliModelIds(resolveTitleModelId(), resolveSessionLaunchModelId(session)); - const titleReasoningEffort = resolveTitleReasoningEffort(); + const titleModelIds = resolveCliTitleModelIds(session); const finalTitle = await tryCliAiModels(titleModelIds, async (modelId) => { + const reasoningEffort = backgroundUtilityReasoningEffort(modelId); const titleResult = await aiIntegrationService!.summarizeTerminal({ cwd: summaryCwd || laneService.getLaneBaseAndBranch(session.laneId).worktreePath, prompt: titlePrompt, taskType: "session_title", timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, model: modelId, - ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), }); return sanitizeGeneratedCliTitle(titleResult.text); }, (modelId, err) => { @@ -2937,7 +2882,6 @@ export function createPtyService({ error: err instanceof Error ? err.message : String(err), }); } - } } }) .catch(() => { @@ -3929,46 +3873,12 @@ export function createPtyService({ const scheduleClaudeRuntimeTitleCaptureBestEffort = ( sessionId: string, - claudeSessionId: string | null | undefined, - cwd: string, + _claudeSessionId: string | null | undefined, + _cwd: string, ): void => { - const cleanClaudeSessionId = sanitizeResumeTargetId(claudeSessionId ?? null); - if (!cleanClaudeSessionId) return; - const key = `${sessionId}:${cleanClaudeSessionId}`; - if (claudeTitleCaptureKeys.has(key)) return; - claudeTitleCaptureKeys.add(key); - - const filePath = claudeSessionFilePathForCwd(cwd, cleanClaudeSessionId); - const timers = new Set>(); - const cleanup = (): void => { - claudeTitleCaptureKeys.delete(key); - for (const timer of timers) clearTimeout(timer); - timers.clear(); - }; - const tryTitle = (source: string): boolean => { - const title = readClaudeRuntimeTitle(filePath, cleanClaudeSessionId); - return adoptClaudeRuntimeTitle(sessionId, title, source); - }; - - if (tryTitle("claude-storage-initial")) { - cleanup(); - return; - } - - for (let i = 0; i < CLAUDE_TITLE_POLL_DELAYS_MS.length; i += 1) { - const timer = setTimeout(() => { - timers.delete(timer); - try { - if (tryTitle(`claude-storage-poll-${i}`)) cleanup(); - } catch (err) { - logger.warn("pty.claude_runtime_title_capture_failed", { sessionId, attempt: i, err: String(err) }); - } finally { - if (i === CLAUDE_TITLE_POLL_DELAYS_MS.length - 1) cleanup(); - } - }, CLAUDE_TITLE_POLL_DELAYS_MS[i]); - timer.unref?.(); - timers.add(timer); - } + // CLI sessions are always ADE-named. Native JSONL titles used to win and + // leave rows on generic Claude Chat / first-prompt echoes. + void sessionId; }; const adoptCodexRuntimeThreadName = ( @@ -3996,21 +3906,10 @@ export function createPtyService({ const scheduleCodexRuntimeTitleCaptureBestEffort = ( sessionId: string, - codexSessionId: string, - filePath: string, + _codexSessionId: string, + _filePath: string, ): void => { - const tryTitle = (source: string): boolean => { - const title = readCodexRuntimeThreadName(filePath, codexSessionId); - return adoptCodexRuntimeThreadName(sessionId, title, source); - }; - - if (tryTitle("codex-storage-initial")) return; - for (let i = 0; i < CODEX_TITLE_POLL_DELAYS_MS.length; i += 1) { - const timer = setTimeout(() => { - tryTitle(`codex-storage-poll-${i}`); - }, CODEX_TITLE_POLL_DELAYS_MS[i]); - timer.unref?.(); - } + void sessionId; }; const listOtherAdoptedCodexTargetIds = (sessionId: string): Set => { @@ -6993,17 +6892,17 @@ export function createPtyService({ strippedOutput.slice(0, 800) ].join("\n"); - const titleModelIds = uniqueCliModelIds(resolveTitleModelId(), resolveSessionLaunchModelId(session)); - const titleReasoningEffort = resolveTitleReasoningEffort(); + const titleModelIds = resolveCliTitleModelIds(session); if (!titleModelIds.length) return; void tryCliAiModels(titleModelIds, async (modelId) => { + const reasoningEffort = backgroundUtilityReasoningEffort(modelId); const result = await capturedAi.summarizeTerminal({ cwd: entry.boundCwd || entry.laneWorktreePath, prompt, taskType: "session_title", timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, model: modelId, - ...(titleReasoningEffort ? { reasoningEffort: titleReasoningEffort } : {}), + ...(reasoningEffort ? { reasoningEffort } : {}), }); return sanitizeGeneratedCliTitle(result.text); }, (modelId, err) => { diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 13e5849514..4896b23c12 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -429,6 +429,7 @@ export function createSessionService({ // revision guarantees. const settleLifecycle = createSettleLifecycleWriter(db); const writeSettleLifecycle = settleLifecycle.write; + const statusNoteUpdatedAtById = new Map(); /** * Shared skeleton for the single-session lifecycle mutators: trim, existence @@ -2119,9 +2120,14 @@ export function createSessionService({ "update terminal_sessions set status_note = ? where id = ?", [normalizeSessionStatusNote(note), id], ); + statusNoteUpdatedAtById.set(id, new Date().toISOString()); }); }, + getStatusNoteUpdatedAt(sessionId: string): string | null { + return statusNoteUpdatedAtById.get(sessionId) ?? null; + }, + /** * A pending approval / input request is the loudest hand-raise there is: * it un-settles (including any override) and it wakes a snoozed row early, diff --git a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts index a706f7a062..cc3456cf74 100644 --- a/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts +++ b/apps/desktop/src/renderer/components/automations/adeActionSchemas.ts @@ -763,18 +763,6 @@ export const ADE_ACTION_SCHEMAS: readonly AdeActionSchema[] = [ description: "Replace the PR body.", params: [PR_ID_PARAM, { name: "body", type: "string", required: true }], }, - { - domain: "pr", - action: "draftDescription", - label: "Draft PR description with AI", - description: "Use an AI model to draft a title + body for a PR from the lane's commits.", - params: [ - LANE_ID_PARAM, - { name: "model", type: "string" }, - { name: "reasoningEffort", type: "string" }, - { name: "baseBranch", type: "string" }, - ], - }, { domain: "pr", action: "setLabels", diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index a7d0224160..3eb4c9c06d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -9232,6 +9232,7 @@ export function AgentChatPane({ modelId: string; /** Model the chat itself launched with; the backend's cross-provider naming fallback needs it even when a naming model is configured. */ chatModelId?: string; + provider?: string; fallbackName: string; temporaryBranch?: string; attachments?: AgentChatFileRef[]; @@ -9248,6 +9249,7 @@ export function AgentChatPane({ modelId: args.modelId, fallbackName: args.fallbackName, ...(args.chatModelId ? { chatModelId: args.chatModelId } : {}), + ...(args.provider ? { provider: args.provider } : {}), ...(args.temporaryBranch ? { temporaryBranch: args.temporaryBranch } : {}), ...(args.attachments?.length ? { attachments: args.attachments.slice(0, 8) } : {}), }; @@ -9302,6 +9304,7 @@ export function AgentChatPane({ prompt: string; modelId: string; chatModelId?: string; + provider?: string; fallbackName: string; temporaryBranch?: string; attachments?: AgentChatFileRef[]; @@ -9322,6 +9325,7 @@ export function AgentChatPane({ prompt: string; modelId: string; chatModelId?: string; + provider?: string; fallbackBase: string; children: Array<{ laneId: string; suffix: string }>; pin?: OpenProjectBinding | null; @@ -9331,6 +9335,7 @@ export function AgentChatPane({ prompt: args.prompt, modelId: args.modelId, ...(args.chatModelId ? { chatModelId: args.chatModelId } : {}), + ...(args.provider ? { provider: args.provider } : {}), fallbackName: args.fallbackBase, flagLaneIds: args.children.map((child) => child.laneId), pin: args.pin, @@ -9369,9 +9374,7 @@ export function AgentChatPane({ if (!primaryLane) throw new Error("Auto-create requires a primary lane."); const namingSeed = buildDraftLaunchNamingSeed(snapshot); const projectConfigSnapshot = await getProjectConfigCached({ projectRoot, pin, force: true }).catch(() => null); - const titleSettings = projectConfigSnapshot?.effective?.ai?.sessionIntelligence?.titles; - const titleModelId = typeof titleSettings?.modelId === "string" ? titleSettings.modelId.trim() : ""; - const namingModelId = titleModelId || snapshot.modelId; + const namingModelId = snapshot.modelId; onAutoCreateNameModelResolved?.(namingModelId); const genericSuffix = autoLaneGenericSuffix(); // Instant: name the lane deterministically now. If AI titles are enabled, @@ -9422,18 +9425,17 @@ export function AgentChatPane({ } throw abortError; } - if (titleSettings?.enabled !== false) { - startBackgroundLaneNaming({ - laneId: createdLane.id, - prompt: namingSeed, - modelId: namingModelId, - chatModelId: snapshot.modelId, - fallbackName: laneName, - temporaryBranch: createdLane.branchRef, - attachments: snapshot.attachments, - pin, - }); - } + startBackgroundLaneNaming({ + laneId: createdLane.id, + prompt: namingSeed, + modelId: namingModelId, + chatModelId: snapshot.modelId, + provider: sessionProvider, + fallbackName: laneName, + temporaryBranch: createdLane.branchRef, + attachments: snapshot.attachments, + pin, + }); if (canRefreshPinnedProject(pin)) { await refreshLanesStore().catch((refreshError: unknown) => { console.warn("draft launch lane refresh failed", refreshError); @@ -10643,9 +10645,7 @@ export function AgentChatPane({ pin: launchBinding, force: false, }).catch(() => null); - const titleSettings = projectConfigSnapshot?.effective?.ai?.sessionIntelligence?.titles; - const titleModelId = typeof titleSettings?.modelId === "string" ? titleSettings.modelId.trim() : ""; - const namingModelId = titleModelId || parallelModelSlots[0]!.modelId; + const namingModelId = parallelModelSlots[0]!.modelId; const genericSuffix = autoLaneGenericSuffix(); // Instant: name child lanes deterministically now. If AI titles are // enabled, the real base name is generated in the background after the @@ -10689,17 +10689,16 @@ export function AgentChatPane({ await refreshLanesStore(); } - if (titleSettings?.enabled !== false) { - startBackgroundParallelLaneNaming({ - laneId, - prompt: namingSeed, - modelId: namingModelId, - chatModelId: parallelModelSlots[0]!.modelId, - fallbackBase: baseName, - children: childLaneNamings, - pin: launchBinding, - }); - } + startBackgroundParallelLaneNaming({ + laneId, + prompt: namingSeed, + modelId: namingModelId, + chatModelId: parallelModelSlots[0]!.modelId, + provider: sessionProvider, + fallbackBase: baseName, + children: childLaneNamings, + pin: launchBinding, + }); const { sendText, displayText: displayForSend } = buildParallelLaunchPrompt({ text, diff --git a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx index df856f8bd7..ef05aed18e 100644 --- a/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx +++ b/apps/desktop/src/renderer/components/graph/WorkspaceGraphPage.tsx @@ -78,7 +78,6 @@ import type { BatchProgress, GraphTextPromptState, PrDialogState, - ConflictPanelState, IntegrationDialogState, RebasePublishOutcome } from "./graphTypes"; @@ -116,7 +115,6 @@ import { } from "./graphLayout"; import { GraphLaneNode } from "./graphNodes/LaneNode"; import { GraphProposalNode } from "./graphNodes/ProposalNode"; -import { ConflictPanel as GraphConflictPanel } from "./graphDialogs/ConflictPanel"; import { RiskEdge } from "./graphEdges/RiskEdge"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { PrDetailPane } from "../prs/detail/PrDetailPane"; @@ -398,7 +396,6 @@ function GraphInner({ active = true }: { active?: boolean }) { const [mergeDisappearingAtByLaneId, setMergeDisappearingAtByLaneId] = React.useState>({}); const [prDialog, setPrDialog] = React.useState(null); const [graphPrActionBusy, setGraphPrActionBusy] = React.useState(null); - const [conflictPanel, setConflictPanel] = React.useState(null); const [showRiskMatrix, setShowRiskMatrix] = React.useState(false); const [integrationDialog, setIntegrationDialog] = React.useState(null); const [integrationProposals, setIntegrationProposals] = React.useState([]); @@ -1930,10 +1927,9 @@ function GraphInner({ active = true }: { active?: boolean }) { laneId, baseLaneId, baseBranch, - title: existing?.title ?? "", + title: existing?.title ?? lane.name ?? "", body: "", draft: existing?.state === "draft", - loadingDraft: !existing, creating: false, existingPr: existing, loadingDetails: Boolean(existing), @@ -1946,18 +1942,7 @@ function GraphInner({ active = true }: { active?: boolean }) { error: null }); - if (!existing) { - void window.ade.prs - .draftDescription({ laneId }) - .then((draft) => { - setPrDialog((prev) => (prev && prev.laneId === laneId ? { ...prev, title: draft.title, body: draft.body, loadingDraft: false } : prev)); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setPrDialog((prev) => (prev && prev.laneId === laneId ? { ...prev, loadingDraft: false, error: message } : prev)); - }); - return; - } + if (!existing) return; void Promise.all([ window.ade.prs.getStatus(existing.id), @@ -2028,50 +2013,34 @@ function GraphInner({ active = true }: { active?: boolean }) { [refreshGraphPrSurface] ); - const openConflictPanelForEdge = React.useCallback( - (laneAId: string, laneBId: string) => { - const laneA = laneById.get(laneAId); - const laneB = laneById.get(laneBId); - const applyLaneId = laneA && laneB && laneA.stackDepth !== laneB.stackDepth - ? (laneA.stackDepth > laneB.stackDepth ? laneAId : laneBId) - : laneAId; - - setConflictPanel({ - laneAId, - laneBId, - loading: true, - result: null, - error: null, - applyLaneId, - preview: null, - preparing: false, - proposal: null, - proposing: false, - applyMode: "unstaged", - commitMessage: "", - applying: false + const simulateEdgeMerge = React.useCallback((laneAId: string, laneBId: string) => { + setReparentDialog(null); + setContextMenu(null); + setEdgeSimulation({ + laneAId, + laneBId, + loading: true, + result: null, + error: null + }); + void window.ade.conflicts + .simulateMerge({ laneAId, laneBId }) + .then((result) => { + setEdgeSimulation((prev) => + prev && prev.laneAId === laneAId && prev.laneBId === laneBId + ? { ...prev, loading: false, result } + : prev + ); + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error); + setEdgeSimulation((prev) => + prev && prev.laneAId === laneAId && prev.laneBId === laneBId + ? { ...prev, loading: false, error: message } + : prev + ); }); - - void window.ade.conflicts - .simulateMerge({ laneAId, laneBId }) - .then((result) => { - setConflictPanel((prev) => - prev && prev.laneAId === laneAId && prev.laneBId === laneBId - ? { ...prev, loading: false, result } - : prev - ); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setConflictPanel((prev) => - prev && prev.laneAId === laneAId && prev.laneBId === laneBId - ? { ...prev, loading: false, error: message } - : prev - ); - }); - }, - [laneById] - ); + }, []); const openExistingPrDetail = React.useCallback( (pr: GraphPrOverlay) => { @@ -3250,42 +3219,11 @@ function GraphInner({ active = true }: { active?: boolean }) { return; } - if (prefix === "risk") { + if (prefix === "risk" || prefix === "stack" || prefix === "topology") { setEdgeSimulation(null); - setReparentDialog(null); - setContextMenu(null); - openConflictPanelForEdge(laneAId, laneBId); + simulateEdgeMerge(laneAId, laneBId); return; } - - if (prefix === "stack" || prefix === "topology") { - setReparentDialog(null); - setContextMenu(null); - setEdgeSimulation({ - laneAId, - laneBId, - loading: true, - result: null, - error: null - }); - void window.ade.conflicts - .simulateMerge({ laneAId, laneBId }) - .then((result) => { - setEdgeSimulation((prev) => - prev && prev.laneAId === laneAId && prev.laneBId === laneBId - ? { ...prev, loading: false, result } - : prev - ); - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error); - setEdgeSimulation((prev) => - prev && prev.laneAId === laneAId && prev.laneBId === laneBId - ? { ...prev, loading: false, error: message } - : prev - ); - }); - } }} onEdgeMouseEnter={(_event, edge) => { if (!nodeDragActiveRef.current) setHoveredEdgeId(edge.id); }} onEdgeMouseMove={(event, edge) => { @@ -3944,13 +3882,6 @@ function GraphInner({ active = true }: { active?: boolean }) { {!prDialog.existingPr ? (
- {prDialog.loadingDraft ? ( -
-
- Drafting description from pack… -
- ) : null} -
setPrDialog(null)}> Cancel -
) : null} - {conflictPanel ? ( - - ) : null} - {showRiskMatrix ? (
@@ -4556,14 +4454,14 @@ function GraphInner({ active = true }: { active?: boolean }) { entries={batch?.matrix ?? []} overlaps={batch?.overlaps ?? []} selectedPair={ - conflictPanel - ? { laneAId: conflictPanel.laneAId, laneBId: conflictPanel.laneBId } + edgeSimulation + ? { laneAId: edgeSimulation.laneAId, laneBId: edgeSimulation.laneBId } : null } loading={loadingRisk} progress={batchProgress ? { completedPairs: batchProgress.completedPairs, totalPairs: batchProgress.totalPairs } : null} onSelectPair={(pair) => { - openConflictPanelForEdge(pair.laneAId, pair.laneBId); + simulateEdgeMerge(pair.laneAId, pair.laneBId); }} />
diff --git a/apps/desktop/src/renderer/components/graph/graphDialogs/ConflictPanel.tsx b/apps/desktop/src/renderer/components/graph/graphDialogs/ConflictPanel.tsx deleted file mode 100644 index a51499f403..0000000000 --- a/apps/desktop/src/renderer/components/graph/graphDialogs/ConflictPanel.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import React from "react"; -import type { LaneSummary } from "../../../../shared/types"; -import { Button } from "../../ui/Button"; -import { edgePairKey } from "../graphHelpers"; -import type { ConflictPanelState } from "../graphTypes"; - -export function ConflictPanel({ - conflictPanel, - setConflictPanel, - laneById, - overlapFilesByPair, - refreshRiskBatch, - refreshLanes -}: { - conflictPanel: ConflictPanelState; - setConflictPanel: React.Dispatch>; - laneById: Map; - overlapFilesByPair: Map; - refreshRiskBatch: () => Promise; - refreshLanes: () => Promise; -}) { - return ( -
-
-
Conflict Resolution
- -
-
- {(laneById.get(conflictPanel.laneAId)?.name ?? conflictPanel.laneAId)} ↔ {(laneById.get(conflictPanel.laneBId)?.name ?? conflictPanel.laneBId)} -
- - {conflictPanel.error ? ( -
- {conflictPanel.error} -
- ) : null} - - {conflictPanel.loading ? ( -
-
-
Running merge simulation…
-
- ) : conflictPanel.result ? ( -
-
- outcome: {conflictPanel.result.outcome} -
-
- conflicts: {conflictPanel.result.conflictingFiles.length} · files changed:{" "} - {conflictPanel.result.diffStat.filesChanged} -
-
- ) : null} - -
-
Overlapping Files
-
- {(() => { - const key = edgePairKey(conflictPanel.laneAId, conflictPanel.laneBId); - const files = overlapFilesByPair.get(key) ?? []; - if (files.length === 0) return
No overlap file list.
; - return files.slice(0, 30).map((file) => ( -
- {file} -
- )); - })()} -
-
- -
-
- Apply to: - -
-
- -
-
- - {conflictPanel.preview ? ( -
-
AI preview
-
- files: {conflictPanel.preview.stats.fileCount} · approx chars:{" "} - {conflictPanel.preview.stats.approxChars.toLocaleString()} - {" "}· lane export: {conflictPanel.preview.stats.laneExportChars.toLocaleString()} - {" "}· peer export: {conflictPanel.preview.stats.peerLaneExportChars.toLocaleString()} - {" "}· conflict export: {conflictPanel.preview.stats.conflictExportChars.toLocaleString()} -
- {conflictPanel.preview.warnings.length ? ( -
- {conflictPanel.preview.warnings.slice(0, 2).join(" ")} -
- ) : null} - {conflictPanel.preview.laneExportLite || conflictPanel.preview.peerLaneExportLite || conflictPanel.preview.conflictExportStandard ? ( -
- exports sent to AI - {conflictPanel.preview.laneExportLite ? ( -
-
lane export (lite)
-
-                    {conflictPanel.preview.laneExportLite}
-                  
-
- ) : null} - {conflictPanel.preview.peerLaneExportLite ? ( -
-
peer lane export (lite)
-
-                    {conflictPanel.preview.peerLaneExportLite}
-                  
-
- ) : null} - {conflictPanel.preview.conflictExportStandard ? ( -
-
conflict export (standard)
-
-                    {conflictPanel.preview.conflictExportStandard}
-                  
-
- ) : null} -
- ) : null} - {conflictPanel.preview.files.length ? ( -
- included files -
- {conflictPanel.preview.files.slice(0, 6).map((f) => ( -
- {f.path} - {f.markerPreview ? ( -
-                        {f.markerPreview}
-                      
- ) : null} - {f.laneDiff ? ( -
-                        {f.laneDiff}
-                      
- ) : null} - {f.peerDiff ? ( -
-                        {f.peerDiff}
-                      
- ) : null} -
- ))} -
-
- ) : null} -
- ) : null} - - {conflictPanel.proposal ? ( -
-
-
Proposal
-
status: {conflictPanel.proposal.status}
- {conflictPanel.proposal.confidence != null ? ( -
confidence: {Math.round(conflictPanel.proposal.confidence * 100)}%
- ) : null} - {conflictPanel.proposal.explanation ? ( -
{conflictPanel.proposal.explanation}
- ) : null} -
- -
-
Apply Mode
-
- {(["unstaged", "staged", "commit"] as const).map((mode) => ( - - ))} -
- {conflictPanel.applyMode === "commit" ? ( - setConflictPanel((prev) => (prev ? { ...prev, commitMessage: e.target.value } : prev))} - /> - ) : null} -
- {conflictPanel.proposal.status === "applied" ? ( - - ) : null} - -
-
-
- ) : null} -
- ); -} diff --git a/apps/desktop/src/renderer/components/graph/graphTypes.ts b/apps/desktop/src/renderer/components/graph/graphTypes.ts index fff10127c9..4b205ff267 100644 --- a/apps/desktop/src/renderer/components/graph/graphTypes.ts +++ b/apps/desktop/src/renderer/components/graph/graphTypes.ts @@ -1,13 +1,10 @@ import type { - ConflictProposal, - ConflictProposalPreview, ConflictStatus, GitUpstreamSyncStatus, AutoRebaseLaneStatus, GraphViewMode, LaneSummary, MergeMethod, - MergeSimulationResult, PrCheck, PrComment, PrReview, @@ -123,7 +120,6 @@ export type PrDialogState = { title: string; body: string; draft: boolean; - loadingDraft: boolean; creating: boolean; existingPr: PrWithConflicts | null; loadingDetails: boolean; @@ -136,22 +132,6 @@ export type PrDialogState = { error: string | null; }; -export type ConflictPanelState = { - laneAId: string; - laneBId: string; - loading: boolean; - result: MergeSimulationResult | null; - error: string | null; - applyLaneId: string; - preview: ConflictProposalPreview | null; - preparing: boolean; - proposal: ConflictProposal | null; - proposing: boolean; - applyMode: "unstaged" | "staged" | "commit"; - commitMessage: string; - applying: boolean; -}; - export type IntegrationDialogState = { laneIds: string[]; targetLaneId: string | null; diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index cc128dbae3..1116c9c5f5 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -214,16 +214,6 @@ describe("LaneGitActionsPane rescue action", () => { onAutoRebaseEvent: vi.fn(() => () => undefined), createFromUnstaged: vi.fn(async () => buildLane({ id: "lane-2", name: "Rescue lane", status: { dirty: true, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false } })), }, - projectConfig: { - get: vi.fn(async () => ({ - effective: { - ai: { - features: { commit_messages: true }, - featureModelOverrides: { commit_messages: "openai/gpt-5.4-mini" }, - }, - }, - })), - }, } as any; }); @@ -935,4 +925,22 @@ describe("LaneGitActionsPane rescue action", () => { const commitInput = screen.getByPlaceholderText(/commit message/i) as HTMLInputElement; expect(commitInput.value).toBe(""); }); + + it("does not commit when generated message is empty", async () => { + const user = userEvent.setup(); + mockChangesByLaneId["lane-1"] = { + staged: [{ path: "src/file.ts", kind: "modified" }], + unstaged: [], + }; + (window.ade.git.generateCommitMessage as any).mockResolvedValueOnce({ message: "", model: null }); + + renderPane(); + await user.click(await screen.findByRole("button", { name: "COMMIT" })); + + await waitFor(() => { + expect(window.ade.git.generateCommitMessage).toHaveBeenCalledWith({ laneId: "lane-1", amend: false }, null); + }); + expect(window.ade.git.commit).not.toHaveBeenCalled(); + expect(screen.getByText(/No turned chat on this lane to suggest a commit message/)).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index fec4492671..02e46d699d 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -2,14 +2,12 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { ArrowDown, ArrowLeft, ArrowsClockwise, CaretDown, CaretRight, Check, Folder, Stack, Trash, Upload, Warning } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import { - selectActiveProjectRoot, selectActiveProjectStateKey, useAppStore, } from "../../state/appStore"; import { selectOtherMachineBranchStates, useLanesForPin } from "../../state/crossMachineLanes"; const EMPTY_CROSS_MACHINE_LANES: Record = {}; -import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { modifierKeyLabel } from "../../lib/platform"; import { cn } from "../ui/cn"; import { showToast } from "../app/toast/toastStore"; @@ -56,11 +54,6 @@ type NextActionHint = { detail: string; }; -type CommitMessageAiState = { - enabled: boolean; - modelId: string | null; -}; - type ResponsiveMode = "narrow" | "medium" | "wide"; /** A push held back by the divergence guard, replayed verbatim on confirm. */ @@ -377,20 +370,11 @@ function getCommitButtonLabel(args: { return args.amendCommit ? "AMEND COMMIT" : "COMMIT"; } -function getCommitHelperText(args: { - commitMessage: string; - commitMessageAi: CommitMessageAiState; -}): string { - if (args.commitMessage.trim().length > 0) { +function getCommitHelperText(commitMessage: string): string { + if (commitMessage.trim().length > 0) { return `Press ${modifierKeyLabel}+Enter to commit with the typed message.`; } - if (args.commitMessageAi.enabled && args.commitMessageAi.modelId) { - return `Blank messages will be auto-generated with ${args.commitMessageAi.modelId}.`; - } - if (args.commitMessageAi.enabled) { - return "Commit Messages is enabled, but no model is selected in Settings."; - } - return "Type a commit message, or enable Commit Messages in Settings to auto-generate one when blank."; + return "Type a commit message, or leave blank to generate one from the last turned chat on this lane."; } function getAutoRebaseBannerConfig(state: AutoRebaseLaneStatus["state"]): { @@ -685,7 +669,6 @@ export function LaneGitActionsPane({ const crossMachineLanesByMachineId = useAppStore((s) => s.crossMachineLanesByMachineId ?? EMPTY_CROSS_MACHINE_LANES); const refreshLanes = useAppStore((s) => s.refreshLanes); const selectLane = useAppStore((s) => s.selectLane); - const projectRoot = useAppStore(selectActiveProjectRoot); const activeProjectStateKey = useAppStore(selectActiveProjectStateKey); const pin = runtimePin ?? null; // Lane ids are only unique per machine, so a pinned panel gets its own cache @@ -721,7 +704,6 @@ export function LaneGitActionsPane({ const [loading, setLoading] = useState(false); const [changes, setChanges] = useState(initialCachedGitState?.changes ?? EMPTY_CHANGES); const [commitMessage, setCommitMessage] = useState(""); - const [commitMessageAi, setCommitMessageAi] = useState({ enabled: false, modelId: null }); const [syncMode, setSyncMode] = useState("merge"); const [stashes, setStashes] = useState(initialCachedGitState?.stashes ?? []); const [syncStatus, setSyncStatus] = useState(initialCachedGitState?.syncStatus ?? null); @@ -943,29 +925,6 @@ export function LaneGitActionsPane({ } }, [isViewingLane, laneId, pin, projectStateKey]); - const refreshCommitMessageAiState = useCallback(async () => { - try { - const snapshot = await getProjectConfigCached({ projectRoot }); - const effectiveAi = snapshot.effective?.ai; - const features = effectiveAi && typeof effectiveAi === "object" && "features" in effectiveAi - ? (effectiveAi.features as Record | undefined) - : undefined; - const featureModelOverrides = effectiveAi && typeof effectiveAi === "object" && "featureModelOverrides" in effectiveAi - ? (effectiveAi.featureModelOverrides as Record | undefined) - : undefined; - const enabled = features?.commit_messages === true; - const modelIdRaw = typeof featureModelOverrides?.commit_messages === "string" - ? featureModelOverrides.commit_messages.trim() - : ""; - setCommitMessageAi({ - enabled, - modelId: modelIdRaw.length ? modelIdRaw : null, - }); - } catch { - setCommitMessageAi({ enabled: false, modelId: null }); - } - }, [projectRoot]); - useEffect(() => { autoRebaseStatusSnapshotRef.current = autoRebaseStatusSnapshot; if (autoRebaseStatusSnapshot !== undefined) { @@ -1097,10 +1056,22 @@ export function LaneGitActionsPane({ }); try { const generated = await window.ade.git.generateCommitMessage({ laneId: actionLaneId, amend: amendCommit }, pin); + const generatedMessage = generated.message.trim(); + if (!generatedMessage) { + if (isViewingLane(actionLaneId)) { + setCommitMessage(""); + } + patchLaneGitActionRuntimeStateIfCurrent(actionScopeKey, actionVersion, { + busyAction: null, + notice: null, + error: "No turned chat on this lane to suggest a commit message. Type one, then commit.", + }); + return; + } if (isViewingLane(actionLaneId)) { - setCommitMessage(generated.message); + setCommitMessage(generatedMessage); } - await window.ade.git.commit({ laneId: actionLaneId, message: generated.message, amend: amendCommit }, pin); + await window.ade.git.commit({ laneId: actionLaneId, message: generatedMessage, amend: amendCommit }, pin); await completeCommitRefresh(actionLaneId); patchLaneGitActionRuntimeStateIfCurrent(actionScopeKey, actionVersion, { busyAction: null, @@ -1137,7 +1108,6 @@ export function LaneGitActionsPane({ setForcePushSuggested(cached?.forcePushSuggested ?? false); setCollapsedChangeFolders(new Set()); setAmendCommit(false); - setCommitMessageAi({ enabled: false, modelId: null }); setAutoRebaseStatus(autoRebaseStatusSnapshotRef.current ?? cached?.autoRebaseStatus ?? null); setConflictState(cached?.conflictState ?? null); setStuckRebase(cached?.stuckRebase ?? null); @@ -1148,8 +1118,7 @@ export function LaneGitActionsPane({ error: err instanceof Error ? err.message : String(err), }); }); - void refreshCommitMessageAiState(); - }, [active, laneGitActionScopeKey, laneId, lane?.branchRef, projectStateKey, refreshCommitMessageAiState]); + }, [active, laneGitActionScopeKey, laneId, lane?.branchRef, projectStateKey]); useEffect(() => { if (!active || !laneId) return; @@ -1646,7 +1615,7 @@ export function LaneGitActionsPane({ const headerDotColor = getLaneHeaderDotColor(lane); const rebaseConflictParentLaneId = autoRebaseStatus?.parentLaneId ?? lane?.parentLaneId ?? null; const commitButtonLabel = getCommitButtonLabel({ busyAction, amendCommit }); - const commitHelperText = getCommitHelperText({ commitMessage, commitMessageAi }); + const commitHelperText = getCommitHelperText(commitMessage); const syncButtonDisabled = !laneId || busyAction != null || lane?.status.behind === 0 || lane?.status.dirty; const syncButtonTitle = useMemo(() => { if (!laneId) return "Sync is unavailable until you select a child lane."; diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 1bf461a9f6..90cdb91fb4 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -406,17 +406,13 @@ export function PrDetailPane({ unmappedAffordance = null, }: PrDetailPaneProps) { const { - dismissedAiSummaries, timelineFiltersByPrId, - detailAiSummary, detailReviewThreads: ctxReviewThreads, detailDeployments, detailLiveDataPrId: ctxDetailPrId, viewerLogin, writeViewerLogin, setTimelineFilters, - setAiSummaryDismissed, - regeneratePrAiSummary, isGithubPollStoodDown, noteGithubReadFailure, noteGithubReadSuccess, @@ -565,18 +561,6 @@ export function PrDetailPane({ () => (ctxDetailPrId === pr.id ? detailDeployments : []), [ctxDetailPrId, detailDeployments, pr.id], ); - const aiSummaryDismissedForPr = Boolean(dismissedAiSummaries?.[pr.id]); - const handleDismissAiSummary = React.useCallback(() => { - setAiSummaryDismissed?.(pr.id, true); - }, [pr.id, setAiSummaryDismissed]); - const handleRegenerateAiSummary = React.useCallback(() => { - void regeneratePrAiSummary?.(pr.id); - }, [pr.id, regeneratePrAiSummary]); - const timelineAiSummary = React.useMemo( - () => (detailAiSummary?.prId === pr.id ? detailAiSummary : null), - [detailAiSummary, pr.id], - ); - // Page-level keyboard shortcuts scoped to the Timeline+Rails overview. // Only attach listeners when the flag is on AND the overview tab is active. React.useEffect(() => { @@ -1425,10 +1409,6 @@ export function PrDetailPane({ writeViewerLogin={writeViewerLogin} filters={timelineFilters} onFiltersChange={handleTimelineFiltersChange} - aiSummary={timelineAiSummary} - aiSummaryDismissed={aiSummaryDismissedForPr} - onDismissAiSummary={handleDismissAiSummary} - onRegenerateAiSummary={handleRegenerateAiSummary} commentDraft={commentDraft} setCommentDraft={setCommentDraft} actionBusy={actionBusy} diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailTimelineRails.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailTimelineRails.test.tsx index be94e29e81..94d37023c3 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailTimelineRails.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailTimelineRails.test.tsx @@ -251,10 +251,6 @@ function renderRails( viewerLogin="alice" filters={{} as never} onFiltersChange={() => {}} - aiSummary={null} - aiSummaryDismissed={false} - onDismissAiSummary={() => {}} - onRegenerateAiSummary={() => {}} commentDraft="" setCommentDraft={() => {}} actionBusy={false} diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailTimelineRails.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailTimelineRails.tsx index bbb11568fb..ef3a65359d 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailTimelineRails.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailTimelineRails.tsx @@ -8,7 +8,6 @@ import type { MergeMethod, PrActionRun, PrActivityEvent, - PrAiSummary, PrCheck, PrCommit, PrComment, @@ -189,10 +188,6 @@ type Props = { writeViewerLogin?: string | null; filters: PrTimelineFilters; onFiltersChange: (next: PrTimelineFilters) => void; - aiSummary: PrAiSummary | null; - aiSummaryDismissed: boolean; - onDismissAiSummary: () => void; - onRegenerateAiSummary: () => void; commentDraft: string; setCommentDraft: (value: string) => void; actionBusy: boolean; @@ -750,10 +745,6 @@ export const PrDetailTimelineRails = forwardRef writeViewerLogin, filters, onFiltersChange, - aiSummary, - aiSummaryDismissed, - onDismissAiSummary, - onRegenerateAiSummary, commentDraft, setCommentDraft, actionBusy, @@ -943,8 +934,6 @@ export const PrDetailTimelineRails = forwardRef [navigate, pr.githubPrNumber, pr.id, pr.repoName, pr.repoOwner], ); - const summaryForTimeline = aiSummaryDismissed ? null : aiSummary ?? null; - // The tick pill sizes itself from the commit count alone, so the thread no // longer needs a ResizeObserver to hand it a measured column height. @@ -992,9 +981,6 @@ export const PrDetailTimelineRails = forwardRef writeViewerLogin={writeViewerLogin} filters={filters} onFiltersChange={onFiltersChange} - summary={summaryForTimeline} - onRegenerateSummary={onRegenerateAiSummary} - onDismissSummary={onDismissAiSummary} onVisibleEventChange={handleVisibleEventChange} footer={ = {}): PrAiSummary { - return { - prId: "pr-1", - summary: "Refactors the auth middleware to use session cookies.", - riskAreas: ["auth middleware", "session storage"], - reviewerHotspots: ["src/auth.ts:42"], - unresolvedConcerns: ["Missing migration plan"], - generatedAt: "2026-04-14T10:00:00.000Z", - headSha: "abc123", - ...overrides, - }; -} - -let regenerateAiSummary: ReturnType; - -beforeEach(() => { - sessionStorage.clear(); - regenerateAiSummary = vi.fn().mockResolvedValue(makeSummary({ summary: "Updated summary." })); - (window as unknown as { ade?: unknown }).ade = { - prs: { regenerateAiSummary }, - }; -}); - -afterEach(() => { - cleanup(); - sessionStorage.clear(); - delete (window as unknown as { ade?: unknown }).ade; -}); - -describe("PrAiSummaryCard", () => { - it("renders summary paragraph and section chips", () => { - render(); - expect(screen.getByText(/refactors the auth middleware/i)).toBeTruthy(); - expect(screen.getByText("auth middleware")).toBeTruthy(); - expect(screen.getByText("src/auth.ts:42")).toBeTruthy(); - expect(screen.getByText("Missing migration plan")).toBeTruthy(); - }); - - it("shows skeleton while loading", () => { - const { container } = render(); - expect(container.querySelector("[data-pr-ai-summary-skeleton]")).toBeTruthy(); - }); - - it("shows Generate CTA when summary is null", () => { - render(); - expect(screen.getByRole("button", { name: /generate summary/i })).toBeTruthy(); - }); - - it("calls regenerateAiSummary on Regenerate click and invokes onGenerated", async () => { - const user = userEvent.setup(); - const onGenerated = vi.fn(); - render(); - await user.click(screen.getByRole("button", { name: /regenerate/i })); - await waitFor(() => { - expect(regenerateAiSummary).toHaveBeenCalledWith("pr-1"); - expect(onGenerated).toHaveBeenCalled(); - }); - }); - - it("persists dismissal in sessionStorage and hides the card", async () => { - const user = userEvent.setup(); - const onDismiss = vi.fn(); - const { container } = render( - , - ); - await user.click(screen.getByRole("button", { name: /dismiss summary/i })); - expect(onDismiss).toHaveBeenCalledWith("pr-42"); - expect(container.querySelector("[data-pr-ai-summary-card]")).toBeNull(); - expect(sessionStorage.getItem("ade:pr-ai-summary-dismissed:pr-42")).toBe("1"); - }); - - it("respects a pre-existing sessionStorage dismissal", () => { - sessionStorage.setItem("ade:pr-ai-summary-dismissed:pr-7", "1"); - const { container } = render(); - expect(container.querySelector("[data-pr-ai-summary-card]")).toBeNull(); - }); - - it("surfaces errors from regeneration as an alert", async () => { - const user = userEvent.setup(); - regenerateAiSummary.mockRejectedValueOnce(new Error("rate limited")); - render(); - await user.click(screen.getByRole("button", { name: /regenerate/i })); - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain("rate limited"); - }); - }); -}); diff --git a/apps/desktop/src/renderer/components/prs/shared/PrAiSummaryCard.tsx b/apps/desktop/src/renderer/components/prs/shared/PrAiSummaryCard.tsx deleted file mode 100644 index 57cc4868b5..0000000000 --- a/apps/desktop/src/renderer/components/prs/shared/PrAiSummaryCard.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { memo, useCallback, useMemo, useState, type CSSProperties } from "react"; -import { ArrowClockwise, Sparkle, X } from "@phosphor-icons/react"; - -import type { PrAiSummary } from "../../../../shared/types"; -import { - COLORS, - SANS_FONT, - cardStyle, - inlineBadge, - outlineButton, -} from "../../lanes/laneDesignTokens"; -import { formatTimeAgo } from "./prFormatters"; - -type PrAiSummaryCardProps = { - prId: string; - summary: PrAiSummary | null; - loading?: boolean; - onGenerated?: (next: PrAiSummary) => void; - onDismiss?: (prId: string) => void; - dismissed?: boolean; -}; - -const DISMISS_PREFIX = "ade:pr-ai-summary-dismissed:"; - -export function isAiSummaryDismissed(prId: string): boolean { - try { - return sessionStorage.getItem(DISMISS_PREFIX + prId) === "1"; - } catch { - return false; - } -} - -function persistDismissed(prId: string, value: boolean) { - try { - if (value) sessionStorage.setItem(DISMISS_PREFIX + prId, "1"); - else sessionStorage.removeItem(DISMISS_PREFIX + prId); - } catch { - /* ignore */ - } -} - -function Section({ - heading, - items, - kind, -}: { - heading: string; - items: string[]; - kind: "risk" | "hotspot" | "concern"; -}) { - if (!items.length) return null; - const chipColor = - kind === "risk" ? COLORS.danger : kind === "hotspot" ? COLORS.warning : COLORS.info; - return ( -
- - {heading} - -
- {items.map((item, idx) => ( - - {item} - - ))} -
-
- ); -} - -function Skeleton() { - const shimmerLine: CSSProperties = { - background: - "linear-gradient(90deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0.08) 50%, rgba(255,255,255,0.04) 100%)", - backgroundSize: "200% 100%", - animation: "pr-ai-summary-shimmer 1.4s ease-in-out infinite", - borderRadius: 4, - }; - - return ( -
- -
-
-
-
- ); -} - -export const PrAiSummaryCard = memo(function PrAiSummaryCard({ - prId, - summary, - loading, - onGenerated, - onDismiss, - dismissed, -}: PrAiSummaryCardProps) { - const [busy, setBusy] = useState<"regenerate" | "generate" | null>(null); - const [error, setError] = useState(null); - const [localDismissed, setLocalDismissed] = useState(() => dismissed ?? isAiSummaryDismissed(prId)); - - const containerStyle = useMemo( - () => - cardStyle({ - padding: 16, - borderRadius: 12, - borderColor: COLORS.accentBorder, - background: `linear-gradient(180deg, ${COLORS.accentSubtle} 0%, rgba(255,255,255,0.03) 100%)`, - }), - [], - ); - - const handleGenerate = useCallback(async () => { - const bridge = window.ade?.prs?.regenerateAiSummary; - if (!bridge) return; - setBusy(summary ? "regenerate" : "generate"); - setError(null); - try { - const next = await bridge(prId); - onGenerated?.(next); - } catch (err) { - setError(err instanceof Error ? err.message : String(err)); - } finally { - setBusy(null); - } - }, [summary, prId, onGenerated]); - - const handleDismiss = useCallback(() => { - persistDismissed(prId, true); - setLocalDismissed(true); - onDismiss?.(prId); - }, [prId, onDismiss]); - - if (localDismissed) return null; - - const headerLabel = "AI summary"; - const timestamp = summary ? formatTimeAgo(summary.generatedAt) : null; - - return ( -
-
- - - - - {headerLabel} - - {timestamp ? ( - - {timestamp} - - ) : null} - {summary ? ( - - ) : null} - -
- - {loading ? ( - - ) : summary ? ( -
-

- {summary.summary} -

-
-
-
-
- ) : ( -
- - No summary yet. - - -
- )} - - {error ? ( -
- {error} -
- ) : null} -
- ); -}); - -export default PrAiSummaryCard; diff --git a/apps/desktop/src/renderer/components/prs/shared/PrTimeline.test.tsx b/apps/desktop/src/renderer/components/prs/shared/PrTimeline.test.tsx index ce791af190..a0416b552f 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrTimeline.test.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrTimeline.test.tsx @@ -28,13 +28,6 @@ vi.mock("./PrBotReviewCard", () => ({ detectBotProvider: (login: string) => (login.endsWith("[bot]") ? "coderabbit" : null), })); -vi.mock("./PrAiSummaryCard", () => ({ - PrAiSummaryCard: ({ prId }: { prId: string }) => ( -
- ), - isAiSummaryDismissed: () => false, -})); - vi.mock("./PrMarkdown", () => ({ PrMarkdown: ({ children }: { children: string }) => (
{children}
@@ -66,7 +59,6 @@ vi.mock("@tanstack/react-virtual", () => ({ import type { PrTimelineEvent, - PrAiSummary, } from "../../../../shared/types/prs"; import { PrTimeline, @@ -689,16 +681,7 @@ describe("PrTimeline", () => { expect(screen.getByText("No events match the current filters.")).toBeTruthy(); }); - it("renders the AI summary card when a summary is provided", () => { - const summary: PrAiSummary = { - prId: "pr-1", - summary: "LGTM", - riskAreas: [], - reviewerHotspots: [], - unresolvedConcerns: [], - generatedAt: new Date().toISOString(), - headSha: "a".repeat(40), - }; + it("does not render an AI summary card above the timeline", () => { render( { viewerLogin="alice" filters={DEFAULT_PR_TIMELINE_FILTERS} onFiltersChange={() => {}} - summary={summary} />, ); - expect(screen.getByTestId("ai-summary-card")).toBeTruthy(); + expect(screen.queryByTestId("ai-summary-card")).toBeNull(); + expect(screen.queryByTestId("pr-timeline-summary")).toBeNull(); }); it("renders the PR opened banner with title, branches, and stats", () => { diff --git a/apps/desktop/src/renderer/components/prs/shared/PrTimeline.tsx b/apps/desktop/src/renderer/components/prs/shared/PrTimeline.tsx index a564f4a516..2e72ead9d9 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrTimeline.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrTimeline.tsx @@ -37,7 +37,6 @@ import { } from "@phosphor-icons/react"; import type { - PrAiSummary, PrCheck, PrTimelineEvent, PrDeploymentState, @@ -51,7 +50,6 @@ import { PrReviewThreadCard } from "./PrReviewThreadCard"; import { DescriptionContent, IssueCommentContent } from "./PrTimelineCommentCards"; import { PrBotReviewCard, detectBotProvider } from "./PrBotReviewCard"; import { PrUserAvatar } from "./PrUserAvatar"; -import { PrAiSummaryCard } from "./PrAiSummaryCard"; /* ══════════════════ Types ══════════════════ */ @@ -79,9 +77,6 @@ export type PrTimelineProps = { writeViewerLogin?: string | null; filters: PrTimelineFilters; onFiltersChange: (next: PrTimelineFilters) => void; - summary?: PrAiSummary | null; - onRegenerateSummary?: () => void; - onDismissSummary?: () => void; /** Fired (debounced) with the id of the top-most visible event as the user scrolls. */ onVisibleEventChange?: (eventId: string | null) => void; /** Rendered at the very bottom of the scrollable thread (e.g. the comment composer). */ @@ -327,9 +322,6 @@ export const PrTimeline = forwardRef(function Pr writeViewerLogin, filters, onFiltersChange: _onFiltersChange, - summary, - onRegenerateSummary: _onRegenerateSummary, - onDismissSummary, onVisibleEventChange, footer, }, @@ -497,19 +489,6 @@ export const PrTimeline = forwardRef(function Pr className="flex h-full w-full min-h-0 flex-col" style={{ background: COLORS.prSurface }} > - {summary ? ( -
- onDismissSummary() : undefined} - /> -
- ) : null} -
{detailReviews.length}
{detailComments.length}
{detailDeployments.length}
-
{detailAiSummary?.summary ?? ""}
); } @@ -999,7 +996,7 @@ describe("PrsContext refresh", () => { }); }); - it("hydrates deployments and AI summary after snapshot prefill while live detail is pending", async () => { + it("hydrates deployments after snapshot prefill while live detail is pending", async () => { const user = userEvent.setup(); vi.mocked(window.ade.prs.listWithConflicts).mockResolvedValue([makeFakePr("pr-1")]); const liveStatus = createDeferred(); @@ -1038,15 +1035,6 @@ describe("PrsContext refresh", () => { createdAt: "2026-03-24T12:00:00.000Z", updatedAt: "2026-03-24T12:05:00.000Z", }; - const aiSummary: PrAiSummary = { - prId: "pr-1", - summary: "Cached overview is available.", - riskAreas: [], - reviewerHotspots: [], - unresolvedConcerns: [], - generatedAt: "2026-03-24T12:06:00.000Z", - headSha: "abc123", - }; Object.assign(window.ade.prs, { listSnapshots: vi.fn(async (args?: { prId?: string }) => (args?.prId === "pr-1" ? [snapshot] : [])), getStatus: vi.fn(async (_prId: string) => liveStatus.promise), @@ -1054,7 +1042,7 @@ describe("PrsContext refresh", () => { getReviews: vi.fn(async (_prId: string) => liveReviews.promise), getComments: vi.fn(async (_prId: string) => liveComments.promise), getDeployments: vi.fn(async (_prId: string) => [deployment]), - getAiSummary: vi.fn(async (_prId: string) => aiSummary), + getAiSummary: vi.fn(), }); render( @@ -1075,9 +1063,8 @@ describe("PrsContext refresh", () => { await waitFor(() => { expect(window.ade.prs.getDeployments).toHaveBeenCalledWith("pr-1"); - expect(window.ade.prs.getAiSummary).toHaveBeenCalledWith("pr-1"); + expect(window.ade.prs.getAiSummary).not.toHaveBeenCalled(); expect(screen.getByTestId("deployments-count").textContent).toBe("1"); - expect(screen.getByTestId("ai-summary").textContent).toBe("Cached overview is available."); }); expect(screen.getByTestId("live-detail-pr-id").textContent).toBe(""); }); @@ -1098,15 +1085,6 @@ describe("PrsContext refresh", () => { createdAt: "2026-03-24T12:00:00.000Z", updatedAt: "2026-03-24T12:05:00.000Z", }; - const aiSummary: PrAiSummary = { - prId: "pr-1", - summary: "Fresh summary is available.", - riskAreas: [], - reviewerHotspots: [], - unresolvedConcerns: [], - generatedAt: "2026-03-24T12:06:00.000Z", - headSha: "abc123", - }; Object.assign(window.ade.prs, { getStatus: vi.fn(async (_prId: string) => ({ prId: "pr-1", @@ -1121,7 +1099,7 @@ describe("PrsContext refresh", () => { getReviews: vi.fn(async (_prId: string) => []), getComments: vi.fn(async (_prId: string) => []), getDeployments: vi.fn(async (_prId: string) => [deployment]), - getAiSummary: vi.fn(async (_prId: string) => aiSummary), + getAiSummary: vi.fn(), }); render(); @@ -1133,7 +1111,7 @@ describe("PrsContext refresh", () => { await waitFor(() => { expect(screen.getByTestId("live-detail-pr-id").textContent).toBe("pr-1"); expect(window.ade.prs.getDeployments).toHaveBeenCalledTimes(1); - expect(window.ade.prs.getAiSummary).toHaveBeenCalledTimes(1); + expect(window.ade.prs.getAiSummary).not.toHaveBeenCalled(); }); await user.click(screen.getByRole("button", { name: "deactivate" })); @@ -1141,7 +1119,7 @@ describe("PrsContext refresh", () => { await waitFor(() => { expect(window.ade.prs.getDeployments).toHaveBeenCalledTimes(2); - expect(window.ade.prs.getAiSummary).toHaveBeenCalledTimes(2); + expect(window.ade.prs.getAiSummary).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 493416bde8..7e61352786 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -24,7 +24,6 @@ import type { AutoRebaseEventPayload, PrReviewThread, PrDeployment, - PrAiSummary, PrSnapshotHydration, PrAgentPermissionMode, } from "../../../../shared/types"; @@ -118,7 +117,6 @@ type PrsState = { detailComments: PrComment[]; detailReviewThreads: PrReviewThread[]; detailDeployments: PrDeployment[]; - detailAiSummary: PrAiSummary | null; detailSnapshot: PrSnapshotHydration | null; detailSnapshotsByPrId: Record; detailLiveDataPrId: string | null; @@ -137,7 +135,6 @@ type PrsState = { resolverPermissionMode: PrAgentPermissionMode; resolverSessionsByContextKey: Record; - dismissedAiSummaries: Record; timelineFiltersByPrId: Record; viewerLogin: string | null; writeViewerLogin?: string | null; @@ -168,8 +165,6 @@ type PrsContextValue = PrsState & { refresh: (args?: { prId?: string; prIds?: string[] }) => Promise; setTimelineFilters: (prId: string, filters: PrTimelineFilters) => void; - setAiSummaryDismissed: (prId: string, dismissed: boolean) => void; - regeneratePrAiSummary: (prId: string) => Promise; setViewerLogin: (login: string | null) => void; setWriteViewerLogin: (login: string | null) => void; /** Record a merge/close the moment GitHub confirms it, before any refetch. */ @@ -193,7 +188,6 @@ const PrsContext = createContext(null); const LS_MODEL_KEY = "ade:prs:resolverModel"; const LS_REASONING_KEY = "ade:prs:resolverReasoningLevel"; const LS_PERMISSION_KEY = "ade:prs:resolverPermissions"; -const LS_DISMISSED_SUMMARIES_KEY = "ade:prs:dismissedAiSummaries"; const LS_TIMELINE_FILTERS_KEY = "ade:prs:timelineFiltersByPrId"; const PRS_CONTEXT_CACHE_TTL_MS = 120_000; const PRS_DETAIL_CACHE_TTL_MS = 60_000; @@ -214,7 +208,6 @@ type PrsContextWarmCache = { detailComments: PrComment[]; detailReviewThreads: PrReviewThread[]; detailDeployments: PrDeployment[]; - detailAiSummary: PrAiSummary | null; detailSnapshotsByPrId: Record; rebaseNeeds: RebaseNeed[]; autoRebaseStatuses: AutoRebaseLaneStatus[]; @@ -477,7 +470,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi () => warmCache?.detailReviewThreads ?? [], ); const [detailDeployments, setDetailDeployments] = useState(() => warmCache?.detailDeployments ?? []); - const [detailAiSummary, setDetailAiSummary] = useState(() => warmCache?.detailAiSummary ?? null); const [detailSnapshot, setDetailSnapshot] = useState(null); const [detailSnapshotsByPrId, setDetailSnapshotsByPrId] = useState>( () => warmCache?.detailSnapshotsByPrId ?? {}, @@ -535,10 +527,8 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi || detailComments.length > 0 || detailReviewThreads.length > 0 || detailDeployments.length > 0 - || detailAiSummary !== null || detailSnapshot !== null; }, [ - detailAiSummary, detailChecks.length, detailComments.length, detailDeployments.length, @@ -548,9 +538,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi detailStatus, ]); - const [dismissedAiSummaries, setDismissedAiSummaries] = useState>( - () => readJsonLs>(LS_DISMISSED_SUMMARIES_KEY, {}), - ); const [timelineFiltersByPrId, setTimelineFiltersByPrId] = useState>( () => readJsonLs>(LS_TIMELINE_FILTERS_KEY, {}), ); @@ -563,29 +550,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi }); }, []); - const setAiSummaryDismissed = useCallback((prId: string, dismissed: boolean) => { - setDismissedAiSummaries((prev) => { - if (Boolean(prev[prId]) === dismissed) return prev; - const next = { ...prev }; - if (dismissed) next[prId] = true; - else delete next[prId]; - writeJsonLs(LS_DISMISSED_SUMMARIES_KEY, next); - return next; - }); - }, []); - - const regeneratePrAiSummary = useCallback(async (prId: string) => { - const fn = window.ade?.prs?.regenerateAiSummary; - if (typeof fn !== "function") return; - try { - const summary = await fn(prId); - if (selectedPrIdRef.current !== prId) return; - setDetailAiSummary(summary); - } catch (err) { - console.warn("[PrsContext] regenerateAiSummary failed:", err); - } - }, []); - // Rebase state const [rebaseNeeds, setRebaseNeeds] = useState(() => warmCache?.rebaseNeeds ?? []); const [autoRebaseStatuses, setAutoRebaseStatuses] = useState( @@ -1103,7 +1067,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi setDetailComments([]); setDetailReviewThreads([]); setDetailDeployments([]); - setDetailAiSummary(null); setDetailSnapshot(null); setDetailLiveDataPrId(null); return; @@ -1122,7 +1085,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi setDetailComments([]); setDetailReviewThreads([]); setDetailDeployments([]); - setDetailAiSummary(null); setDetailSnapshot(null); setDetailLiveDataPrId(null); setSelectedPrId(null); @@ -1140,7 +1102,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi const clearSecondaryDetail = () => { setDetailReviewThreads([]); setDetailDeployments([]); - setDetailAiSummary(null); }; const applySnapshotPrefill = (snapshot: PrSnapshotHydration) => { snapshotForRequest = snapshot; @@ -1183,7 +1144,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi setDetailComments([]); setDetailReviewThreads([]); setDetailDeployments([]); - setDetailAiSummary(null); } const yieldToPaint = () => new Promise((resolve) => { @@ -1205,11 +1165,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi const deployments = await api.getDeployments(prId); if (!cancelled && selectedPrIdRef.current === prId) setDetailDeployments(deployments); }], - ["getAiSummary", async () => { - if (typeof api?.getAiSummary !== "function") return; - const summary = await api.getAiSummary(prId); - if (!cancelled && selectedPrIdRef.current === prId) setDetailAiSummary(summary); - }], ]; for (const [name, step] of steps) { if (cancelled) return; @@ -1506,7 +1461,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi detailComments, detailReviewThreads, detailDeployments, - detailAiSummary, detailSnapshotsByPrId, rebaseNeeds, autoRebaseStatuses, @@ -1521,7 +1475,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi activeTab, autoRebaseStatuses, cacheKey, - detailAiSummary, detailChecks, detailComments, detailDeployments, @@ -1559,7 +1512,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi detailComments, detailReviewThreads, detailDeployments, - detailAiSummary, detailSnapshot, detailSnapshotsByPrId, detailLiveDataPrId, @@ -1571,7 +1523,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi resolverReasoningLevel, resolverPermissionMode: resolverPermissions[resolvePermissionFamilyForModel(resolverModel)], resolverSessionsByContextKey, - dismissedAiSummaries, timelineFiltersByPrId, viewerLogin, writeViewerLogin, @@ -1590,8 +1541,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi setInlineTerminal, refresh, setTimelineFilters, - setAiSummaryDismissed, - regeneratePrAiSummary, setViewerLogin, setWriteViewerLogin, isGithubPollStoodDown, @@ -1624,7 +1573,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi detailComments, detailReviewThreads, detailDeployments, - detailAiSummary, detailSnapshot, detailSnapshotsByPrId, detailLiveDataPrId, @@ -1636,7 +1584,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi resolverReasoningLevel, resolverPermissions, resolverSessionsByContextKey, - dismissedAiSummaries, timelineFiltersByPrId, viewerLogin, writeViewerLogin, @@ -1647,8 +1594,6 @@ export function PrsProvider({ active = true, children }: { active?: boolean; chi clearResolverSession, refresh, setTimelineFilters, - setAiSummaryDismissed, - regeneratePrAiSummary, isGithubPollStoodDown, noteGithubReadFailure, noteGithubReadSuccess, diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx index 3e121e712a..304eef7a71 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx @@ -2,78 +2,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { AiFeaturesSection } from "./AiFeaturesSection"; -const modelPickerProps = vi.hoisted(() => - [] as Array<{ surfaceKey: string; onOpenSignIn?: (family?: unknown) => void }>, -); - -vi.mock("../shared/ModelPicker/ModelPicker", () => { - return { - ModelPicker: (props: { surfaceKey: string; onOpenSignIn?: (family?: unknown) => void }) => { - modelPickerProps.push(props); - return ( - - ); - }, - }; -}); - -vi.mock("../shared/ModelPicker/ReasoningEffortPicker", () => ({ - ReasoningEffortPicker: () => null, -})); - -function LocationProbe() { - const location = useLocation(); - return
{`${location.pathname}${location.search}${location.hash}`}
; -} - function installAdeMocks() { (window as any).ade = { ai: { - getStatus: vi.fn().mockResolvedValue({ - mode: "subscription", - availableProviders: { - claude: { - binary: { present: false, source: "missing", path: null }, - auth: { ready: false, mode: "none", detail: null }, - }, - codex: true, - cursor: false, - droid: false, - }, - models: { claude: [], codex: [], cursor: [], droid: [] }, - features: [ - { feature: "terminal_summaries", enabled: true, dailyUsage: 0 }, - { feature: "pr_descriptions", enabled: true, dailyUsage: 0 }, - { feature: "commit_messages", enabled: true, dailyUsage: 0 }, - { feature: "conflict_proposals", enabled: true, dailyUsage: 0 }, - ], - detectedAuth: [{ type: "cli-subscription", cli: "codex", authenticated: true }], - availableModelIds: ["openai/gpt-5.4"], - }), updateConfig: vi.fn().mockResolvedValue(undefined), }, projectConfig: { get: vi.fn().mockResolvedValue({ effective: { ai: { - featureModelOverrides: { - terminal_summaries: "openai/gpt-5.4", - pr_descriptions: "openai/gpt-5.4", - commit_messages: "openai/gpt-5.4", - conflict_proposals: "openai/gpt-5.4", - }, - sessionIntelligence: { - titles: { - enabled: true, - modelId: "openai/gpt-5.4", - refreshOnComplete: true, - }, - }, + chat: { scheduledWorkPaused: false }, }, }, }), @@ -91,53 +32,23 @@ function installAdeMocks() { afterEach(() => { cleanup(); - modelPickerProps.length = 0; delete (window as any).ade; }); describe("AiFeaturesSection", () => { - it("passes provider setup actions to every AI feature model picker", async () => { + it("does not offer background-helper model pickers", async () => { installAdeMocks(); - render( - - - , - ); - const expectedSurfaceKeys = [ - "ai-feature-terminal_summaries", - "ai-feature-pr_descriptions", - "ai-feature-commit_messages", - "ai-feature-conflict_proposals", - "ai-feature-chat-auto-title", - ]; - await waitFor(() => { - for (const surfaceKey of expectedSurfaceKeys) { - expect( - modelPickerProps.find((props) => props.surfaceKey === surfaceKey) - ?.onOpenSignIn, - `${surfaceKey} should route setup to AI providers`, - ).toEqual(expect.any(Function)); - } - }); - }); - - it("routes the chat auto-title provider setup action to the AI providers settings section", async () => { - installAdeMocks(); - - render( - + - , ); - - await screen.findByRole("button", { name: "Set up ai-feature-chat-auto-title" }); - fireEvent.click(screen.getByRole("button", { name: "Set up ai-feature-chat-auto-title" })); - - await waitFor(() => { - expect(screen.getByTestId("location").textContent).toBe("/settings?tab=agents#ai-providers"); - }); + await screen.findByText("Pause all scheduled work"); + expect(screen.queryByText("Auto-name chats, lanes, and branches")).toBeNull(); + expect(screen.queryByText("Commit messages")).toBeNull(); + expect(screen.queryByText("PR description drafting")).toBeNull(); + expect(screen.queryByText("Conflict proposals")).toBeNull(); + expect(screen.queryByText("Summarize completed chats and terminals")).toBeNull(); }); it("persists the global scheduled-work pause toggle", async () => { diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index 82aefb46ee..8d72675f9f 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -1,91 +1,15 @@ import React, { useCallback, useEffect, useState } from "react"; import type { - AiFeatureKey, AiConfig, - AiSettingsStatus, AgentChatScheduledWorkItem, } from "../../../shared/types"; import { COLORS, MONO_FONT, SANS_FONT, - LABEL_STYLE, cardStyle, } from "../lanes/laneDesignTokens"; -import { deriveConfiguredModelIds } from "../../lib/modelOptions"; -import { getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; -import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; -import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; -import { Alarm, ChatCircleDots, GitPullRequest, GitCommit, GitMerge, ChatText, type Icon } from "@phosphor-icons/react"; -import { useOpenProviderSignIn } from "../shared/useOpenProviderSignIn"; - -type FeatureInfo = { - key: AiFeatureKey; - label: string; - description: string; - subtitle: string; - icon: Icon; -}; - -type ChatTitleSettingsPatch = { - enabled?: boolean; - modelId?: string; - refreshOnComplete?: boolean; - reasoningEffort?: string | null; -}; - -const FEATURES: FeatureInfo[] = [ - { key: "terminal_summaries", label: "Summarize completed chats and terminals", description: "Replace raw last output with a concise session summary when work completes", subtitle: "Show what happened instead of the last terminal line", icon: ChatCircleDots }, - { key: "pr_descriptions", label: "PR description drafting", description: "Draft PR descriptions when you trigger the action in the PR flows", subtitle: "Get a head start on PR descriptions when you're ready to merge", icon: GitPullRequest }, - { key: "commit_messages", label: "Commit messages", description: "Generate a brief git commit subject when the field is empty", subtitle: "Meaningful commit messages generated from your staged changes", icon: GitCommit }, - { key: "conflict_proposals", label: "Conflict proposals", description: "Draft a merge-conflict resolution when you request one", subtitle: "Propose a patch when you request AI conflict resolution", icon: GitMerge }, -]; - -function normalizeModelSetting(value: unknown): string { - const raw = typeof value === "string" ? value.trim() : ""; - if (!raw.length) return ""; - return getModelById(raw)?.id ?? resolveModelAlias(raw)?.id ?? raw; -} - -function buildDefaultFeatureModels(): Record { - const defaults: Record = {}; - for (const feature of FEATURES) { - defaults[feature.key] = ""; - } - return defaults; -} - -function mergeFeatureModels( - defaultFeatureModels: Record, - effectiveAi: AiConfig | null, -): Record { - const persistedFeatureModels = effectiveAi?.featureModelOverrides ?? {}; - const nextFeatureModels: Record = { ...defaultFeatureModels }; - - for (const feature of FEATURES) { - const persistedModel = typeof persistedFeatureModels[feature.key] === "string" - ? persistedFeatureModels[feature.key]!.trim() - : ""; - if (persistedModel.length > 0) { - nextFeatureModels[feature.key] = persistedModel; - } - } - - const summaryModel = typeof effectiveAi?.sessionIntelligence?.summaries?.modelId === "string" - ? effectiveAi.sessionIntelligence.summaries.modelId.trim() - : ""; - if (summaryModel.length > 0) { - nextFeatureModels.terminal_summaries = summaryModel; - } - - return nextFeatureModels; -} - -function toFeatureModelOverrides(featureModels: Record): AiConfig["featureModelOverrides"] { - return Object.fromEntries( - Object.entries(featureModels).filter(([, value]) => value.trim().length > 0), - ) as AiConfig["featureModelOverrides"]; -} +import { Alarm } from "@phosphor-icons/react"; function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( @@ -122,25 +46,15 @@ function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean } export function AiFeaturesSection() { - const openProviderSignIn = useOpenProviderSignIn(); - const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const defaultFeatureModels = React.useMemo(buildDefaultFeatureModels, []); - const [featureModels, setFeatureModels] = useState>(defaultFeatureModels); - const [featureReasoning, setFeatureReasoning] = useState>({}); - const [utilityModel, setUtilityModel] = useState(""); - const [chatAutoTitleEnabled, setChatAutoTitleEnabled] = useState(false); - const [chatAutoTitleRefresh, setChatAutoTitleRefresh] = useState(true); - const [chatAutoTitleReasoning, setChatAutoTitleReasoning] = useState(null); const [scheduledWorkPaused, setScheduledWorkPaused] = useState(false); const [scheduledWork, setScheduledWork] = useState([]); const [scheduledWorkError, setScheduledWorkError] = useState(null); const loadStatus = useCallback(async () => { try { - const [nextStatus, snapshot, scheduledWorkResult] = await Promise.all([ - window.ade.ai.getStatus(), + const [snapshot, scheduledWorkResult] = await Promise.all([ window.ade.projectConfig.get(), window.ade.agentChat.listScheduledWork() .then((items) => ({ items, error: null as string | null })) @@ -149,121 +63,21 @@ export function AiFeaturesSection() { error: error instanceof Error ? error.message : String(error), })), ]); - setStatus(nextStatus); setScheduledWork(scheduledWorkResult.items); setScheduledWorkError(scheduledWorkResult.error); const effectiveAiRaw = snapshot.effective?.ai; const effectiveAi = effectiveAiRaw && typeof effectiveAiRaw === "object" ? (effectiveAiRaw as AiConfig) : null; - setFeatureModels(mergeFeatureModels(defaultFeatureModels, effectiveAi)); - setUtilityModel( - normalizeModelSetting(effectiveAi?.sessionIntelligence?.titles?.modelId) - || normalizeModelSetting(effectiveAi?.sessionIntelligence?.summaries?.modelId) - || normalizeModelSetting(effectiveAi?.featureModelOverrides?.terminal_summaries) - || "", - ); - setChatAutoTitleEnabled(effectiveAi?.sessionIntelligence?.titles?.enabled ?? true); - setChatAutoTitleRefresh(effectiveAi?.sessionIntelligence?.titles?.refreshOnComplete ?? true); - setChatAutoTitleReasoning( - effectiveAi?.sessionIntelligence?.titles?.reasoningEffort - ?? effectiveAi?.chat?.autoTitleReasoningEffort - ?? null - ); setScheduledWorkPaused(effectiveAi?.chat?.scheduledWorkPaused === true); - - const persistedReasoning = effectiveAi?.featureReasoningOverrides ?? {}; - const nextReasoning: Record = {}; - for (const key of Object.keys(persistedReasoning)) { - nextReasoning[key] = persistedReasoning[key as AiFeatureKey] ?? null; - } - nextReasoning.terminal_summaries = effectiveAi?.sessionIntelligence?.summaries?.reasoningEffort - ?? nextReasoning.terminal_summaries - ?? null; - setFeatureReasoning(nextReasoning); } finally { setLoading(false); } - }, [defaultFeatureModels]); + }, []); useEffect(() => { void loadStatus(); }, [loadStatus]); - const availableModelIds = React.useMemo(() => deriveConfiguredModelIds(status), [status]); - - const featureRowHoverCss = `.ai-feature-row:hover { background: ${COLORS.hoverBg}; }`; - - const saveChatTitleSettings = useCallback(async (patch: ChatTitleSettingsPatch) => { - if (saving) return; - setSaving(true); - try { - const nextModelId = - patch.modelId !== undefined - ? patch.modelId - : utilityModel || ""; - const nextEnabled = - patch.enabled !== undefined ? patch.enabled : chatAutoTitleEnabled; - const nextRefresh = - patch.refreshOnComplete !== undefined - ? patch.refreshOnComplete - : chatAutoTitleRefresh; - const nextReasoning = - patch.reasoningEffort !== undefined - ? patch.reasoningEffort - : chatAutoTitleReasoning; - - await window.ade.ai.updateConfig({ - sessionIntelligence: { - titles: { - enabled: nextEnabled, - modelId: nextModelId || null, - reasoningEffort: nextReasoning, - refreshOnComplete: nextRefresh, - }, - }, - }); - - setChatAutoTitleEnabled(nextEnabled); - setChatAutoTitleRefresh(nextRefresh); - if (patch.reasoningEffort !== undefined) { - setChatAutoTitleReasoning(patch.reasoningEffort); - } - } catch (error) { - console.error("[AiFeaturesSection] saveChatTitleSettings failed:", error); - } finally { - setSaving(false); - } - }, [chatAutoTitleEnabled, chatAutoTitleRefresh, chatAutoTitleReasoning, saving, utilityModel]); - - const handleToggle = useCallback(async (key: AiFeatureKey, enabled: boolean) => { - if (saving) return; - setSaving(true); - try { - const currentFeatures: Record = {}; - if (status?.features) { - for (const f of status.features) { - currentFeatures[f.feature] = f.enabled; - } - } - currentFeatures[key] = enabled; - await window.ade.ai.updateConfig({ - features: currentFeatures as AiConfig["features"], - ...(key === "terminal_summaries" - ? { - sessionIntelligence: { - summaries: { - enabled, - }, - } as AiConfig["sessionIntelligence"], - } - : {}), - }); - await loadStatus(); - } finally { - setSaving(false); - } - }, [loadStatus, saving, status]); - const handleScheduledWorkPaused = useCallback(async (paused: boolean) => { if (saving) return; setSaving(true); @@ -295,63 +109,6 @@ export function AiFeaturesSection() { } }, [loadStatus]); - const handleModelChange = useCallback(async (key: AiFeatureKey, modelId: string) => { - if (saving) return; - setSaving(true); - try { - const nextFeatureModels = { ...featureModels, [key]: modelId }; - setFeatureModels(nextFeatureModels); - const featureModelOverrides = toFeatureModelOverrides(nextFeatureModels) ?? {}; - if (!modelId) { - featureModelOverrides[key] = null; - } - await window.ade.ai.updateConfig({ - featureModelOverrides, - ...(key === "terminal_summaries" - ? { - sessionIntelligence: { - summaries: { - modelId: modelId || null, - }, - } as AiConfig["sessionIntelligence"], - } - : {}), - }); - } finally { - setSaving(false); - } - }, [featureModels, saving]); - - const handleReasoningChange = useCallback(async (key: AiFeatureKey, effort: string | null) => { - if (saving) return; - setSaving(true); - try { - const nextReasoning = { ...featureReasoning, [key]: effort }; - setFeatureReasoning(nextReasoning); - const overrides: Partial> = {}; - for (const [k, v] of Object.entries(nextReasoning)) { - if (v != null) overrides[k] = v; - } - if (effort == null) { - overrides[key] = null; - } - await window.ade.ai.updateConfig({ - featureReasoningOverrides: overrides as AiConfig["featureReasoningOverrides"], - ...(key === "terminal_summaries" - ? { - sessionIntelligence: { - summaries: { - reasoningEffort: effort, - }, - } as AiConfig["sessionIntelligence"], - } - : {}), - }); - } finally { - setSaving(false); - } - }, [featureReasoning, saving]); - if (loading) { return (
@@ -360,13 +117,7 @@ export function AiFeaturesSection() { ); } - if (!status) { - return ( -
- Unable to load AI status. -
- ); - } + const featureRowHoverCss = `.ai-feature-row:hover { background: ${COLORS.hoverBg}; }`; return ( <> @@ -380,10 +131,9 @@ export function AiFeaturesSection() { fontFamily: SANS_FONT, marginBottom: 12, lineHeight: 1.4, - whiteSpace: "nowrap", }} > - ADE can handle routine tasks in the background while you focus on what matters. Enable the helpers you want and pick a model for each. + Background naming, idle status lines, and commit suggestions follow the ADE provider of the session that needs them. Pause or inspect durable scheduled work here.
@@ -453,243 +203,6 @@ export function AiFeaturesSection() {
)}
- -
-
-
ON
-
FEATURE
-
MODEL
-
TODAY
-
- - {FEATURES.map((feature, index) => { - const row = status.features.find((entry) => entry.feature === feature.key); - const enabled = row?.enabled ?? false; - const dailyUsage = row?.dailyUsage ?? 0; - const selectedModel = featureModels[feature.key] ?? ""; - const needsModelSelection = enabled && !selectedModel; - const IconComponent = feature.icon; - - return ( -
- void handleToggle(feature.key, value)} /> - -
- -
-
- {feature.label} -
-
- {feature.subtitle} -
- {needsModelSelection ? ( -
- Select a model to enable this feature. -
- ) : null} -
-
- -
- void handleModelChange(feature.key, modelId)} - surfaceKey={`ai-feature-${feature.key}`} - availableModelIds={availableModelIds} - onOpenSignIn={openProviderSignIn} - disabled={!enabled} - /> - void handleReasoningChange(feature.key, effort)} - useFamilyDefaults={false} - disabled={!enabled} - /> -
- -
0 ? COLORS.textSecondary : COLORS.textDim, - textAlign: "right", - }} - > - {dailyUsage} -
-
- ); - })} - - {/* Auto-name chats, lanes, and branches */} -
- void saveChatTitleSettings({ enabled: value })} - /> - -
- -
-
-
- Auto-name chats, lanes, and branches -
-
- Names come from the opening request. ADE uses the selected naming model, or the launched chat model by default, with deterministic names when it is unavailable or slow. -
- -
-
-
- -
- { - setUtilityModel(modelId); - void saveChatTitleSettings({ modelId }); - }} - surfaceKey="ai-feature-chat-auto-title" - availableModelIds={availableModelIds} - onOpenSignIn={openProviderSignIn} - disabled={!chatAutoTitleEnabled} - /> - { - setChatAutoTitleReasoning(effort); - void saveChatTitleSettings({ reasoningEffort: effort }); - }} - useFamilyDefaults={false} - disabled={!chatAutoTitleEnabled} - /> -
- -
- — -
-
-
); diff --git a/apps/desktop/src/shared/adeCliGuidance.test.ts b/apps/desktop/src/shared/adeCliGuidance.test.ts index 39e4eeb337..5a31ac72af 100644 --- a/apps/desktop/src/shared/adeCliGuidance.test.ts +++ b/apps/desktop/src/shared/adeCliGuidance.test.ts @@ -48,6 +48,9 @@ describe("ADE bootstrap guidance", () => { expect(bootstrap).toContain("the user's call, or the automatic result of its PR merging"); expect(bootstrap).not.toContain("ade chat settle --outcome"); expect(bootstrap).toContain("ade session snooze --for "); + expect(bootstrap).toContain("ade chat generate-names"); + expect(bootstrap).toContain("ade chat update --title"); + expect(bootstrap).toContain("ade lanes rename"); // The skill index is still advertised so the model knows what exists. for (const skillName of adeBundledAgentSkills) { expect(bootstrap).toContain(`\`${skillName}\``); @@ -60,7 +63,7 @@ describe("ADE bootstrap guidance", () => { // The redesign dropped the ~4,300-char blob; the bootstrap must stay tiny // Keep the shared lifecycle protocol compact while guarding against // regrowth into the old per-domain rulebook. - expect(bootstrap.length).toBeLessThan(2600); + expect(bootstrap.length).toBeLessThan(2900); // The per-domain operating rules now live in their skills, not always-on. expect(bootstrap).not.toContain("### Minimum operating rules"); expect(bootstrap).not.toContain("--socket"); diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 85825219ac..dfa9489ffc 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -31,6 +31,8 @@ export const ADE_SESSION_STATUS_PROTOCOL_GUIDANCE = [ '- Done: report it and leave `ade chat note ""`.', "- You cannot settle or unsettle a session; that is the user's call, or the automatic result of its PR merging.", "- Waiting a while? `ade session snooze --for ` hides the row without claiming done; a hand-raise wakes it.", + "- Keep `ade chat note` current as the work changes; do not wait until the end.", + "- If the lane, branch, or chat name is wrong, rename it: `ade chat generate-names`, `ade chat update --title`, or `ade lanes rename`.", ].join("\n"); /** diff --git a/apps/desktop/src/shared/backgroundUtilityModel.test.ts b/apps/desktop/src/shared/backgroundUtilityModel.test.ts new file mode 100644 index 0000000000..6fe5eae0e4 --- /dev/null +++ b/apps/desktop/src/shared/backgroundUtilityModel.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + adeBackgroundUtilityProvider, + adeBackgroundUtilityProviderFromToolType, + BACKGROUND_UTILITY_CLAUDE_MODEL_ID, + BACKGROUND_UTILITY_CODEX_MODEL_ID, + BACKGROUND_UTILITY_CODEX_REASONING_EFFORT, + BACKGROUND_UTILITY_CURSOR_MODEL_ID, + backgroundUtilityModelId, + backgroundUtilityReasoningEffort, +} from "./backgroundUtilityModel"; + +describe("backgroundUtilityModel", () => { + it("keys cheap models off the ADE provider, not a registry family", () => { + expect(adeBackgroundUtilityProvider("claude")).toBe("claude"); + expect(adeBackgroundUtilityProvider("codex")).toBe("codex"); + expect(adeBackgroundUtilityProvider("cursor")).toBe("cursor"); + expect(adeBackgroundUtilityProvider("opencode")).toBeNull(); + expect(adeBackgroundUtilityProvider("droid")).toBeNull(); + expect(adeBackgroundUtilityProvider("pi")).toBeNull(); + expect(backgroundUtilityModelId("claude")).toBe(BACKGROUND_UTILITY_CLAUDE_MODEL_ID); + expect(backgroundUtilityModelId("codex")).toBe(BACKGROUND_UTILITY_CODEX_MODEL_ID); + expect(backgroundUtilityModelId("cursor")).toBe(BACKGROUND_UTILITY_CURSOR_MODEL_ID); + }); + + it("maps tracked CLI and ADE-chat tool types onto the same ADE providers", () => { + expect(adeBackgroundUtilityProviderFromToolType("claude-chat")).toBe("claude"); + expect(adeBackgroundUtilityProviderFromToolType("codex")).toBe("codex"); + expect(adeBackgroundUtilityProviderFromToolType("cursor-cli")).toBe("cursor"); + expect(adeBackgroundUtilityProviderFromToolType("opencode-chat")).toBeNull(); + expect(adeBackgroundUtilityProviderFromToolType("shell")).toBeNull(); + }); + + it("pins Codex Luna to low reasoning and leaves other cheap models unset", () => { + expect(backgroundUtilityReasoningEffort(BACKGROUND_UTILITY_CODEX_MODEL_ID)) + .toBe(BACKGROUND_UTILITY_CODEX_REASONING_EFFORT); + expect(backgroundUtilityReasoningEffort(BACKGROUND_UTILITY_CLAUDE_MODEL_ID)).toBeNull(); + expect(backgroundUtilityReasoningEffort(BACKGROUND_UTILITY_CURSOR_MODEL_ID)).toBeNull(); + }); +}); diff --git a/apps/desktop/src/shared/backgroundUtilityModel.ts b/apps/desktop/src/shared/backgroundUtilityModel.ts new file mode 100644 index 0000000000..1adb654d0b --- /dev/null +++ b/apps/desktop/src/shared/backgroundUtilityModel.ts @@ -0,0 +1,75 @@ +/** + * Cheap background helpers (lane/chat names, idle status lines, commit + * suggestions) pick a model from the ADE provider that owns the session, not + * from Settings and not from the model's registry family. + * + * OpenCode-wrapped Anthropic must not spawn `claude -p` Haiku. Droid, Pi, ACP, + * and local sessions reuse the session's own model. + */ +export const BACKGROUND_UTILITY_CLAUDE_MODEL_ID = "anthropic/claude-haiku-4-5"; +export const BACKGROUND_UTILITY_CODEX_MODEL_ID = "openai/gpt-5.6-luna"; +export const BACKGROUND_UTILITY_CODEX_REASONING_EFFORT = "low"; +export const BACKGROUND_UTILITY_CURSOR_MODEL_ID = "cursor/composer-2.5"; + +export type AdeBackgroundUtilityProvider = "claude" | "codex" | "cursor"; + +export function adeBackgroundUtilityProvider( + provider: string | null | undefined, +): AdeBackgroundUtilityProvider | null { + const normalized = String(provider ?? "").trim().toLowerCase(); + if (normalized === "claude") return "claude"; + if (normalized === "codex") return "codex"; + if (normalized === "cursor") return "cursor"; + return null; +} + +export function adeBackgroundUtilityProviderFromToolType( + toolType: string | null | undefined, +): AdeBackgroundUtilityProvider | null { + const normalized = String(toolType ?? "").trim().toLowerCase(); + if ( + normalized === "claude" + || normalized === "claude-chat" + || normalized === "claude-orchestrated" + ) { + return "claude"; + } + if ( + normalized === "codex" + || normalized === "codex-chat" + || normalized === "codex-orchestrated" + ) { + return "codex"; + } + if (normalized === "cursor" || normalized === "cursor-cli") return "cursor"; + return null; +} + +export function backgroundUtilityModelId( + provider: AdeBackgroundUtilityProvider, +): string { + switch (provider) { + case "claude": + return BACKGROUND_UTILITY_CLAUDE_MODEL_ID; + case "codex": + return BACKGROUND_UTILITY_CODEX_MODEL_ID; + case "cursor": + return BACKGROUND_UTILITY_CURSOR_MODEL_ID; + default: { + const exhaustive: never = provider; + return exhaustive; + } + } +} + +export function backgroundUtilityReasoningEffort(modelId: string | null | undefined): string | null { + const id = String(modelId ?? "").trim(); + if (id === BACKGROUND_UTILITY_CODEX_MODEL_ID || id === "gpt-5.6-luna" || id === "luna") { + return BACKGROUND_UTILITY_CODEX_REASONING_EFFORT; + } + return null; +} + +/** How long ADE waits for a native provider title before naming the chat itself. */ +export const NATIVE_TITLE_WAIT_MS = 8_000; +export const NATIVE_TITLE_POLL_MS = 250; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 0c5a703b9f..c618d98c6d 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -3167,13 +3167,14 @@ export type AgentChatSuggestLaneNameArgs = { laneId: string; /** User prompt for the chat launch (used to derive a short lane name prefix). */ prompt: string; - /** Registry model ID used to run the naming call (e.g. first selected model). */ + /** Optional ADE provider that owns the launching chat (`claude`/`codex`/`cursor`). */ + provider?: string; + /** Registry model ID used as the session-model fallback after the cheap helper. */ modelId: string; /** - * Registry model ID the chat itself was launched with. Distinct from `modelId`, - * which is the configured naming model when one is set — the naming fallback - * chain needs the launched model even then, so it can escape a naming provider - * that is broken at the provider level. + * Registry model ID the chat itself was launched with. Distinct from `modelId` + * when the naming call and the chat model differ. The cheap helper runs first; + * these are the fallbacks if that helper answers unusably. */ chatModelId?: string; /** Optional fallback used when model-backed naming is disabled or unavailable. */ diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 7d2e804cd3..4b91125a0d 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3679,6 +3679,11 @@ struct AgentChatSessionIdRequest: Codable, Equatable { var sessionId: String } +struct AgentChatRegenerateSessionMetadataRequest: Codable, Equatable { + var sessionId: String + var fields: [String]? +} + struct AgentChatApproveRequest: Codable, Equatable { var sessionId: String var itemId: String diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 5caaed3361..f1c80298f2 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -13907,6 +13907,16 @@ final class SyncService: ObservableObject { ) } + func regenerateChatSessionMetadata(sessionId: String, fields: [String]? = nil) async throws { + let scope = chatCommandScope(for: sessionId) + _ = try await sendChatCommand( + action: chatActionName("chat.regenerateSessionMetadata", sessionId: sessionId), + payload: AgentChatRegenerateSessionMetadataRequest(sessionId: sessionId, fields: fields), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) + } + func archiveChatSession(sessionId: String) async throws { let scope = chatCommandScope(for: sessionId) _ = try await sendChatCommand( @@ -14018,10 +14028,6 @@ final class SyncService: ObservableObject { ]) } - func draftPullRequestDescription(laneId: String) async throws -> PullRequestDraftSuggestion { - try await sendDecodableCommand(action: "prs.draftDescription", args: ["laneId": laneId], as: PullRequestDraftSuggestion.self) - } - func rerunPullRequestChecks(prId: String, checkRunIds: [Int]? = nil) async throws { var args: [String: Any] = ["prId": prId] if let checkRunIds, !checkRunIds.isEmpty { diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index d6289fd08c..015ee915b9 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -323,6 +323,10 @@ struct WorkSidebarSectionHeader: View { /// stated once, here. Nil for status and time sections, which span lanes and /// therefore have no single true answer. var laneStatus: LaneStatus? = nil + /// Lane record for the Work-tab divider long-press menu. Nil on status/time + /// headers and orphaned sections. + var lane: LaneSummary? = nil + var laneMenu: WorkSessionLaneMenuActions? = nil /// Collapsed and holding only settled work: render one thin muted row with the /// count folded in, instead of a full-weight header over nothing. @@ -404,6 +408,11 @@ struct WorkSidebarSectionHeader: View { .padding(.horizontal, 4) .padding(.vertical, isQuietRow ? 3 : 8) .opacity(isQuietRow ? 0.72 : 1) + .contextMenu { + if let lane, let laneMenu, group.laneId != nil, !group.isOrphaned { + WorkLaneContextMenuContent(lane: lane, actions: laneMenu) + } + } } /// Nothing to say when the worktree is clean and level with its base — an @@ -513,6 +522,9 @@ struct WorkSessionLaneMenuActions { var colorAvailable: Bool = false var manageAvailable: Bool = false var onStartChat: (LaneSummary) -> Void = { _ in } + var onToggleWorkPin: (LaneSummary) -> Void = { _ in } + var isWorkPinned: (LaneSummary) -> Bool = { _ in false } + var onOpenInWeb: (LaneSummary) -> Void = { _ in } var onCopyLaneLink: (LaneSummary) -> Void = { _ in } var onCopyBranchLink: (LaneSummary) -> Void = { _ in } var onCopyLinearLink: (LaneSummary) -> Void = { _ in } @@ -521,6 +533,88 @@ struct WorkSessionLaneMenuActions { var onManage: (LaneSummary) -> Void = { _ in } } +/// Shared lane long-press contents for a session row's `Lane ▸` submenu and the +/// lane divider itself, so the two surfaces cannot drift. +struct WorkLaneContextMenuContent: View { + let lane: LaneSummary + let actions: WorkSessionLaneMenuActions + + var body: some View { + Button { + actions.onStartChat(lane) + } label: { + Label("Start chat in lane", systemImage: "plus.bubble") + } + Button { + actions.onToggleWorkPin(lane) + } label: { + Label( + actions.isWorkPinned(lane) ? "Unpin from Work sidebar" : "Pin to Work sidebar", + systemImage: actions.isWorkPinned(lane) ? "pin.slash" : "pin" + ) + } + Button { + actions.onOpenInWeb(lane) + } label: { + Label("Open in web", systemImage: "safari") + } + Menu { + Button { + actions.onCopyLaneLink(lane) + } label: { + Label("ADE lane link", systemImage: "link") + } + Button { + actions.onCopyBranchLink(lane) + } label: { + Label("Branch link", systemImage: "arrow.triangle.branch") + } + if primaryLaneLinearIssue(for: lane)?.url != nil { + Button { + actions.onCopyLinearLink(lane) + } label: { + Label("Linear issue link", systemImage: "square.on.square") + } + } + Button { + actions.onCopyPath(lane) + } label: { + Label("Path", systemImage: "folder") + } + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + if actions.colorAvailable { + Menu { + ForEach(LaneColorPalette.entries) { entry in + Button { + actions.onSetColor(lane, entry.hex) + } label: { + Label(entry.name, systemImage: lane.color?.lowercased() == entry.hex.lowercased() + ? "checkmark.circle.fill" + : "circle.fill") + } + } + Divider() + Button { + actions.onSetColor(lane, nil) + } label: { + Label("No color", systemImage: "circle.dashed") + } + } label: { + Label("Color", systemImage: "paintpalette") + } + } + if actions.manageAvailable { + Button { + actions.onManage(lane) + } label: { + Label("Manage lane", systemImage: "slider.horizontal.3") + } + } + } +} + /// Single-row renderer for the session list that carries the swipe + context-menu action set. /// Used inside the sidebar's grouped loop so the Work root screen can drive the section /// organization directly (byLane / byStatus / byTime) without a nested Section wrapper. @@ -587,6 +681,9 @@ struct WorkSessionListRow: View { /// Lane-scoped actions for the `Lane ▸` submenu. Nil (the default) renders no /// submenu at all, which is also what a row with no resolvable lane gets. var laneMenu: WorkSessionLaneMenuActions? = nil + /// The host advertises `chat.regenerateSessionMetadata`. + var generateNamesAvailable: Bool = false + var onGenerateNames: (TerminalSessionSummary, [String]) -> Void = { _, _ in } /// Observed so the muted glyph and menu label re-render the moment a mute /// flips anywhere (this menu, the open chat's header menu, settings). @@ -892,6 +989,44 @@ struct WorkSessionListRow: View { systemImage: isMuted ? "bell" : "bell.slash") } } + generateNamesMenu + } + + @ViewBuilder + private var generateNamesMenu: some View { + if isChat && generateNamesAvailable { + let cloudOwned = CursorCloudNaming.ownsName(session.cursorCloudAgentId) + || CursorCloudNaming.ownsName(chatSummary?.cursorCloudAgentId) + Menu { + if !cloudOwned { + Button { + onGenerateNames(session, ["title"]) + } label: { + Label("Generate chat title", systemImage: "textformat") + } + } + Button { + onGenerateNames(session, ["laneName"]) + } label: { + Label("Generate lane name", systemImage: "arrow.triangle.branch") + } + Button { + onGenerateNames(session, ["statusLine"]) + } label: { + Label("Generate status line", systemImage: "text.alignleft") + } + Button { + onGenerateNames( + session, + cloudOwned ? ["laneName", "statusLine"] : ["title", "laneName", "statusLine"] + ) + } label: { + Label(cloudOwned ? "Generate lane & status" : "Generate all three", systemImage: "sparkles") + } + } label: { + Label("Generate names", systemImage: "sparkles") + } + } } /// Everything that changes where the list files this row: stop (runtime), @@ -1028,69 +1163,7 @@ struct WorkSessionListRow: View { private var laneMenuSection: some View { if let lane, let laneMenu { Menu { - Button { - laneMenu.onStartChat(lane) - } label: { - Label("Start chat in lane", systemImage: "plus.bubble") - } - Menu { - Button { - laneMenu.onCopyLaneLink(lane) - } label: { - Label("ADE lane link", systemImage: "link") - } - Button { - laneMenu.onCopyBranchLink(lane) - } label: { - Label("Branch link", systemImage: "arrow.triangle.branch") - } - // Only a lane that actually carries a Linear issue URL — the copy is - // that URL verbatim, so there is nothing to offer without one. - if primaryLaneLinearIssue(for: lane)?.url != nil { - Button { - laneMenu.onCopyLinearLink(lane) - } label: { - Label("Linear issue link", systemImage: "square.on.square") - } - } - Button { - laneMenu.onCopyPath(lane) - } label: { - Label("Path", systemImage: "folder") - } - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - if laneMenu.colorAvailable { - Menu { - ForEach(LaneColorPalette.entries) { entry in - Button { - laneMenu.onSetColor(lane, entry.hex) - } label: { - Label(entry.name, systemImage: lane.color?.lowercased() == entry.hex.lowercased() - ? "checkmark.circle.fill" - : "circle.fill") - } - } - Divider() - Button { - laneMenu.onSetColor(lane, nil) - } label: { - Label("No color", systemImage: "circle.dashed") - } - } label: { - Label("Color", systemImage: "paintpalette") - } - } - if laneMenu.manageAvailable { - // Last inside the submenu, like desktop: it opens a surface that can - // archive or delete the lane. - Button { - laneMenu.onManage(lane) - } label: { - Label("Manage lane", systemImage: "slider.horizontal.3") - } - } + WorkLaneContextMenuContent(lane: lane, actions: laneMenu) } label: { Label("Lane", systemImage: "arrow.triangle.branch") } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index afdaf55041..027e37e2d1 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -796,6 +796,28 @@ extension WorkRootScreen { pushNewChatRoute(preferredLaneId: lane.id) } + func openLaneInWeb(_ lane: LaneSummary) { + let pullRequest = pullRequests.first(where: { $0.laneId == lane.id }) + let link = LaneDeeplinkHelpers.laneLink( + laneId: lane.id, + envelope: LaneDeeplinkHelpers.envelope(lane: lane, pullRequest: pullRequest), + form: .https + ) + guard let url = workWebClientURL(for: link) else { return } + UIApplication.shared.open(url) + } + + func generateSessionNames(_ session: TerminalSessionSummary, fields: [String]) { + Task { + do { + try await syncService.regenerateChatSessionMetadata(sessionId: session.id, fields: fields) + } catch { + ADEHaptics.error() + actionErrorMessage = error.localizedDescription + } + } + } + func copyLaneLink(_ lane: LaneSummary) { let pullRequest = pullRequests.first(where: { $0.laneId == lane.id }) UIPasteboard.general.string = LaneDeeplinkHelpers.laneLink( diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index fdf22ee1d2..29ab888ca6 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -465,6 +465,16 @@ struct WorkRootScreen: View { Set(pinnedLaneIdsStorage.split(separator: ",").map(String.init).filter { !$0.isEmpty }) } + func toggleWorkPin(_ lane: LaneSummary) { + var next = workPinnedLaneIds + if next.contains(lane.id) { + next.remove(lane.id) + } else { + next.insert(lane.id) + } + pinnedLaneIdsStorage = next.sorted().joined(separator: ",") + } + /// Machines that own work in this project and are no longer reachable. The /// connected host is online by definition, so anything here is a second Mac /// whose lanes reached this list through the account feed. @@ -981,6 +991,9 @@ struct WorkRootScreen: View { colorAvailable: syncService.canInvokeRemoteAction("lanes.updateAppearance"), manageAvailable: syncService.canInvokeRemoteAction("lanes.rename"), onStartChat: startChatInLane, + onToggleWorkPin: toggleWorkPin, + isWorkPinned: { workPinnedLaneIds.contains($0.id) }, + onOpenInWeb: openLaneInWeb, onCopyLaneLink: copyLaneLink, onCopyBranchLink: copyLaneBranchLink, onCopyLinearLink: copyLaneLinearLink, @@ -1080,7 +1093,9 @@ struct WorkRootScreen: View { // Lane-scoped git state belongs to the lane, so it is stated once here // rather than repeated on every row beneath. Orphaned sections have no // lane record to read it from. - laneStatus: group.isOrphaned ? nil : group.laneId.flatMap { laneById[$0]?.status } + laneStatus: group.isOrphaned ? nil : group.laneId.flatMap { laneById[$0]?.status }, + lane: group.isOrphaned ? nil : group.laneId.flatMap { laneById[$0] }, + laneMenu: workLaneMenuActions ) .disabled(isLaneDeleting) .redacted(reason: isLaneDeleting ? .placeholder : []) @@ -1219,7 +1234,9 @@ struct WorkRootScreen: View { deleteSessionAvailable: syncService.supportsWorkSessionDeletion, onDeleteSession: deleteWorkSession, onOpenInWeb: openSessionInWeb, - laneMenu: workLaneMenuActions + laneMenu: workLaneMenuActions, + generateNamesAvailable: syncService.canInvokeRemoteAction("chat.regenerateSessionMetadata"), + onGenerateNames: generateSessionNames ) } diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 430587993f..bdc00c12ff 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -1237,9 +1237,9 @@ session primitives: unique `-` style name and persists progress under `agent-chat-parallel-launch::` in `kv`. When AI titles are enabled, `startBackgroundParallelLaneNaming` then - makes a single background `ade.agentChat.suggestLaneName` call (which - runs the shared session-intelligence title prompt against the - requested, configured, and fallback title models) and renames every + makes a single background `ade.agentChat.suggestLaneName` call (cheap + helper by ADE provider, then this session's model, then deterministic) + and renames every child to `-` in place; one child's rename failure does not abort the rest, and the children are flagged in `laneNamingStore` so their lane labels are masked while the pass runs. @@ -2489,10 +2489,11 @@ config service): - `ai.mode` -- `subscription` vs `guest`; gates auto-title, tool availability, and provider selection. -- `ai.sessionIntelligence.titles.*` -- AI title generation. The - configured `titleModelId` when set, then this chat's model, then - deterministic. An empty candidate list still names the chat. Legacy - `ai.chat.autoTitleReasoningEffort` is migrated into this tree. +- `ai.sessionIntelligence.titles.*` -- parsed for compatibility; naming + is not gated by `enabled` and does not use a Settings model picker. + Cheap helper by ADE provider, then this session's model, then + deterministic. Legacy `ai.chat.autoTitleReasoningEffort` is migrated + into this tree. - `ai.permissions.*` -- per-provider permission defaults (`claudePermissionMode`, Codex approval/sandbox defaults, OpenCode permission). diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 4ad2f637b4..6d5f7e8057 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -905,25 +905,28 @@ on the Claude Agent SDK: ## Auto-title generation -Sessions auto-title through two stages when -`ai.sessionIntelligence.titles.enabled` is true and the runtime is not `guest`: - -- **Initial** -- generated early in the conversation from the first - user message, providing an immediate label while the session is still - brief. -- **Final** -- generated once enough transcript has accumulated, - producing a more accurate title. - -`ai.sessionIntelligence.titles.refreshOnComplete` (default true) triggers a final -refresh after a turn completes. - -Both stages walk the shared naming chain built by `buildSessionIntelligenceModelCandidates` -in `sessionNaming.ts` — the configured `titleModelId` when set, then this -chat's model — and run it through `runNamingAcrossProviders`. There is no -hardcoded Haiku or "first available" namer. A provider-level failure -condemns every remaining model behind that provider. An empty candidate -list is a no-op walk and still uses the deterministic title; naming does -not throw or skip just because no model is configured. +ADE names chats and auto-created lanes from the ADE provider that owns +the session, not from a Settings picker and not from the model's +registry family: + +- Claude → Haiku 4.5, then this session's model, then a deterministic slug +- Codex → GPT-5.6 Luna (`low`), then this session's model, then a slug +- Cursor → Composer 2.5, then this session's model, then a slug +- OpenCode, Droid, Pi, ACP, local → this session's model, then a slug + +OpenCode wrapping Anthropic does not spawn `claude -p` Haiku. Cursor +Cloud names stay on Cursor; ADE never overlays them. ADE chats wait +about 8s for a native provider title, then ADE-name if the title is +still the default. CLI sessions are always ADE-named. `ade chat +generate-names` regenerates title, lane name, and status line. + +Both stages walk `buildSessionIntelligenceModelCandidates` in +`sessionNaming.ts`. A provider-level failure condemns every remaining +model behind that provider. An empty candidate list uses the +deterministic title. + +`ai.sessionIntelligence.titles.enabled` is not a gate. Naming always +runs (except Cursor Cloud titles and `manuallyNamed`). Six words is the guideline the prompt gives the model, not a rejection rule: a seven-word title is clamped to the first six rather than discarded, and an @@ -939,21 +942,21 @@ suppresses further auto-title generation. The manual-rename check runs *before* the title write, not after, because adopting a title has side effects (session meta, runtime push) that a rename landing mid-request must stop. -The same chain — Settings title/summary model, then this session's model, -then deterministic — covers chat titles, end-of-session summaries, explicit -session-metadata regeneration, automatic lane names, handoff briefs, and -identity-continuity summaries. CLI titles and terminal summaries are -separate: they try the title/summary setting, then the stored launch model, -and skip the AI call when both are missing. See +The same chain — cheap helper for the ADE provider, then this session's +model, then deterministic — covers chat titles, idle status lines, +explicit session-metadata regeneration, automatic lane names, handoff +briefs, and identity-continuity summaries. CLI sessions are always +ADE-named from the cheap helper plus the stored launch model. See [AI-driven titles](../terminals-and-sessions/pty-and-sessions.md#ai-driven-titles). ## One-shot utility tasks -Commit messages, PR drafts/summaries, and conflict proposals pick a -model once: the caller argument, else the feature picker in Settings, -else skip or throw a Settings prompt. Review start requires an explicit -run `modelId` (not a Settings feature picker). There is no hardcoded -Haiku / Sonnet / "first available" namer. +Commit messages come from the last turned ADE chat on that lane +(same cheap-helper rule). An empty suggestion does not commit. Graph +and iOS PR create is a title (from the lane name) plus optional +markdown; ADE does not draft or summarize the PR. Graph edge clicks +run merge simulation only — there is no AI conflict-proposal flyout. +Review start requires an explicit run `modelId`. - Commit messages and conflict proposals throw `Choose a … model in Settings`. - PR drafts and PR AI summaries use the deterministic template when the diff --git a/docs/features/conflicts/README.md b/docs/features/conflicts/README.md index d6d6c7be88..8179883753 100644 --- a/docs/features/conflicts/README.md +++ b/docs/features/conflicts/README.md @@ -10,7 +10,7 @@ is projected into the surfaces where it matters: - **Lanes**: status badges and overlap chips on lane rows, inline merge simulation, rebase banner. - **Graph**: project-wide `RiskMatrix`, pairwise overlap tooltips, - inline `ConflictPanel` for AI resolution. + edge-click merge simulation. - **PRs**: blocked/manual rebase UIs, integration (merge-plan) pairwise simulation, issue resolution. @@ -29,9 +29,8 @@ desktop's in-process IPC handlers when no runtime is bound. Remote- bound windows therefore predict conflicts, run merge simulations, and execute external CLI resolvers on the remote machine — the worktrees and pack artifacts they read are on the remote host. The -`ConflictPanel` and `RiskMatrix` renderer components only hold view -state; they call out to the runtime for every prediction or -proposal action. +`RiskMatrix` renderer components only hold view +state; they call out to the runtime for every prediction. ## Source file map @@ -53,7 +52,6 @@ Renderer: |------|---------------| | `renderer/components/graph/shared/RiskMatrix.tsx` | Animated pairwise risk grid | | `renderer/components/graph/shared/RiskTooltip.tsx` | Hover detail for a matrix cell | -| `renderer/components/graph/graphDialogs/ConflictPanel.tsx` | AI proposal apply flow, overlapping file list | | `renderer/components/lanes/mergeSimulation/*` | Conflict file diff, merge simulation panel, language detection | | `renderer/components/shared/conflictResolver/ResolverTerminalModal.tsx` | External CLI resolver terminal modal | | `renderer/components/prs/ConflictFilePreview.tsx` | Conflict marker preview (re-used from PR flows) | @@ -263,8 +261,7 @@ Conflict intelligence lives where it's useful: - **Lanes**: status badges, overlap counts, "Open in Graph" jump. - **Graph**: `RiskMatrix` with animated transitions, cell tooltips - with overlap file lists, `ConflictPanel` for AI proposal apply, - merge simulation entry from edge clicks. + with overlap file lists, merge simulation from edge clicks. - **PRs > Rebase**: rebase continue/abort, manual attention surface for failed auto-rebases, upstream rebase chain view. - **PRs > Integration**: pairwise matrix for merge-plan proposals. diff --git a/docs/features/conflicts/simulation.md b/docs/features/conflicts/simulation.md index 41cfe55e1f..16ff8039fa 100644 --- a/docs/features/conflicts/simulation.md +++ b/docs/features/conflicts/simulation.md @@ -95,10 +95,8 @@ entries. 2. Short-circuit if `insufficientContext`: record a `failed` proposal with explicit data-gap messaging, do not dispatch. 3. Route through `aiIntegrationService.requestConflictProposal` - using the Conflict Proposals model from Settings - (`featureModelOverrides.conflict_proposals`). If that picker is - empty, the call throws `Choose a Conflict Proposals model in Settings` - — it does not default to Sonnet or the first available provider. + using the caller-supplied model. There is no Settings conflict-proposal + picker. 4. Persist the result as a `conflict_proposals` row with: - `source: 'subscription'` or `'local'` - `confidence: number | null` (0.0–1.0) @@ -254,7 +252,8 @@ ai: ``` The conflict resolution dialog reads and writes these values via -`projectConfigService`. +`projectConfigService`. The workspace graph no longer opens a +conflict-resolution flyout; edge clicks run `simulateMerge` only. ## Gotchas diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 93869931de..2cc839d7f5 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -654,22 +654,12 @@ Renderer — settings: get the bundled CLI automatically; this surface is what makes `ade` available to the user's own terminals. - `apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx` - — Background Jobs settings for AI-powered helpers: auto-naming chats, - CLI sessions, and lanes; summarizing completed chats and terminals; - PR description drafting; commit message drafting; and conflict - proposals. One-shot helpers (commit messages, PR descriptions, - conflict proposals, terminal summaries) require that row's Settings - model: an empty picker skips AI or throws a Settings prompt instead of - silently picking Haiku or the first available model. Commit messages - and conflict proposals refuse with a Settings prompt; PR drafts and - summaries use the deterministic template. Auto-naming is session - intelligence, not a one-shot: the title setting, then this session's - model, then deterministic — an empty title picker still names the - chat. CLI titles/summaries try the setting, then the stored launch - model, and skip the AI call when both are missing. Live chat - compaction stays on the chat's own provider. Reasoning-effort - pickers use `useFamilyDefaults={false}` so each row keeps an - independent effort override. The section also owns **Pause all scheduled + — Pause all scheduled work and the active durable-job list. Naming, + commit suggestions, idle status lines, and similar background helpers + pick a cheap model from the ADE provider that owns the session + (Haiku 4.5 for Claude, GPT-5.6 Luna for Codex, Composer 2.5 for + Cursor; other providers reuse the session model). There is no + Settings picker for those helpers. The section owns **Pause all scheduled work**, persisted as `ai.chat.scheduledWorkPaused`. This pauses Claude wakeups, cron tasks, and `/loop` schedules across the project runtime without disarming them; overdue work catches up once after resume. Its @@ -1444,7 +1434,7 @@ changing rather than which service backs it: |---|---|---| | General | `ProjectSection.tsx`, `AdeCliSection.tsx`, `AutoUpdatesSection.tsx`, `KeepAwakeSection.tsx`, `ProductAnalyticsSection.tsx`, `DiagnosticsSharingSection.tsx`, `AboutSection.tsx` | The top ADE card shows running/installed/downloaded versions, the runtime service, and update controls; below it are project health, the `ade` command line (`#ade-cli`), **Sleep** (`#keep-awake`, hidden on hosted web — a browser holds no power lock), and the two Privacy consents — anonymous analytics and diagnostics sharing (`#diagnostics-sharing`, hidden on hosted web). Legacy `?tab=workspace`, `?tab=project`, `?tab=context`, `?tab=onboarding`, `?tab=help`, and `?tab=tours` land here. | | Appearance | `AppearanceSection.tsx`, `LaunchPromptSection.tsx` (renders `ChatAppearancePreview`) | Theme, chat typography and density, chat surface (tint, corners), chat details (copy-button position, message minimap, prompt-stash bookmark, launch-prompt clipboard, live preview), and terminal text. Rebuilt on the primitives — the old version used `font-mono` for every prose line and four different control idioms. Persisted to `localStorage` under `ade.userPreferences.v1`. | -| Agents & Models | `ProvidersSection.tsx`, `OAuthConnectModal.tsx`, `AiFeaturesSection.tsx`, `BudgetCapEditor.tsx`, `DictationSection.tsx` | Provider connections, model routing, background helpers, spend cap, and voice input — merged because provider auth and per-task model routing are one mental model. **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid, Pi — Pi's card also carries in-app provider sign-in) and **OpenCode — Universal Model Access**. Background helpers cover summaries, PR descriptions, commit messages, conflict proposals, auto-naming, and scheduled-work recovery. Legacy `?tab=ai`, `?tab=providers`, `?tab=background-jobs`, and `?tab=automations` land here. | +| Agents & Models | `ProvidersSection.tsx`, `OAuthConnectModal.tsx`, `AiFeaturesSection.tsx`, `BudgetCapEditor.tsx`, `DictationSection.tsx` | Provider connections, model routing, spend cap, and voice input — merged because provider auth and per-task model routing are one mental model. **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid, Pi — Pi's card also carries in-app provider sign-in) and **OpenCode — Universal Model Access**. Background helpers on this tab are scheduled-work pause/recovery only; naming and commit suggestions use the session's ADE provider. Legacy `?tab=ai`, `?tab=providers`, `?tab=background-jobs`, and `?tab=automations` land here. | | Lanes | `LaneBehaviorSection.tsx`, `LaneTemplatesSection.tsx`, `PrChatTranscriptsSection.tsx` | How lanes start (`new lane base`), stay current (`auto-rebase`), and tell you they fell behind (`rebase suggestions` off/badge/banner + min-behind threshold), plus lane init recipes and PR transcript gists. Legacy `?tab=lane-templates` lands here. | | Integrations | `GitHubIntegrationSection.tsx`, `LinearIntegrationSection.tsx` | GitHub and Linear — reinstated as its own tab. Legacy `?tab=integrations`, `?tab=github`, and `?tab=linear` land here; `?integration=github|linear` too, while `?integration=cli` follows the `ade-cli` anchor to General. | | Notifications | `NotificationsSection.tsx`, `AgentCompletionSoundSection.tsx` | Delivery for `AttentionPreferences`: per-event policy (off / ambient / notify) for agent and PR events, quiet hours, focus suppression, phone delivery and escalation, the agent completion sound, and the Lanes banner budget. The per-event matrix and quiet hours were fully modelled with balanced defaults but had **no UI at all** before this tab. | diff --git a/docs/features/onboarding-and-settings/configuration-schema.md b/docs/features/onboarding-and-settings/configuration-schema.md index 2cfbf4bc4f..a41b707867 100644 --- a/docs/features/onboarding-and-settings/configuration-schema.md +++ b/docs/features/onboarding-and-settings/configuration-schema.md @@ -292,21 +292,20 @@ type AiConfig = { }; ``` -`featureModelOverrides` / `featureReasoningOverrides` are the per-feature -model pickers in Settings → Agents & Models → Background helpers -(commit messages, PR descriptions, terminal summaries, conflict -proposals). An empty picker **skips AI** for that one-shot — ADE does -not fall through to Haiku, Sonnet, or the first available model. -Commit messages and conflict proposals refuse with a Settings prompt; -PR drafts and PR AI summaries use the deterministic template instead. -Review start is not a feature picker: the run requires an explicit -`modelId`, and empty throws `Choose a review model before starting a review.` -Session intelligence (chat titles, summaries, metadata, lane names, -handoff, continuity) uses the title/summary setting, then this session's -model, then deterministic. An empty candidate list still uses -deterministic naming — it does not throw or skip. CLI titles and -terminal summaries try the setting, then the stored launch model, and -skip the AI call when both are missing. Live chat compaction stays on +`featureModelOverrides` / `featureReasoningOverrides` remain in the +schema for older configs, but Settings no longer offers per-helper +model pickers. Background naming, idle status lines, and commit +suggestions pick a cheap model from the ADE provider that owns the +session (Haiku 4.5 / GPT-5.6 Luna / Composer 2.5), then that session's +model, then a deterministic slug. OpenCode, Droid, Pi, and ACP skip the +cheap helper and use the session model. Manual Graph PR create is +title plus optional markdown — ADE does not draft the description. +Review start still requires an explicit `modelId`. +Live chat compaction stays on +the chat's own provider. Session intelligence +(`sessionIntelligence.titles.enabled`) is not a gate: naming always +runs. Legacy `ai.chat.autoTitleModelId` values still parse into +`sessionIntelligence.titles.*` by `coerceAiConfig`. They are the chat's own provider. ### Disabled providers diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 7c37593da0..1665e321f7 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -30,7 +30,7 @@ tab reads on the remote runtime through `callPrReadRuntimeActionOr` (`domain: "pr"`). Local-bound windows call the in-process PR IPC handlers directly for high-volume reads such as `listWithConflicts`, `getDetail`, `getStatus`, `getChecks`, `getReviews`, `getComments`, -`getFiles`, `getCommits`, `getDeployments`, `getAiSummary`, and +`getFiles`, `getCommits`, `getDeployments`, and `getGitHubSnapshot`, so opening the PR tab does not wait on local daemon startup. Mutations and long-running workflows still use the project runtime route where that route owns the behavior. @@ -168,7 +168,7 @@ Service files (`apps/desktop/src/main/services/prs/`): | `prPollingService.ts` | Webhook-first PR freshness plus the direct-GitHub safety net. `reconcilePrs(prIds)` coalesces webhook-linked ids and refreshes only those rows immediately. A healthy relay suppresses hot polling and reduces broad refreshes to a 15-minute safety sweep; an unhealthy relay uses the configurable 60 s fallback (clamped to 5 s–5 min) and user-driven hot windows of 15 s for the first minute, then 30 s until the three-minute cap. Empty-cache discovery runs at most every 30 minutes with a healthy relay or 10 minutes without one. Before every network refresh, the poller honors credential cooldown/reset state and preserves the final 500 core/GraphQL requests for foreground actions. It writes `last_polled_at` per PR for delta polling. The ADE daemon owns an instance (created + started + disposed in `apps/ade-cli/src/bootstrap.ts`) for runtime-bound windows; the desktop main process owns the local-bound instance. | | `prMergeAutoSettlementService.ts` | Applies the enabled lane-PR merge settlement policy after each polling snapshot. It files chat and tracked-agent-CLI sessions for a newly discovered merged PR even when the session has pending input or background work: the merge is the explicit override. The single exception is a chat turn that is running *right now* — see [Active-turn deferral](#active-turn-deferral). **Which** sessions it may file is an explicit `MergeSettlementScope` union rather than an implicit fallthrough — see [Merge settlement scope](#merge-settlement-scope). Each PR is handled once — including when the scope resolves to `ambiguous` and nothing is filed at all, because this merge looked and decided — so user reactivation is not re-filed by that old merge, while another linked PR can file a later lifecycle. It emits `pr-sessions-auto-settled` only when the preceding in-memory snapshot contained that PR as open or draft. A first-sight merge — including backfilled history from another machine or the first snapshot after restart — is filed silently, so an imported history cannot generate merge toasts or push notifications. | | `prChatCards.ts` | Converts bounded PR polling transitions into durable `ade_card` episodes for linked Work chats: CI completion/failure, review received, merge ready, conflicts, and merged. CI jobs are failure-first, capped at three visible rows with `rowsTruncated`, and report an honest `degradedReason` + Retry action when both job/check detail sources fail instead of rendering an empty success state. Desktop-main and daemon-owned pollers call the same emitter, and failures are isolated per PR/session so one cold or malformed chat cannot stop the poll loop. | -| `prSummaryService.ts` | AI PR summary generator; uses the PR Descriptions model from Settings, otherwise a deterministic template (`This PR modifies N file(s).`). Caches `PrAiSummary` per `(prId, headSha)` in `pull_request_ai_summaries` so pushes invalidate the cache | +| `prSummaryService.ts` | Unused by current PR UI. Cached `PrAiSummary` generator remains in-process for the old IPC; desktop and iOS no longer fetch or show it. | | `workflowGraph.ts` | `createWorkflowGraph` — reconstructs the CI pipeline DAG (`PrWorkflowGraph`) behind a swappable `WorkflowGraph` interface. GitHub's jobs API does not return `needs:`, so the graph is built by parsing the workflow YAML that actually ran and joining it to live run state. Parses **only** `jobs..needs` and `jobs..strategy.matrix`, with the existing `yaml` dep. Source order: lane worktree `git show :.github/workflows/` → GitHub Contents API `?ref=` (fork PRs / non-local repos) → `source: "none"` with an `unavailableReason`; it never guesses an edge. A single WORKFLOW degrades to flat swimlanes (not the whole graph) when a job uses a reusable workflow (`uses:`), has a `${{ }}` `name:`, or the YAML will not parse. Matrix legs collapse into one node whose state is the worst leg (failed > running > queued > passed > skipped); `tier` is a cycle-safe longest-path rank over `needs`; `criticalPath` is the longest-duration chain. Running nodes report live elapsed. Parsed YAML is cached per `(repo, headSha)` behind a TTL; the graph itself is always recomputed from live run state. | | `checkLogParser.ts` | Pure parsing for `prService.getCheckLog`: strips the per-line ISO timestamp, splits a job log on top-level `##[group]` / `##[endgroup]` markers into step sections, selects the failing step's section, and lifts a framework summary headline (vitest/jest/pytest/go) — falling through to `null` rather than guessing. `selectStepSection` returns the section **and** how it chose it (`named-step` / `errored-step` / `whole-log`); when neither a step name nor an `##[error]` identifies one it returns no section, because the previous "last section" fallback resolved to the `Post Run …` cleanup group on any job that passed. `prService` owns the bounded streaming download (the logs endpoint 302s to a pre-signed blob; the redirect is followed without the API token and reading stops past a few MB, setting `truncated`). | | `githubPrStackService.ts` | Native GitHub stack decoding, persistence, and repository reconciliation | @@ -262,7 +262,6 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | `shared/GitHubPrSearchInput.tsx`, `shared/GitHubRepoSyncBar.tsx` | Repo-PR header chrome shared by the GitHub tab and detail views: the magnifying-glass search input and the "syncing…" toolbar that drives manual snapshot refreshes. | | `shared/PrUserAvatar.tsx` | Shared GitHub user avatar with a fallback `UserCircle` glyph for users that don't have a cached avatar URL. Commit rows without a linked GitHub account use the Gravatar identicon URL the service derives from the commit-author email (see `prService.getCommits`), so the CSP allowlist includes `gravatar.com`. | | `shared/PrCommandPalettes.tsx` | `g c` (commits) / `g t` (threads) / `g f` (files) palettes opened by the keyboard chord and by the timeline toolbar | -| `shared/PrAiSummaryCard.tsx` | AI summary card above the timeline; dismissible per PR (state in `PrsContext.dismissedAiSummaries`), with a "Regenerate" action wired to `prSummaryService.regenerateSummary` | | `shared/PrReviewThreadCard.tsx`, `shared/PrBotReviewCard.tsx` | Rich thread cards for the timeline (bot-review collapse, reply box, resolve, `PrReactionBar`, and per-comment Edit via `usePrCommentEdit`) | | `shared/PrDeploymentCard.tsx` | Deployment row used in the status rail and on the timeline | | `shared/PrAiResolverPanel.tsx` | AI resolver launch controls in Rebase/Integration flows, including additional-instructions passthrough | @@ -1964,15 +1963,10 @@ Overview tab is selected): ## AI summary cache -`prSummaryService` generates a `PrAiSummary` (summary text, risk -areas, reviewer hotspots, unresolved concerns) via the AI integration -service when the PR Descriptions model is set in Settings, and caches -it in `pull_request_ai_summaries` keyed by `(pr_id, head_sha)`. An -empty picker returns a deterministic template instead of silently -picking Haiku. Pushing new commits advances `head_sha` -(maintained by `prService.upsertFromGithub`) so the next read misses -and the summary regenerates. `regenerateSummary` forces a rebuild -regardless of cache state. +PR detail no longer shows an AI summary card and `PrsContext` does +not fetch `getAiSummary`. `prSummaryService` and the +`ade.prs.getAiSummary` / `ade.prs.regenerateAiSummary` IPC remain +as unused in-process compatibility until they are deleted. ## Delta polling cursor @@ -1991,12 +1985,11 @@ best-effort — failures log a warning and do not abort the tick. - Workflow surfaces batch PR merge context through `prs.getMergeContexts(prIds)` instead of fanning out one `getMergeContext(prId)` call per card. The service builds the batch from metadata-only lane rows so integration/rebase views do not pay full git status cost on render. - `PrsContext` owns PR list, GitHub stack state, rebase needs, proposals, and the Timeline+Rails UI state - (`timelineFiltersByPrId`, `dismissedAiSummaries`, `viewerLogin`, + (`timelineFiltersByPrId`, `viewerLogin`, `writeViewerLogin`, `detailReviewThreads`, - `detailDeployments`, `detailAiSummary`). It exposes - `setTimelineFilters`, `setAiSummaryDismissed`, - `setViewerLogin` / `setWriteViewerLogin`, and - `regeneratePrAiSummary`. + `detailDeployments`). It exposes + `setTimelineFilters` and + `setViewerLogin` / `setWriteViewerLogin`. - Chat-side PR surfaces (`ChatGitToolbar`, `ChatPrPane`) first scope the cached lane PR set to the selected chat's explicit edges, so one chat can show more than one PR without leaking another chat's PR. Rows with no edge retain the @@ -2095,15 +2088,10 @@ Builder responsibilities: The snapshot is read-only; create/merge/close/comment actions go through the existing command surface (`prs.createFromLane`, -`prs.land`, `prs.close`, `prs.addComment`, `prs.rerunChecks`, -`prs.draftDescription`). The mobile create wizard now creates normal -PRs with `source lane -> target lane` titles and no AI-generated -title/body step; the explicit `prs.draftDescription` action remains -available to callers that request PR-description drafting directly. -AI drafts use the PR Descriptions model from Settings when one is set; -if that picker is empty, ADE returns the deterministic template -instead of silently picking Haiku. `requireAi` callers get a Settings -prompt rather than a stub. +`prs.land`, `prs.close`, `prs.addComment`, `prs.rerunChecks`). +The mobile create wizard creates normal PRs with title prefilled +from the lane name and an optional empty body. There is no +AI-generated title/body step. The mobile client calls `getMobileSnapshot` on open and re-fetches on focus or after a successful mutation. Unmapped GitHub projections are local-only on the host, so webhook changes also emit a tiny `prs_updated` sync invalidation. diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 2b69fe8a2c..f00f90155a 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -548,7 +548,7 @@ a boolean. `aiResolutionStart` - `listProposals`, `getMergeContext`, `getMergeContexts`, `listWithConflicts`, `listSnapshots` -- `createFromLane`, `draftDescription`, `land`, +- `createFromLane`, `land`, `close`, `reopen`, `requestReviewers`, `rerunChecks`, `addComment` - `simulateIntegration`, `commitIntegration`, `listIntegrationWorkflows`, `updateIntegrationProposal`, diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index e70c9a703d..75c21d2aa3 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -49,8 +49,8 @@ and in tests. - `apps/desktop/src/main/services/pty/ptyService.ts` — PTY lifecycle, transcript capture with a 16 MiB physical retention ceiling and lifetime logical byte offsets, runtime - state, AI auto-titles (Settings title model, then stored launch - model; skip AI if both are missing), tool-type routing, continuation-target backfill, + state, AI auto-titles (cheap helper by ADE provider, then stored launch + model, then the deterministic title already on the row), tool-type routing, continuation-target backfill, session-id based write/resize entry points used by mobile sync terminal control, `readTranscriptTail({ sessionId, ... })` which merges the on-disk transcript tail with the live PTY output tail so diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md index 4bccea5641..35892c7673 100644 --- a/docs/features/terminals-and-sessions/pty-and-sessions.md +++ b/docs/features/terminals-and-sessions/pty-and-sessions.md @@ -671,31 +671,21 @@ command argument whenever structured resume metadata is available. ### AI-driven titles -Three paths, all gated by `sessionIntelligence.titles.enabled` and the -presence of an AI integration service in non-guest mode (except the -Claude runtime-title capture, which is free). - -CLI AI titles and terminal summaries try models in this order: the -Settings title/summary model, then `resumeMetadata.launch.model`. -`tryCliAiModels` walks that list and continues on failure. If both -are missing, ADE skips the AI call and keeps the deterministic -title/summary already on the row — it does not throw, pick Haiku, or -use the first available provider. - -- **Output snippet title** (shell, cursor, aider, continue): +CLI sessions are always ADE-named. The cheap helper for the ADE +provider (Haiku 4.5 / GPT-5.6 Luna / Composer 2.5) runs first, then +`resumeMetadata.launch.model`, then the deterministic title already on +the row. ADE does not prefer Claude JSONL `ai-title` or Codex thread +names over that chain. + +- **Output snippet title** (shell and other non-chat CLIs): `aiTitleTimer` fires after 6 s, sends up to 800 chars of ANSI-stripped early output to `aiIntegrationService.summarizeTerminal` with a "max 80 chars, plain text" prompt. -- **Claude runtime-storage title** (claude, claude-orchestrated): - `scheduleClaudeRuntimeTitleCaptureBestEffort` polls Claude's local - JSONL at `~/.claude/projects//.jsonl` for an - `ai-title` or `custom-title` record using - `CLAUDE_TITLE_POLL_DELAYS_MS = [1s, 2.5s, 5s, 12s, 30s, 60s]`. The - ADE prompt summarizer is intentionally skipped for Claude so that - Claude Code's own generated title wins when it arrives, with - `adoptClaudeRuntimeTitle` honouring the `manuallyNamed` flag and - refusing to overwrite a user rename. -- **CLI user title** (codex, cursor-cli, droid, opencode): +- **Claude / Codex native titles**: + `scheduleClaudeRuntimeTitleCaptureBestEffort` and + `scheduleCodexRuntimeTitleCaptureBestEffort` are no-ops. ADE names + the row; JSONL `ai-title` and Codex thread names do not win. +- **CLI user title** (claude, claude-orchestrated, codex, cursor-cli, droid, opencode): `tryCliUserTitleFromWrite` listens to PTY *writes* (keyboard input) and commits the first submitted prompt line (3 to 180 chars). This avoids the alt-screen noise that every interactive agent TUI hides @@ -721,9 +711,9 @@ use the first available provider. At session close, when `refreshOnComplete` is enabled, the transcript tail (last 2000 chars) is re-summarized into a final title through the -same setting-then-launch-model walk. Failure logs a warn and moves on — -the title contract never fails the session. End-of-session summaries -use the same walk against `sessionIntelligence.summaries.modelId`. +same cheap-helper-then-launch-model walk. Failure logs a warn and moves on — +the title contract never fails the session. End-of-session AI summaries +are not generated; the row keeps its deterministic `summary` fallback. ### Continuation metadata backfill diff --git a/docs/features/workspace-graph/README.md b/docs/features/workspace-graph/README.md index 6f3c9bdcc1..7f7a64aa01 100644 --- a/docs/features/workspace-graph/README.md +++ b/docs/features/workspace-graph/README.md @@ -46,7 +46,6 @@ Core renderer files (`apps/desktop/src/renderer/components/graph/`): | `graphNodes/LaneNode.tsx` | Lane node rendering (badges, status, PR overlay) plus the inline per-lane agent dashboard (`LaneAgentList`) when `GraphNodeData.agents` is populated | | `graphNodes/ProposalNode.tsx` | Integration proposal node rendering | | `graphEdges/RiskEdge.tsx` | Edge renderer with risk-level coloring and animations | -| `graphDialogs/ConflictPanel.tsx` | Inline conflict resolution panel for edge clicks | | `shared/RiskMatrix.tsx` | Project-wide pairwise risk grid with animations | | `shared/RiskTooltip.tsx` | Hover detail for a matrix cell | @@ -62,7 +61,7 @@ Lane topology (parent-child stack relationships) + Conflict-risk overlays (edges colored by pairwise risk) + PR overlays (per-lane badges, edge coloring) + Sync and activity signals (dots/chips) -+ Merge simulation entry points (edge clicks open ConflictPanel) ++ Merge simulation entry points (edge clicks run `simulateMerge`) + Integration proposal nodes (with "Fed By" source chips) ``` @@ -257,8 +256,9 @@ so the extra spoke would just add clutter. keyed by view mode. - **Node click** — select lane; context-dependent side panel updates. -- **Edge click** — open `ConflictPanel` with merge simulation + - overlapping file list + AI proposal apply flow. +- **Edge click** — run merge simulation (`simulateMerge`) and show + the clean/conflict outcome plus overlapping files. Graph PR create + prefills the title from the lane name; the body is optional markdown. - **Right-click / context menu** — reparent, archive, delete, create child, view diff, open terminal. - **Collapse/expand** — `collapsedLaneIds[]` hides descendants; the @@ -274,7 +274,7 @@ so the extra spoke would just add clutter. - `` — standard React Flow minimap. - `` for the dot grid. - Custom `` regions for filters, zoom controls, and the - active `ConflictPanel` / `PrDetailPane` overlays. + `PrDetailPane` overlay. ## Refresh cadence @@ -323,32 +323,11 @@ Activity scoring prioritizes: `activityBucket` (on each node): `"min" | "low" | "medium" | "high"`. Drives node size and shadow intensity in `LaneNode`. -## Conflict panel +## Merge simulation -`graphDialogs/ConflictPanel.tsx` is the inline resolution UI: - -- Header with lane A ↔ lane B names. -- Merge simulation outcome (clean / conflict / count). -- Overlapping files list (from `overlapFilesByPair` map). -- "Apply to" lane selector (target branch chooser). -- AI proposal flow: prepare → request → apply with mode selector - (`unstaged | staged | commit`) + optional commit message. - -Data wiring: - -```ts -props: { - conflictPanel: ConflictPanelState; - setConflictPanel: React.Dispatch<…>; - laneById: Map; - overlapFilesByPair: Map; - refreshRiskBatch: () => Promise; - refreshLanes: () => Promise; -} -``` - -The panel issues IPC calls directly to `ade.conflicts.simulateMerge`, -`.prepareProposal`, `.requestProposal`, `.applyProposal`. +Edge clicks and Risk Matrix pair selection run `ade.conflicts.simulateMerge` +and show the clean/conflict outcome plus overlapping files. There is no +AI proposal flyout on Graph. ## Risk matrix diff --git a/docs/features/workspace-graph/data-sources.md b/docs/features/workspace-graph/data-sources.md index 6d07392959..6b38e6ab1f 100644 --- a/docs/features/workspace-graph/data-sources.md +++ b/docs/features/workspace-graph/data-sources.md @@ -161,7 +161,8 @@ Several derived maps are memoized: empty-lanes case by returning `primary: null` with empty maps so derivation can short-circuit safely. - `overlapFilesByPair` — `Map` from - `batch.overlaps`; used by `ConflictPanel`. + `batch.overlaps`; used by drag-to-integrate overlap lists and + merge-simulation overlays. - `integrationSourcesByLaneId` — built via `buildIntegrationSourcesByLaneId(lanes)` from `renderer/lib/integrationLanes.ts`; used to annotate integration @@ -250,7 +251,7 @@ rewrites on next save. | Reparent a lane | `refreshLanes`, `scheduleRefreshActivity(_, {includeOperations:false})` | | Create/delete lane | `refreshLanes`, re-run auto-layout for fresh node | | Apply AI proposal | `refreshRiskBatch`, `refreshLanes` | -| Run merge simulation | No refresh (inline panel state only) | +| Run merge simulation | No refresh (inline overlay state only) | | Change view mode | Recompute auto-layout if positions missing; resets `showOverviewRiskEdges` to `false` | | Toggle "Show overlap web" (Overview only) | No IPC; local boolean drives risk-edge render gate | | Change filters | No refresh; local dimmed/highlight recalculation | @@ -282,8 +283,8 @@ Intentional omissions: - **Do not assume `batch.overlaps` covers every pair.** In prefilter mode (over 15 lanes), only likely-conflict pairs are in the map. `overlapFilesByPair` falls back to an empty list - for missing pairs and the `ConflictPanel` handles the empty - case gracefully. + for missing pairs; edge-click merge simulation still runs + `simulateMerge` and paints the overlay. - **PR refresh is intentionally delayed 4 s.** Earlier than that and PR data blocks topology paint on slow projects. Later than that and the user perceives "PR overlays never load." Keep From 786cc46ffcda95855912efb741044e66e0cd5b78 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:04:53 -0400 Subject: [PATCH 2/3] fix(desktop): align naming tests and keep OpenCode off Claude helpers Shard 8 still expected Settings models and Claude JSONL titles. Ignore toolType when an explicit non-ADE provider is set, reap status-note timestamps, and keep the scheduled-work toggle unavailable until config loads. Co-authored-by: Cursor --- .../main/services/ai/aiIntegrationService.ts | 1 + .../services/chat/agentChatService.test.ts | 32 ++++++----- .../main/services/chat/sessionNaming.test.ts | 17 ++++++ .../src/main/services/chat/sessionNaming.ts | 3 +- .../src/main/services/pty/ptyService.test.ts | 53 ++++++++----------- .../services/sessions/sessionService.test.ts | 6 +++ .../main/services/sessions/sessionService.ts | 3 +- .../settings/AiFeaturesSection.test.tsx | 14 +++++ .../components/settings/AiFeaturesSection.tsx | 25 +++++++-- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 5 +- 10 files changed, 109 insertions(+), 50 deletions(-) diff --git a/apps/desktop/src/main/services/ai/aiIntegrationService.ts b/apps/desktop/src/main/services/ai/aiIntegrationService.ts index ca5c7e20b9..49f2567bcf 100644 --- a/apps/desktop/src/main/services/ai/aiIntegrationService.ts +++ b/apps/desktop/src/main/services/ai/aiIntegrationService.ts @@ -1707,6 +1707,7 @@ export function createAiIntegrationService(args: { throw new Error("No AI provider is available. Install and authenticate Claude Code and/or Codex CLI."); } + // Titles and commit messages always run. Settings no longer gates them. if (args.taskType !== "session_title" && args.taskType !== "commit_message" && !getFeatureFlag(args.feature)) { logger.warn("ai.task.skipped_feature_disabled", { requestId, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 9877256557..cef6713c5d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -13638,15 +13638,15 @@ describe("createAgentChatService", () => { && event.event.status === "spawn_completion_delivery_failed" )).toBe(true); }, { timeout: 2_500 }); - expect(logger.warn).toHaveBeenCalledTimes(3); - expect(logger.warn).toHaveBeenLastCalledWith( - "agent_chat.spawn_completion_delivery_failed", - expect.objectContaining({ - childSessionId: child.id, - parentSessionId: parent.id, - attempt: 3, - }), + const deliveryWarnings = logger.warn.mock.calls.filter( + ([message]) => message === "agent_chat.spawn_completion_delivery_failed", ); + expect(deliveryWarnings).toHaveLength(3); + expect(deliveryWarnings[2]?.[1]).toEqual(expect.objectContaining({ + childSessionId: child.id, + parentSessionId: parent.id, + attempt: 3, + })); }); it("rejects the legacy silent spawn type for new child chats", async () => { @@ -16024,6 +16024,8 @@ describe("createAgentChatService", () => { const { service, sessionService } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), + // These tests prove native Claude titles win during the wait window. + nativeTitleWaitMs: 250, projectConfigService: { get: vi.fn(() => ({ effective: { @@ -16069,9 +16071,8 @@ describe("createAgentChatService", () => { info: { summary: prompt, firstPrompt: prompt }, firstPrompt: prompt, }); - // Give the fire-and-forget adopt a beat, then confirm the title stayed default. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(sessionService.get(session.id)?.title).toBe("Claude Chat"); + // Echoed SDK summaries are skipped; ADE names the chat after the wait. + await waitForSessionTitle(sessionService, session.id, "Fix Update Modal Flow"); }); it("does not adopt when the session is manually named", async () => { @@ -17944,10 +17945,15 @@ describe("createAgentChatService", () => { event.event.type === "done", ); - // Give auto-title a chance to fire (it's a void promise) + // Give auto-title / idle status-line a chance to fire (void promises) await new Promise((resolve) => setTimeout(resolve, 50)); - expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); + expect(sessionService.get(session.id)?.title).toBe("My Title"); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled(); + for (const [args] of aiIntegrationService.summarizeTerminal.mock.calls) { + expect(args.prompt).toContain("Write a short statusLine"); + expect(args.systemPrompt).toContain("Copy these current values unchanged: chatTitle, laneName"); + } }); it("does not clobber a manual rename that lands while auto-titling is in flight", async () => { diff --git a/apps/desktop/src/main/services/chat/sessionNaming.test.ts b/apps/desktop/src/main/services/chat/sessionNaming.test.ts index 11fc4bcdcc..d8aa88941e 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.test.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.test.ts @@ -122,6 +122,23 @@ describe("buildSessionIntelligenceModelCandidates", () => { })).toEqual([ANTHROPIC_MODELS[0]?.id]); }); + it("does not consult toolType when an explicit non-ADE provider is set", () => { + expect(buildSessionIntelligenceModelCandidates({ + availableModels: ALL_MODELS, + provider: "opencode", + toolType: "claude-chat", + sessionModelId: ANTHROPIC_MODELS[0]?.id, + })).toEqual([ANTHROPIC_MODELS[0]?.id]); + }); + + it("uses toolType only when provider is absent", () => { + expect(buildSessionIntelligenceModelCandidates({ + availableModels: ALL_MODELS, + toolType: "claude-chat", + sessionModelId: OPENAI_MODELS[0]?.id, + })).toEqual([BACKGROUND_UTILITY_CLAUDE_MODEL_ID, OPENAI_MODELS[0]?.id]); + }); + it("injects Composer 2.5 for Cursor even when the auth snapshot has no Cursor inventory", () => { expect(buildSessionIntelligenceModelCandidates({ availableModels: [], diff --git a/apps/desktop/src/main/services/chat/sessionNaming.ts b/apps/desktop/src/main/services/chat/sessionNaming.ts index c16838ba54..57a44626be 100644 --- a/apps/desktop/src/main/services/chat/sessionNaming.ts +++ b/apps/desktop/src/main/services/chat/sessionNaming.ts @@ -519,8 +519,9 @@ export function buildSessionIntelligenceModelCandidates(args: { sessionModelId?: string | null; sessionModel?: string | null; }): string[] { + const hasProvider = typeof args.provider === "string" && args.provider.trim().length > 0; const utilityProvider = adeBackgroundUtilityProvider(args.provider) - ?? adeBackgroundUtilityProviderFromToolType(args.toolType); + ?? (hasProvider ? null : adeBackgroundUtilityProviderFromToolType(args.toolType)); const cheapModelId = utilityProvider ? backgroundUtilityModelId(utilityProvider) : null; const preferred = [cheapModelId, args.sessionModelId, args.sessionModel]; return buildNamingModelCandidates({ diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 2af019cb1d..8be0387a1d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -355,6 +355,11 @@ import { EARLY_CLI_AI_TITLE_DELAY_MS, selectPiStorageSessionCandidate, } from "./ptyService"; +import { + BACKGROUND_UTILITY_CLAUDE_MODEL_ID, + BACKGROUND_UTILITY_CODEX_MODEL_ID, + BACKGROUND_UTILITY_CURSOR_MODEL_ID, +} from "../../../shared/backgroundUtilityModel"; import { resolveBuiltInBrowserActorCapability } from "../builtInBrowser/builtInBrowserActorCapabilities"; import { claudeConfigHome } from "../shared/providerConfigHomes"; @@ -2933,7 +2938,7 @@ describe("ptyService", () => { expect.objectContaining({ prompt: expect.stringContaining("print cwd"), taskType: "session_title", - model: "openai/gpt-5.4", + model: BACKGROUND_UTILITY_CODEX_MODEL_ID, }), ); } finally { @@ -6103,12 +6108,12 @@ describe("ptyService", () => { }); it.each([ - ["claude", "Claude Code"], - ["codex", "Codex session"], - ["cursor-cli", "Cursor Agent CLI"], - ["droid", "Factory Droid CLI"], - ["opencode", "OpenCode CLI"], - ] as const)("generates %s titles from the first submitted PTY write using the bound cwd", async (toolType, title) => { + ["claude", "Claude Code", BACKGROUND_UTILITY_CLAUDE_MODEL_ID], + ["codex", "Codex session", BACKGROUND_UTILITY_CODEX_MODEL_ID], + ["cursor-cli", "Cursor Agent CLI", BACKGROUND_UTILITY_CURSOR_MODEL_ID], + ["droid", "Factory Droid CLI", "openai/gpt-5.4"], + ["opencode", "OpenCode CLI", "openai/gpt-5.4"], + ] as const)("generates %s titles from the first submitted PTY write using the bound cwd", async (toolType, title, expectedModel) => { vi.useFakeTimers(); try { mocks.existsSyncResults.set("/tmp/test-worktree/subdir", true); @@ -6157,7 +6162,7 @@ describe("ptyService", () => { cwd: "/tmp/test-worktree/subdir", prompt: expect.stringContaining("Fix the flaky login tests"), timeoutMs: PTY_AI_TITLE_TIMEOUT_MS, - model: "openai/gpt-5.4", + model: expectedModel, }), ); } finally { @@ -6269,7 +6274,7 @@ describe("ptyService", () => { expect(sessionService.get(createdSessionId)?.goal).toBe("/this is a test"); }); - it("adopts Claude Code runtime ai-title from local session storage", async () => { + it("does not overlay Claude Code JSONL ai-title onto an ADE-named CLI session", async () => { vi.useFakeTimers(); try { const claudeSessionId = "123e4567-e89b-12d3-a456-426614174000"; @@ -6306,12 +6311,11 @@ describe("ptyService", () => { await vi.advanceTimersByTimeAsync(1_000); - expect(sessionService.get(createdSessionId)?.title).toBe("Test session setup"); - expect(sessionService.updateMeta).toHaveBeenCalledWith( + expect(sessionService.get(createdSessionId)?.title).toBe("This is a test"); + expect(sessionService.updateMeta).not.toHaveBeenCalledWith( expect.objectContaining({ sessionId: createdSessionId, title: "Test session setup", - manuallyNamed: false, }), ); } finally { @@ -6421,7 +6425,7 @@ describe("ptyService", () => { expect.objectContaining({ prompt: expect.stringContaining("Fix the flaky login tests"), taskType: "session_title", - model: "anthropic/claude-sonnet-5", + model: BACKGROUND_UTILITY_CLAUDE_MODEL_ID, }), ); expect(sessionService.get(createdSessionId)?.title).toBe("ADE generated title"); @@ -7122,15 +7126,10 @@ describe("ptyService", () => { expect(aiIntegrationService.summarizeTerminal).not.toHaveBeenCalled(); }); - it("walks from the titles/summaries setting to the launch model when the setting fails", async () => { + it("uses the launch model for a generic terminal even when Settings still names a helper", async () => { const aiIntegrationService = { getMode: vi.fn(() => "subscription"), - summarizeTerminal: vi.fn(async ({ model }: { model?: string }) => { - if (model === "anthropic/claude-haiku-4-5") { - throw new Error("quota exceeded"); - } - return { text: "Used launch model" }; - }), + summarizeTerminal: vi.fn(async () => ({ text: "Used launch model" })), }; const { service, mockPty } = createHarness({ aiIntegrationService, @@ -7166,14 +7165,7 @@ describe("ptyService", () => { expect.objectContaining({ model: "openai/gpt-5.4" }), ); }); - expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ model: "anthropic/claude-haiku-4-5" }), - ); - expect(aiIntegrationService.summarizeTerminal).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ model: "openai/gpt-5.4" }), - ); + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledTimes(1); }); it("persists the runtime launch model onto existing session resume metadata", async () => { @@ -8890,7 +8882,7 @@ describe("ptyService", () => { } }); - it("captures Claude runtime titles for sessions that already have a resume target", async () => { + it("does not capture Claude JSONL titles for sessions that already have a resume target", async () => { vi.useFakeTimers(); try { const claudeSessionId = "5647da1e-10de-4089-bce2-00b9c2552bfc"; @@ -8927,10 +8919,9 @@ describe("ptyService", () => { await service.ensureResumeTargets(["session-claude-existing"]); await vi.advanceTimersByTimeAsync(0); - expect(sessionService.updateMeta).toHaveBeenCalledWith(expect.objectContaining({ + expect(sessionService.updateMeta).not.toHaveBeenCalledWith(expect.objectContaining({ sessionId: "session-claude-existing", title: "Patched exit works", - manuallyNamed: false, })); } finally { vi.useRealTimers(); diff --git a/apps/desktop/src/main/services/sessions/sessionService.test.ts b/apps/desktop/src/main/services/sessions/sessionService.test.ts index c80aa1e39b..4d35a45561 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.test.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.test.ts @@ -369,8 +369,14 @@ describe("sessionService resume metadata", () => { }); expect(service.get("session-delete")?.id).toBe("session-delete"); + service.setStatusNote("session-delete", "working on delete"); + expect(service.getStatusNoteUpdatedAt("session-delete")).toBeTruthy(); + expect(service.getStatusNoteUpdatedAt(" session-delete ")).toBe( + service.getStatusNoteUpdatedAt("session-delete"), + ); expect(service.deleteSession("session-delete")).toBe(true); expect(service.get("session-delete")).toBeNull(); + expect(service.getStatusNoteUpdatedAt("session-delete")).toBeNull(); expect(service.deleteSession("session-delete")).toBe(false); activeDisposers.push(async () => db.close()); diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index 4896b23c12..e3200c8547 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -2125,7 +2125,7 @@ export function createSessionService({ }, getStatusNoteUpdatedAt(sessionId: string): string | null { - return statusNoteUpdatedAtById.get(sessionId) ?? null; + return statusNoteUpdatedAtById.get(sessionId.trim()) ?? null; }, /** @@ -2216,6 +2216,7 @@ export function createSessionService({ ); if (!existing) return false; db.run("delete from terminal_sessions where id = ?", [trimmed]); + statusNoteUpdatedAtById.delete(trimmed); // Reap the lifecycle token with its row. ADE has been bitten before by a // local table with no reaper, and every other session-keyed side table is // already cascaded here. diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx index 304eef7a71..1b9ac0cb72 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx @@ -130,4 +130,18 @@ describe("AiFeaturesSection", () => { expect(await screen.findByText(/Scheduled work is unavailable: scheduler offline/)).toBeTruthy(); expect(screen.queryByText("No active durable jobs.")).toBeNull(); }); + + it("does not enable the pause toggle when project configuration fails to load", async () => { + installAdeMocks(); + (window as any).ade.projectConfig.get.mockRejectedValueOnce(new Error("config unavailable")); + + render( + + + , + ); + + expect(await screen.findByText(/Couldn't load AI features/)).toBeTruthy(); + expect(screen.queryByText("Pause all scheduled work")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index 8d72675f9f..9c026cdcae 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -48,14 +48,20 @@ function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean export function AiFeaturesSection() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); + const [configLoadFailed, setConfigLoadFailed] = useState(false); const [scheduledWorkPaused, setScheduledWorkPaused] = useState(false); const [scheduledWork, setScheduledWork] = useState([]); const [scheduledWorkError, setScheduledWorkError] = useState(null); const loadStatus = useCallback(async () => { try { - const [snapshot, scheduledWorkResult] = await Promise.all([ - window.ade.projectConfig.get(), + const [snapshotResult, scheduledWorkResult] = await Promise.all([ + window.ade.projectConfig.get() + .then((snapshot) => ({ snapshot, error: null as string | null })) + .catch((error) => ({ + snapshot: null, + error: error instanceof Error ? error.message : String(error), + })), window.ade.agentChat.listScheduledWork() .then((items) => ({ items, error: null as string | null })) .catch((error) => ({ @@ -65,8 +71,13 @@ export function AiFeaturesSection() { ]); setScheduledWork(scheduledWorkResult.items); setScheduledWorkError(scheduledWorkResult.error); + if (!snapshotResult.snapshot) { + setConfigLoadFailed(true); + return; + } + setConfigLoadFailed(false); - const effectiveAiRaw = snapshot.effective?.ai; + const effectiveAiRaw = snapshotResult.snapshot.effective?.ai; const effectiveAi = effectiveAiRaw && typeof effectiveAiRaw === "object" ? (effectiveAiRaw as AiConfig) : null; setScheduledWorkPaused(effectiveAi?.chat?.scheduledWorkPaused === true); } finally { @@ -117,6 +128,14 @@ export function AiFeaturesSection() { ); } + if (configLoadFailed) { + return ( +
+ Couldn't load AI features. The scheduled-work pause stays unavailable until configuration loads. +
+ ); + } + const featureRowHoverCss = `.ai-feature-row:hover { background: ${COLORS.hoverBg}; }`; return ( diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 29ab888ca6..cda08d4ef3 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -107,6 +107,7 @@ struct WorkRootSessionPresentationTaskKey: Equatable { /// so without this the row stays parked in the Snoozed tail until some /// unrelated change happens to rebuild the presentation. let snoozeEpoch: Int + let pinnedLaneIdsStorage: String } struct WorkRootScreen: View { @@ -473,6 +474,7 @@ struct WorkRootScreen: View { next.insert(lane.id) } pinnedLaneIdsStorage = next.sorted().joined(separator: ",") + scheduleSessionPresentationRebuild() } /// Machines that own work in this project and are no longer reachable. The @@ -510,7 +512,8 @@ struct WorkRootScreen: View { activeProjectId: syncService.activeProjectId, loadedProjectionProjectId: loadedProjectionProjectId, pendingLaneDeletionIds: syncService.pendingLaneDeletionIds, - snoozeEpoch: snoozeEpoch + snoozeEpoch: snoozeEpoch, + pinnedLaneIdsStorage: pinnedLaneIdsStorage ) } From 277d38930030a0ffdb0508d39b90a141d34f1fc6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:29:43 -0400 Subject: [PATCH 3/3] fix(desktop): type the idle status-line spy in chat tests The mock.calls loop failed typecheck because Vitest types an unparameterized spy as an empty tuple. Co-authored-by: Cursor --- .../src/main/services/chat/agentChatService.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index cef6713c5d..6169a105f8 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -17950,10 +17950,12 @@ describe("createAgentChatService", () => { expect(sessionService.get(session.id)?.title).toBe("My Title"); expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalled(); - for (const [args] of aiIntegrationService.summarizeTerminal.mock.calls) { - expect(args.prompt).toContain("Write a short statusLine"); - expect(args.systemPrompt).toContain("Copy these current values unchanged: chatTitle, laneName"); - } + expect(aiIntegrationService.summarizeTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: expect.stringContaining("Write a short statusLine"), + systemPrompt: expect.stringContaining("Copy these current values unchanged: chatTitle, laneName"), + }), + ); }); it("does not clobber a manual rename that lands while auto-titling is in flight", async () => {