diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 9b0a484a8..30f68e322 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2459,21 +2459,58 @@ describe("adeRpcServer", () => { closeLinearIssueOnMerge: true, }); - const drafted = await callTool(handler, "create_pr_from_lane", { + const defaulted = await callTool(handler, "create_pr_from_lane", { laneId: "lane-1", baseBranch: "main", }); - expect(drafted?.isError).toBeUndefined(); - expect(fixture.runtime.prService.draftDescription).toHaveBeenCalledWith({ + expect(defaulted?.isError).toBeUndefined(); + expect(fixture.runtime.prService.draftDescription).not.toHaveBeenCalled(); + expect(fixture.runtime.prService.createFromLane).toHaveBeenLastCalledWith({ laneId: "lane-1", baseBranch: "main", + title: "Lane 1 -> main", + body: "", + draft: false, closeLinearIssueOnMerge: true, }); + + (fixture.runtime.laneService.list as any).mockResolvedValueOnce([ + { + id: "primary", + name: "Primary", + laneType: "primary", + parentLaneId: null, + baseRef: "main", + branchRef: "main", + archivedAt: null, + }, + { + id: "parent-lane", + name: "Parent", + laneType: "worktree", + parentLaneId: null, + baseRef: "main", + branchRef: "feature/parent", + archivedAt: null, + }, + { + id: "child-lane", + name: "Child", + laneType: "worktree", + parentLaneId: "parent-lane", + baseRef: "main", + branchRef: "feature/child", + archivedAt: null, + }, + ]); + const stackedDefaulted = await callTool(handler, "create_pr_from_lane", { + laneId: "child-lane", + }); + expect(stackedDefaulted?.isError).toBeUndefined(); expect(fixture.runtime.prService.createFromLane).toHaveBeenLastCalledWith({ - laneId: "lane-1", - baseBranch: "main", - title: "Drafted PR", - body: "Drafted body", + laneId: "child-lane", + title: "Child -> Parent", + body: "", draft: false, closeLinearIssueOnMerge: true, }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 1097c158c..bd1583613 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -24,6 +24,7 @@ import { resolvePathWithinRoot } from "../../desktop/src/main/services/shared/ut import { getDefaultModelDescriptor } from "../../desktop/src/shared/modelRegistry"; import { buildAdeCliInlineGuidance } from "../../desktop/src/shared/adeCliGuidance"; import { buildDeeplink, isValidCommitSha, isValidRepoRelativePath } from "../../desktop/src/shared/deeplinks"; +import { resolveStableLaneBaseBranch } from "../../desktop/src/shared/laneBaseResolution"; import { ADE_AGENT_SKILLS_DIRS_ENV, getAdeAgentSkillRootsForPrompt, @@ -979,7 +980,7 @@ const TOOL_SPECS: ToolSpec[] = [ }, { name: "create_pr_from_lane", - description: "Create a PR from a lane branch. Drafts a title/body from ADE context when omitted. Returns GitHub and ADE PR URLs when available.", + description: "Create a PR from a lane branch. When omitted, the title defaults to \"source lane -> target lane\" and the body is empty. Returns GitHub and ADE PR URLs when available.", inputSchema: { type: "object", required: ["laneId"], @@ -2019,6 +2020,46 @@ function resolveLaneWorktreePath(runtime: AdeRuntime, laneId: string | null | un return null; } +function branchNameForPrTitle(ref: string | null | undefined): string { + let value = (ref ?? "").trim(); + value = value.replace(/^refs\/heads\//, ""); + value = value.replace(/^refs\/remotes\//, ""); + value = value.replace(/^origin\//, ""); + return value; +} + +async function defaultPrTitleForLane(runtime: AdeRuntime, laneId: string, baseBranch?: string | null): Promise { + const lanes = await runtime.laneService.list({ includeArchived: false, includeStatus: false }).catch(() => []); + const sourceLane = lanes.find((lane) => lane.id === laneId) ?? null; + const laneInfo = (() => { + try { + return typeof runtime.laneService.getLaneBaseAndBranch === "function" + ? runtime.laneService.getLaneBaseAndBranch(laneId) + : null; + } catch { + return null; + } + })(); + const sourceName = asOptionalTrimmedString(sourceLane?.name) || laneId; + const parentLane = sourceLane?.parentLaneId + ? lanes.find((lane) => lane.id === sourceLane.parentLaneId) ?? null + : null; + const primaryLane = lanes.find((lane) => lane.laneType === "primary") ?? null; + const stableBaseBranch = sourceLane + ? resolveStableLaneBaseBranch({ + lane: sourceLane, + parent: parentLane, + primaryBranchRef: primaryLane?.branchRef ?? runtime.project?.baseRef ?? "main", + }) + : laneInfo?.baseRef || runtime.project?.baseRef || "main"; + const targetBranch = branchNameForPrTitle(baseBranch || stableBaseBranch || laneInfo?.baseRef || runtime.project?.baseRef || "main"); + const targetLane = targetBranch + ? lanes.find((lane) => lane.id !== laneId && branchNameForPrTitle(lane.branchRef) === targetBranch) + : null; + const targetName = asOptionalTrimmedString(targetLane?.name) || targetBranch || "target"; + return `${sourceName} -> ${targetName}`; +} + function buildAdeInlineGuidanceForLane(laneWorktreePath: string | null | undefined): string { return buildAdeCliInlineGuidance(getAdeAgentSkillRootsForPrompt({ cwd: laneWorktreePath ?? undefined })); } @@ -4469,15 +4510,8 @@ async function runTool(args: { let title = asOptionalTrimmedString(toolArgs.title); let body = typeof toolArgs.body === "string" ? toolArgs.body : null; const closeLinearIssueOnMerge = asBoolean(toolArgs.closeLinearIssueOnMerge, true); - if (!title || body == null) { - const draft = await prSvc.draftDescription({ - laneId, - ...(baseBranch ? { baseBranch } : {}), - ...(closeLinearIssueOnMerge ? { closeLinearIssueOnMerge } : {}), - }); - title = title || asOptionalTrimmedString(draft.title) || `PR for ${laneId}`; - body = body ?? asOptionalTrimmedString(draft.body) ?? ""; - } + if (!title) title = await defaultPrTitleForLane(runtime, laneId, baseBranch); + if (body == null) body = ""; const draft = asBoolean(toolArgs.draft, false); const pr = await prSvc.createFromLane({ laneId, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index d88bcda5f..9760b3349 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3467,7 +3467,7 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio return await args.ptyService.create({ laneId: parsed.laneId, title: parsed.title, - ...(parsed.toolType === "shell" || !parsed.startupCommand ? {} : { startupCommand: parsed.startupCommand }), + ...(parsed.startupCommand ? { startupCommand: parsed.startupCommand } : {}), tracked: parsed.tracked ?? true, cols: parsed.cols ?? 120, rows: parsed.rows ?? 36, diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 4718c1e7a..44d450f4e 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -12,6 +12,7 @@ import { renderChatVisibleSelectionRows, renderChatVisibleSelectionRowsFromRows, selectedTextFromChatRows, + workFileDiffKey, workGroupExpandKey, } from "../components/ChatView"; import { aggregateChatBlocks } from "../aggregate"; @@ -1240,6 +1241,16 @@ describe("ChatView", () => { const expanded = renderEvents(events, { width: 120, expanded: true }); expect(expanded).toContain("early.ts"); expect(expanded).toContain("recent.ts"); + expect(expanded).toContain("diff"); + + const rows = renderChatVisibleSelectionRows({ + events, + notices: [], + activeSession: session, + width: 120, + expandedLineIds: new Set([workGroupExpandKey(chatEventLineId(events[0]!, 0))]), + }); + expect(rows.some((row) => row.actionId === workFileDiffKey(chatEventLineId(events[0]!, 0), "f1"))).toBe(true); }); it("tags a collapsed work-group header with an expandable click-target id", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts index b39141a81..bf9af5986 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -74,6 +74,7 @@ describe("aggregateChatBlocks typed groups", () => { kind: "modify", additions: 1, deletions: 1, + diff: "+added line\n-removed line", status: "ok", }); expect(fileGroup!.entries[1]).toMatchObject({ diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index b73484a55..42a4a38a7 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -86,6 +86,7 @@ import { promptTextForTerminal, clipboardImageCacheRootForRuntime, uploadClipboardImageAttachmentToRuntime, + defaultPrTitleForLane, } from "../app"; import { isTerminalSessionResumable } from "../closedCliSessions"; import { @@ -633,6 +634,49 @@ describe("lane worktree availability", () => { }); }); +describe("PR title defaults", () => { + function laneForPrTitle(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "Feature", + laneType: "worktree", + baseRef: "main", + branchRef: "feature", + worktreePath: "/tmp/feature", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-05-20T00:00:00.000Z", + ...overrides, + }; + } + + it("uses the parent lane branch when defaulting stacked lane PR titles", () => { + const parent = laneForPrTitle({ + id: "lane-parent", + name: "Parent", + branchRef: "feature/parent", + worktreePath: "/tmp/parent", + }); + const child = laneForPrTitle({ + id: "lane-child", + name: "Child", + branchRef: "feature/child", + parentLaneId: parent.id, + baseRef: "main", + worktreePath: "/tmp/child", + }); + + expect(defaultPrTitleForLane(child, [parent, child])).toBe("Child -> Parent"); + }); +}); + describe("right pane context defaults", () => { function laneForContext(overrides: Partial = {}): LaneSummary { return { diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 56e374da1..ebca797a2 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -38,6 +38,7 @@ export type FileChangeEntry = { status: WorkToolStatus; additions: number; deletions: number; + diff: string; deleted?: boolean; }; @@ -282,6 +283,7 @@ function appendFileChangeEvent( existing.kind = event.kind; existing.additions = additions; existing.deletions = deletions; + existing.diff = event.diff; if (deleted) existing.deleted = true; return; } @@ -292,6 +294,7 @@ function appendFileChangeEvent( status, additions, deletions, + diff: event.diff, }; if (deleted) entry.deleted = true; block.entries.push(entry); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 00d9087a0..f6f13983c 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -10,6 +10,7 @@ import { resolveModelDescriptor, resolveProviderGroupForModel, } from "../../../desktop/src/shared/modelRegistry"; +import { resolveStableLaneBaseBranch } from "../../../desktop/src/shared/laneBaseResolution"; import { LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, resolveClaudeCliModelForLaunch } from "../../../desktop/src/shared/cliLaunch"; import { getAgentSkillRootCandidates } from "../../../desktop/src/shared/agentSkillRoots"; import { @@ -132,6 +133,7 @@ import { } from "./newLaneForm"; import { ChatView, + workFileDiffKey, chatScrollMaxOffsetFromSelectableRows, hasConversationContent, renderChatSelectableRows, @@ -1132,6 +1134,31 @@ function reparentTargetsForLane(lane: LaneSummary, lanes: LaneSummary[]): LaneSu }); } +function prBranchNameFromRef(ref: string | null | undefined): string { + let value = (ref ?? "").trim(); + value = value.replace(/^refs\/heads\//, ""); + value = value.replace(/^refs\/remotes\//, ""); + value = value.replace(/^origin\//, ""); + return value; +} + +export function defaultPrTitleForLane(sourceLane: LaneSummary | null | undefined, lanes: LaneSummary[]): string { + const sourceName = sourceLane?.name?.trim() || "Source lane"; + const parentLane = sourceLane?.parentLaneId + ? lanes.find((lane) => lane.id === sourceLane.parentLaneId) ?? null + : null; + const targetBranch = resolveStableLaneBaseBranch({ + lane: sourceLane, + parent: parentLane, + primaryBranchRef: "main", + }); + const targetLane = targetBranch + ? lanes.find((lane) => lane.id !== sourceLane?.id && prBranchNameFromRef(lane.branchRef) === targetBranch) + : null; + const targetName = targetLane?.name?.trim() || targetBranch || "target"; + return `${sourceName} -> ${targetName}`; +} + function resolveLaneReference(lanes: LaneSummary[], reference: string): LaneSummary | null { const normalized = reference.trim().toLowerCase(); if (!normalized) return null; @@ -4555,6 +4582,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }), [activeSession, displayEvents, displayNotices, displayPendingSteers, expandedLineIds], ); + const displayBlocksRef = useRef([]); + useEffect(() => { + displayBlocksRef.current = displayBlocks; + }, [displayBlocks]); const displayStreaming = selectedAgentSnapshot ? selectedAgentSnapshot.status === "running" : streaming; const displayInterrupted = selectedAgentSnapshot ? false : interrupted && !displayStreaming; useEffect(() => { @@ -9203,7 +9234,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setRightPane({ kind: "details", title: "PR", - body: `No PR is linked to this lane yet.\n${ahead > 0 ? `${ahead} commit${ahead === 1 ? "" : "s"} ahead of base.\n` : ""}Run /pr open to create a draft.`, + body: `No PR is linked to this lane yet.\n${ahead > 0 ? `${ahead} commit${ahead === 1 ? "" : "s"} ahead of base.\n` : ""}Run /pr open to create a pull request.`, }); return; } @@ -9241,12 +9272,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } if (!args) { + const defaultTitle = defaultPrTitleForLane(activeLane, lanes); openForm({ kind: "form", title: "Open PR", command: "pr-open", fields: [ - { name: "title", label: "Title", required: true, placeholder: activeLane?.name ?? "Draft PR" }, + { name: "title", label: "Title", required: true, placeholder: defaultTitle, initialValue: defaultTitle }, { name: "body", label: "Body", placeholder: "Optional" }, ], }); @@ -9256,7 +9288,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, laneId, title: args, body: "", - draft: true, + draft: false, }); setRightPane({ kind: "details", title: "PR open", body: formatPrSummary(created) }); return; @@ -10059,10 +10091,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, laneId, title, body, - draft: true, + draft: false, }); setRightPane({ kind: "details", title: "PR open", body: renderObject(created, 24) }); - addNotice("Created draft PR.", "success"); + addNotice("Created PR.", "success"); await refreshState(); } @@ -11645,10 +11677,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // file changes), if the click landed on one. Mirrors chatPointFromMouse's // viewport math but returns the row's expandableId instead of a text point so // a plain click can toggle the group's collapse state. - const expandableGroupIdFromMouse = useCallback(( + const chatRowTargetFromMouse = useCallback(( x: number | null, y: number | null, - ): string | null => { + ): { expandableId: string | null; actionId: string | null } | null => { if (x == null || y == null) return null; const drawerWidth = resolveDrawerPaneWidth(columns, drawerOpen); const textStartColumn = drawerWidth + 2; @@ -11657,9 +11689,35 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const bottomRow = topRow + Math.max(1, chatRowBudget) - 1; if (x < textStartColumn || x > textEndColumn || y < topRow || y > bottomRow) return null; const visibleRow = Math.max(0, Math.min(y - topRow, Math.max(0, chatRowBudget - 1))); - return visibleChatSelectionRows[visibleRow]?.expandableId ?? null; + const row = visibleChatSelectionRows[visibleRow]; + if (!row) return null; + return { + expandableId: row.expandableId ?? null, + actionId: row.actionId ?? null, + }; }, [addModeRows, chatRowBudget, chatWrapWidth, columns, drawerOpen, goalBannerRows, visibleChatSelectionRows]); + const openFileChangeDiffAction = useCallback((actionId: string): boolean => { + for (const block of displayBlocksRef.current) { + if (block.kind !== "files-changed-group") continue; + const selected = block.entries.find((entry) => workFileDiffKey(block.id, entry.itemId) === actionId); + if (!selected) continue; + const files = block.entries.map((entry) => ({ + path: entry.path, + additions: entry.additions, + deletions: entry.deletions, + body: entry.diff, + })); + const title = block.entries.length === 1 ? selected.path : "This turn"; + setRightPane({ kind: "diff", title, files }); + setRightOpen(true); + lastUserOpenedPaneRef.current = "diff"; + focusDetailsOnly(); + return true; + } + return false; + }, [focusDetailsOnly]); + const toggleExpandedLineId = useCallback((lineId: string) => { setExpandedLineIds((prev) => { const next = new Set(prev); @@ -11944,13 +12002,19 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // ▸ Files changed (N)) toggles it open/closed instead of starting a text // selection. Shift-click still extends a selection across the header. if (!mouse.shift) { - const groupId = expandableGroupIdFromMouse(mouse.x, mouse.y); - if (groupId) { + const chatRowTarget = chatRowTargetFromMouse(mouse.x, mouse.y); + if (chatRowTarget?.actionId && openFileChangeDiffAction(chatRowTarget.actionId)) { + stopChatSelectionEdgeScroll(); + chatSelectionAnchorRef.current = null; + if (activeSelection) updateChatMouseSelection(null); + return; + } + if (chatRowTarget?.expandableId) { stopChatSelectionEdgeScroll(); chatSelectionAnchorRef.current = null; if (activeSelection) updateChatMouseSelection(null); focusChat(); - toggleExpandedLineId(groupId); + toggleExpandedLineId(chatRowTarget.expandableId); return; } } diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 5e0c4f94d..71137e095 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -76,6 +76,8 @@ type RenderedChatRow = { * this id and toggles it in `expandedLineIds` to collapse/expand the group. */ expandableGroupId?: string; + /** Click action for rows that open another pane instead of toggling in place. */ + actionId?: string; }; export type ChatTextSelection = { @@ -92,6 +94,7 @@ export type ChatVisibleSelectionRow = { * RenderedChatRow.expandableGroupId). Lets the click handler toggle the * group without re-deriving block layout. */ expandableId?: string | null; + actionId?: string | null; }; // Expansion keys for collapsible work-log groups (tool calls / file changes) @@ -102,6 +105,11 @@ export function workGroupExpandKey(blockId: string): string { return `${WORK_GROUP_EXPAND_PREFIX}${blockId}`; } +export const WORK_FILE_DIFF_PREFIX = "workfilediff:"; +export function workFileDiffKey(blockId: string, itemId: string): string { + return `${WORK_FILE_DIFF_PREFIX}${encodeURIComponent(blockId)}:${encodeURIComponent(itemId)}`; +} + function textWidth(value: string): number { return terminalDisplayWidth(value); } @@ -823,6 +831,8 @@ function fileChangeEntryRow( { text: trimmedPath, color: theme.color.t1 }, { text: " " }, { text: stats, color: statsColor }, + { text: " " }, + { text: "diff", color: theme.color.t4 }, ]; return { id: `${blockId}:${entry.itemId}`, @@ -830,6 +840,7 @@ function fileChangeEntryRow( text: runsPlainText(runs), runs, rail: null, + actionId: workFileDiffKey(blockId, entry.itemId), }; } @@ -1551,6 +1562,7 @@ export function renderChatVisibleSelectionRowsFromRows({ sourceRow: typeof row.sourceRowIndex === "number" ? row.sourceRowIndex : null, text: renderedRowText(row), expandableId: row.expandableGroupId ?? null, + actionId: row.actionId ?? null, })); } @@ -1690,6 +1702,7 @@ export function renderChatVisibleSelectionRows({ sourceRow: typeof row.sourceRowIndex === "number" ? row.sourceRowIndex : null, text: renderedRowText(row), expandableId: row.expandableGroupId ?? null, + actionId: row.actionId ?? null, })); } diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index dbe4298e4..af8f2833e 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -101,7 +101,7 @@ export const LANE_DETAIL_ACTIONS: ReadonlyArray<{ intent?: "rescue-unstaged"; }> = [ { k: "n", label: "new chat", slashCommand: "/new chat", glyph: "✦", glyphColorKind: "additive" }, - { k: "o", label: "open / create PR", slashCommand: "/pr open", detail: "draft when missing", glyph: "↗", glyphColorKind: "navigation" }, + { k: "o", label: "open / create PR", slashCommand: "/pr open", detail: "create when missing", glyph: "↗", glyphColorKind: "navigation" }, { k: "a", label: "stage all", slashCommand: "/stage all", glyph: "+", glyphColorKind: "additive" }, { k: "u", diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index 70d5db405..907d3b946 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -3277,11 +3277,11 @@ describe("prService.draftDescription", () => { const makeAi = (impl?: () => unknown) => ({ draftPrDescription: vi.fn(impl ?? (() => undefined)) }) as any; - // Regression: the in-chat "AI draft" button (requireAi) must run the real AI - // path using the chat's own model even though the stored providerMode is the - // default "guest" — providerMode is NOT derived from live CLI auth, so a chat - // running on a connected runtime would otherwise be wrongly refused. - it("runs the AI path on the chat's model when requireAi is set, even in guest providerMode", async () => { + // Regression: explicit AI draft requests (requireAi) must run the real AI + // path using the requested model even though the stored providerMode is the + // default "guest" — providerMode is NOT derived from live CLI auth, so a + // connected runtime would otherwise be wrongly refused. + it("runs the AI path on the requested model when requireAi is set, even in guest providerMode", async () => { // NB: this file mocks extractFirstJsonObject → null, so parsePrDraftJson // always falls back to using the raw model text as the body. We assert the // call shape (the regression) and that the AI output reaches the draft. @@ -3346,6 +3346,43 @@ describe("prService.createFromLane", () => { vi.clearAllMocks(); }); + it("defaults omitted PR titles to source lane and target lane names", async () => { + const ghService = makeGithubService({ + apiRequest: vi.fn().mockRejectedValue(new Error("stop after payload capture")), + }); + const laneService = makeLaneService([ + makeFakeLane(), + makeFakeLane({ + id: "lane-primary", + name: "Primary", + laneType: "primary", + baseRef: "refs/heads/main", + branchRef: "refs/heads/main", + parentLaneId: null, + }), + ]); + + const { service } = buildService({ githubService: ghService, laneService }); + + await expect( + service.createFromLane({ + laneId: LANE_ID, + body: "description", + draft: false, + allowDirtyWorktree: true, + } as any), + ).rejects.toThrow('Failed to create pull request for "my-feature" → "main": stop after payload capture'); + + expect(ghService.apiRequest).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + body: expect.objectContaining({ + title: "my-feature -> Primary", + }), + }), + ); + }); + it("wraps githubService.apiRequest errors with branch context", async () => { const ghService = makeGithubService({ apiRequest: vi.fn().mockRejectedValue(new Error("Validation Failed: A pull request already exists")), diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 22e23c3e3..1529aea8c 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -325,6 +325,12 @@ function branchNameFromRef(ref: string): string { return branchNameFromLaneRef(ref); } +function defaultPrTitleForLane(lane: LaneSummary, baseBranch: string, lanes: LaneSummary[]): string { + const targetLane = lanes.find((entry) => entry.id !== lane.id && branchNameFromRef(entry.branchRef) === baseBranch); + const targetName = targetLane?.name?.trim() || baseBranch; + return `${lane.name} -> ${targetName}`; +} + /** * Synthetic, stable id for an unmapped GitHub PR (no DB row). Used as the * `PrDetail.prId` for coordinate-based fetches so the renderer can key per-PR @@ -5267,13 +5273,12 @@ export function createPrService({ }; }; - // The in-chat "AI draft" button sets requireAi. The chat is already running on - // a live runtime, so the coarse stored providerMode — which stays "guest" until - // the user explicitly enables subscription mode in Settings → AI Connections, - // and is NOT derived from active CLI auth — must NOT gate it. We attempt the - // real AI path using the chat's own model and let aiIntegrationService.executeTask - // perform the authoritative auth/runtime detection, surfacing a precise error - // only when no provider is genuinely reachable. + // Explicit AI-draft callers set requireAi. If the caller already has a live + // runtime, the coarse stored providerMode — which stays "guest" until the user + // enables subscription mode in Settings -> AI Connections, and is NOT derived + // from active CLI auth — must NOT gate it. We attempt the real AI path using + // the requested model and let aiIntegrationService.executeTask perform the + // authoritative auth/runtime detection. if (args.requireAi && !aiIntegrationService) { throw new Error( "AI drafting is unavailable in this mode — open this lane in the desktop app to draft with AI.", @@ -5383,13 +5388,14 @@ export function createPrService({ if (!baseBranch) { throw new Error("Choose a target branch before creating the PR."); } + const title = args.title?.trim() || defaultPrTitleForLane(lane, baseBranch, allLanes); if (!args.skipBranchPush) { await pushLaneBranchForPr(lane, headBranch); } const repo = await githubService.getRepoOrThrow(); const closeLinearIssueOnMerge = args.closeLinearIssueOnMerge !== false; - const linearAdjustedBody = applyLinearPrLinkage(args.body, lane, closeLinearIssueOnMerge); + const linearAdjustedBody = applyLinearPrLinkage(args.body ?? "", lane, closeLinearIssueOnMerge); // Append the branded "Open in ADE" footer (branch link only at this point; // we'll PATCH the PR body with the PR-number-aware variant once we know it). const prBody = ensureAdeDeeplinkFooter(linearAdjustedBody, { @@ -5404,7 +5410,7 @@ export function createPrService({ method: "POST", path: `/repos/${repo.owner}/${repo.name}/pulls`, body: { - title: args.title, + title, head: headBranch, base: baseBranch, body: prBody, diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index c8f9903bf..9774c2876 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -2064,6 +2064,24 @@ describe("createSyncRemoteCommandService", () => { ); }); + it("work.runQuickCommand preserves startupCommand for visible shell sessions", async () => { + await service.execute(makePayload("work.runQuickCommand", { + laneId: "lane-1", + title: "Claude login", + startupCommand: "claude auth login", + toolType: "shell", + })); + expect(ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "Claude login", + startupCommand: "claude auth login", + tracked: true, + toolType: "shell", + }), + ); + }); + it("work.runQuickCommand throws when startupCommand is missing and toolType is not shell", async () => { await expect(service.execute(makePayload("work.runQuickCommand", { laneId: "lane-1", diff --git a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx index 715fa0f1c..85ea0400b 100644 --- a/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx +++ b/apps/desktop/src/renderer/components/app/FeedbackReporterModal.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { useNavigate } from "react-router-dom"; import { AnimatePresence, motion } from "motion/react"; import { ArrowSquareOut, @@ -17,6 +16,7 @@ import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { useAppStore } from "../../state/appStore"; import { COLORS, MONO_FONT, SANS_FONT } from "../lanes/laneDesignTokens"; +import { useOpenProviderSignIn } from "../shared/useOpenProviderSignIn"; import type { AppInfo, ProjectInfo } from "../../../shared/types/core"; import type { GitCommitSummary } from "../../../shared/types/git"; import type { LaneSummary } from "../../../shared/types/lanes"; @@ -262,10 +262,7 @@ function NewReportTab({ hasGithubToken: boolean; onSubmitted: () => void; }) { - const navigate = useNavigate(); - const openAiProvidersSettings = useCallback(() => { - navigate("/settings?tab=ai#ai-providers"); - }, [navigate]); + const openProviderSignIn = useOpenProviderSignIn(); const project = useAppStore((s) => s.project); const lanes = useAppStore((s) => s.lanes); const selectedLaneId = useAppStore((s) => s.selectedLaneId); @@ -658,7 +655,7 @@ function NewReportTab({ }} surfaceKey="feedback-reporter" availableModelIds={availableModelIds} - onOpenSignIn={openAiProvidersSettings} + onOpenSignIn={openProviderSignIn} /> <ReasoningEffortPicker modelId={modelId} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 2de6f86f2..d66b0f39c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1241,7 +1241,7 @@ export function AgentChatComposer({ onEditSteer?: (steerId: string, text: string) => void; onDispatchSteerInline?: (steerId: string) => void; onDispatchSteerInterrupt?: (steerId: string) => void; - onOpenAiSettings?: () => void; + onOpenAiSettings?: (family?: ProviderFamily) => void; onOpenLinearSettings?: () => void; launchPromptClipboardEnabled?: boolean; launchPromptClipboardNoticeEnabled?: boolean; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index d797551d1..2cc60997e 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -229,6 +229,31 @@ describe("AgentChatMessageList operator navigation suggestions", () => { }); describe("AgentChatMessageList transcript rendering", () => { + it("keeps turn file-change summaries visible without a session id", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "turn_diff_summary", + turnId: "turn-1", + beforeSha: "before", + afterSha: "after", + files: [ + { path: "apps/desktop/src/main.ts", additions: 12, deletions: 3, status: "M" }, + { path: "apps/desktop/src/renderer.tsx", additions: 4, deletions: 1, status: "M" }, + ], + totalAdditions: 16, + totalDeletions: 4, + }, + }, + ]); + + expect(screen.getByText("Files changed")).toBeTruthy(); + expect(screen.getByText("This turn: 2 files +16 -4")).toBeTruthy(); + expect(screen.getByText("Full thread: 2 files +16 -4")).toBeTruthy(); + }); + it("renders Codex goal lifecycle rows in user-facing language", () => { renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index d612304eb..3f1b6d8b3 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -43,7 +43,7 @@ import type { ChatSurfaceProfile, ChatSurfaceMode, OperatorNavigationSuggestion, - TurnDiffFile, + TurnDiffSummary, } from "../../../shared/types"; import { getModelById, resolveModelDescriptor, type ModelDescriptor } from "../../../shared/modelRegistry"; import { cn } from "../ui/cn"; @@ -103,6 +103,7 @@ import { CodexImageGenerationCard } from "./codex/CodexImageGenerationCard"; import { CodexImageViewLine } from "./codex/CodexImageViewLine"; import { ContextCompactDivider } from "./ContextCompactDivider"; import { peekPendingSessionAnchor, takePendingSessionAnchor } from "../terminals/pendingSessionAnchors"; +import { ChatTurnFileChangesPanel, aggregateFiles } from "./ChatFileChangesPanel"; /** * Threaded into MarkdownBlock only for Claude-family sessions. When present, a @@ -123,6 +124,40 @@ type WorkspacePathLocation = { startColumn?: number; }; +function formatDiffCounts(fileCount: number, additions: number, deletions: number): string { + const fileLabel = fileCount === 1 ? "file" : "files"; + return `${fileCount} ${fileLabel} +${additions} -${deletions}`; +} + +function TurnDiffSummaryFallback({ + turnSummary, + threadSummaries, +}: { + turnSummary: TurnDiffSummary; + threadSummaries: TurnDiffSummary[]; +}) { + const thread = threadSummaries.length > 0 ? threadSummaries : [turnSummary]; + const turnFiles = aggregateFiles([turnSummary]); + if (turnFiles.length === 0) return null; + const threadFiles = aggregateFiles(thread); + const turnAdditions = turnFiles.reduce((sum, file) => sum + file.additions, 0); + const turnDeletions = turnFiles.reduce((sum, file) => sum + file.deletions, 0); + const threadAdditions = threadFiles.reduce((sum, file) => sum + file.additions, 0); + const threadDeletions = threadFiles.reduce((sum, file) => sum + file.deletions, 0); + return ( + <div className="my-2 w-full max-w-full rounded-lg border border-white/10 bg-white/[0.035] px-3 py-2 font-sans text-[length:calc(var(--chat-font-size)*12/14)] text-fg/70"> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="inline-flex items-center gap-1.5 font-semibold text-fg/85"> + <FileCode size={13} weight="bold" aria-hidden /> + Files changed + </span> + <span>This turn: {formatDiffCounts(turnFiles.length, turnAdditions, turnDeletions)}</span> + <span>Full thread: {formatDiffCounts(threadFiles.length, threadAdditions, threadDeletions)}</span> + </div> + </div> + ); +} + function readOperatorNavigationSuggestion(value: unknown): OperatorNavigationSuggestion | null { const record = readRecord(value); if (!record) return null; @@ -192,21 +227,6 @@ function formatFileAction(kind: Extract<AgentChatEvent, { type: "file_change" }> } } -function formatTurnDiffAction(status: TurnDiffFile["status"]): string { - switch (status) { - case "A": - return "Created"; - case "D": - return "Deleted"; - case "R": - return "Renamed"; - case "C": - return "Copied"; - default: - return "Edited"; - } -} - function approvalToneClass(state: PendingInputResolution | null): string { if (state === "accepted") return "text-emerald-300/70"; if (state === "declined") return "text-red-300/70"; @@ -2653,6 +2673,7 @@ function renderEvent( runtimeName?: string | null; onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; onRewindFiles?: (request: { messageId: string; timestamp: string; text: string }) => void; + turnDiffSummaries?: TurnDiffSummary[]; mosaic?: MosaicRenderContext; } ) { @@ -3914,36 +3935,22 @@ function renderEvent( return null; } - /* ── Turn diff summary (minimal inline indicator — detail lives in bottom Tasks panel) ── */ + /* ── Turn diff summary ── */ if (event.type === "turn_diff_summary") { + if (!options?.sessionId) { + return ( + <TurnDiffSummaryFallback + turnSummary={event} + threadSummaries={options?.turnDiffSummaries ?? [event]} + /> + ); + } return ( - <details className="group rounded-lg border border-white/[0.04] bg-white/[0.02] px-3 py-2 font-mono text-[length:calc(var(--chat-font-size)*10/14)] text-fg/32"> - <summary className="flex cursor-pointer list-none items-center gap-2 text-left outline-none"> - <FileCode size={10} /> - <span>{event.files.length} file{event.files.length !== 1 ? "s" : ""} changed</span> - {event.totalAdditions > 0 && <span className="text-emerald-400/50">+{event.totalAdditions}</span>} - {event.totalDeletions > 0 && <span className="text-red-400/50">-{event.totalDeletions}</span>} - <span className="ml-auto rounded-md border border-white/[0.06] bg-white/[0.03] px-1.5 py-0.5 text-[length:calc(var(--chat-font-size)*9/14)] text-fg/42 group-open:text-fg/60"> - View files - </span> - </summary> - <div className="mt-2 space-y-1.5 text-[length:calc(var(--chat-font-size)*11/14)] text-fg/55"> - {event.files.map((file) => ( - <div - key={`${event.turnId}:${file.path}:${file.status}`} - className="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-md border border-white/[0.05] bg-black/10 px-2.5 py-2" - > - <span className="font-medium text-fg/72">{formatTurnDiffAction(file.status)}</span> - <span className="min-w-0 max-w-full truncate text-fg/58" title={file.path}>{basenamePathLabel(file.path)}</span> - {file.additions > 0 ? <span className="text-emerald-300/70">+{file.additions}</span> : null} - {file.deletions > 0 || file.status === "D" ? <span className="text-red-300/70">-{file.deletions}</span> : null} - <span className="min-w-0 max-w-full truncate font-mono text-[length:calc(var(--chat-font-size)*9/14)] text-fg/34" title={dirnamePathLabel(file.path) ?? ""}> - {dirnamePathLabel(file.path) ?? ""} - </span> - </div> - ))} - </div> - </details> + <ChatTurnFileChangesPanel + turnSummary={event} + threadSummaries={options.turnDiffSummaries ?? [event]} + sessionId={options.sessionId} + /> ); } @@ -4238,6 +4245,7 @@ type EventRowProps = { onInsertDraft?: (text: string) => void; onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; onRewindFiles?: (request: { messageId: string; timestamp: string; text: string }) => void; + turnDiffSummaries?: TurnDiffSummary[]; respondingApprovalIds?: Set<string>; pendingApprovalIds?: Set<string>; resolvedInputStates?: Map<string, PendingInputResolution>; @@ -4267,6 +4275,7 @@ const EventRow = React.memo(function EventRow({ onInsertDraft, onRevealChatTerminal, onRewindFiles, + turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, @@ -4333,6 +4342,7 @@ const EventRow = React.memo(function EventRow({ runtimeName, onRevealChatTerminal, onRewindFiles, + turnDiffSummaries, mosaic, })} {envelope.event.type === "done" ? ( @@ -4653,6 +4663,7 @@ function AgentChatMessageListMain({ onInsertDraft, onRevealChatTerminal, onRewindFiles, + turnDiffSummaries, sessionEnded = false, hasOlderHistory = false, loadingOlderHistory = false, @@ -4670,6 +4681,7 @@ function AgentChatMessageListMain({ onInsertDraft?: (text: string) => void; onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; onRewindFiles?: (request: { messageId: string; timestamp: string; text: string }) => void; + turnDiffSummaries?: TurnDiffSummary[]; respondingApprovalIds?: Set<string>; pendingApprovalIds?: Set<string>; laneId?: string | null; @@ -5437,6 +5449,7 @@ function AgentChatMessageListMain({ onInsertDraft={onInsertDraft} onRevealChatTerminal={onRevealChatTerminal} onRewindFiles={onRewindFiles} + turnDiffSummaries={turnDiffSummaries} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} resolvedInputStates={resolvedInputStates} @@ -5469,6 +5482,7 @@ function AgentChatMessageListMain({ onInsertDraft={onInsertDraft} onRevealChatTerminal={onRevealChatTerminal} onRewindFiles={onRewindFiles} + turnDiffSummaries={turnDiffSummaries} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} resolvedInputStates={resolvedInputStates} @@ -5479,7 +5493,7 @@ function AgentChatMessageListMain({ anchored={anchored} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, onRewindFiles, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic]); + }, [activeTurnId, anchoredRowKey, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { @@ -5519,14 +5533,14 @@ function AgentChatMessageListMain({ return ( <div className={cn("relative h-full min-h-0 min-w-0 max-w-full overflow-hidden", className)}> - {/* Bound the user-message minimap to the centered chat column (same width as - the transcript + composer) so it sits at the column's right edge instead - of overextending to the window edge. */} + {/* Bound the minimap to the centered chat column, then place it just outside + the transcript gutter on wide layouts so it does not stripe the message text. */} <div className="pointer-events-none absolute inset-0 z-20 mx-auto w-full max-w-[var(--chat-column,52rem)]"> <ChatUserMinimap displayEntries={minimapDisplayEntries} activeDisplayIndex={activeMinimapDisplayIndex} onJumpToRow={jumpToRowFromMinimap} + placement="outsideRight" /> </div> <div diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 1588667d5..84d9bc865 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -2035,7 +2035,7 @@ describe("AgentChatPane submit recovery", () => { expect(window.ade.pty.create).not.toHaveBeenCalled(); }); - it("keeps the chat terminal drawer wired when Work hides lane tool drawers", async () => { + it("reveals chat terminals without a header terminal shortcut when Work hides lane tool drawers", async () => { const session = buildSession("session-1", { status: "idle" }); const { emitSessionChanged } = installAdeMocks({ sessions: [session] }); const terminalSession: TerminalSessionDetail = { @@ -2080,7 +2080,7 @@ describe("AgentChatPane submit recovery", () => { ); await screen.findByRole("textbox"); - expect(await screen.findByRole("button", { name: /(Open|Close) terminal/i })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /(Open|Close) terminal/i })).toBeNull(); expect(screen.queryByRole("button", { name: "Open iOS simulator drawer" })).toBeNull(); act(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c608e4233..b17818eb9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -79,6 +79,7 @@ import { resolveModelDescriptorForProvider, type LocalProviderFamily, type ModelDescriptor, + type ProviderFamily, } from "../../../shared/modelRegistry"; import { filterChatModelIdsForSession } from "../../../shared/chatModelSwitching"; import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; @@ -114,7 +115,6 @@ import { ChatComputerUsePanel } from "./ChatComputerUsePanel"; import { ChatIosSimulatorPanel } from "./ChatIosSimulatorPanel"; import { ChatAppControlPanel } from "./ChatAppControlPanel"; import { ChatSubagentsPanel } from "./ChatSubagentsPanel"; -import { ChatFileChangesPanel } from "./ChatFileChangesPanel"; import { RewindFilesConfirmDialog, type RewindFilesConfirmDialogState } from "./RewindFilesConfirmDialog"; import { buildRewindPreviewFiles, deriveRewindDiffSummaries } from "./rewindFilesPreview"; import { ChatCursorCloudPanel, type ChatCursorCloudPanelHandle } from "./ChatCursorCloudPanel"; @@ -122,7 +122,7 @@ import { CursorCloudInlineLaunch, type CursorCloudInlineLaunchHandle } from "./C import { QuickRunInlineList } from "../run/QuickRunMenu"; import { getLaneAccent } from "../lanes/laneColorPalette"; import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; -import { ChatTerminalDrawer, ChatTerminalToggle } from "./ChatTerminalDrawer"; +import { ChatTerminalDrawer } from "./ChatTerminalDrawer"; import { deriveChatSubagentSnapshots, deriveScheduledWorkSnapshots, deriveTodoItems, deriveTurnDiffSummaries } from "./chatExecutionSummary"; import { deriveMissionSnapshot } from "./chatMission"; import { MissionControlPanel } from "./MissionControlPanel"; @@ -133,7 +133,7 @@ import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel"; import { ChatPrPane } from "./ChatPrPane"; import { useChatPrAutoPop } from "./useChatPrAutoPop"; -import { ClaudeLoginPromptButton } from "../work/ClaudeLoginPromptButton"; +import { ClaudeLoginPromptButton, createClaudeLoginTerminalInWork } from "../work/ClaudeLoginPromptButton"; import { CHAT_AUTH_RECOVERED_EVENT, CHAT_AUTH_RETRY_REJECTED_EVENT, CHAT_RETRY_AUTH_TURN_EVENT } from "./AgentCliAuthCard"; import { rootAppStoreApi, selectActiveProjectRoot, useAppStore, useRootAppStore } from "../../state/appStore"; import { setLaneNaming } from "../../state/laneNamingStore"; @@ -3100,6 +3100,20 @@ export function AgentChatPane({ const handoffErrorClearTimerRef = useRef<number | null>(null); const [deletingChatSessionId, setDeletingChatSessionId] = useState<string | null>(null); const [computerUseSnapshot, setComputerUseSnapshot] = useState<ComputerUseOwnerSnapshot | null>(null); + const openClaudeLoginInPrimaryLane = useCallback(async () => { + try { + await createClaudeLoginTerminalInWork({ navigate }); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [navigate]); + const openProviderSignIn = useCallback((family?: ProviderFamily) => { + if (family === "anthropic") { + void openClaudeLoginInPrimaryLane(); + return; + } + openAiProvidersSettings(); + }, [openAiProvidersSettings, openClaudeLoginInPrimaryLane]); const [chatActionsOpen, setChatActionsOpen] = useState( () => readChatCompanionUiState(initialCompanionStateKey).chatActionsOpen, ); @@ -3159,14 +3173,6 @@ export function AgentChatPane({ } | null>(null); const terminalRevealNonceRef = useRef(0); const hasExternalTerminalPane = Boolean(onToggleTerminalPane || onOpenTerminalPane); - const effectiveTerminalPaneOpen = hasExternalTerminalPane ? terminalPaneOpen === true : terminalDrawerOpen; - const toggleTerminalPanel = useCallback(() => { - if (onToggleTerminalPane) { - onToggleTerminalPane(); - return; - } - setTerminalDrawerOpen((current) => !current); - }, [onToggleTerminalPane]); const openTerminalPanel = useCallback(() => { if (onOpenTerminalPane) { onOpenTerminalPane(); @@ -4615,14 +4621,8 @@ export function AgentChatPane({ tone: "muted", }); } - const hasServiceTier = selectedSession?.provider === "codex" - && Object.prototype.hasOwnProperty.call(selectedSession, "codexServiceTier"); - if (hasServiceTier) { - const serviceTier = selectedSession?.codexServiceTier?.trim().toLowerCase() || "default"; - chips.push({ label: `Tier: ${serviceTier}`, tone: serviceTier === "fast" ? "info" : "muted" }); - } return chips; - }, [resolvedChips, selectedSession?.codexServiceTier, selectedSession?.provider, selectedSessionImportedProvider]); + }, [resolvedChips, selectedSessionImportedProvider]); // Keep configured models selectable unless a caller explicitly constrains // this surface. Unconstrained sessions keep their active model visible even @@ -9310,7 +9310,7 @@ export function AgentChatPane({ onChange={setHandoffModelId} surfaceKey="chat-handoff" {...(handoffAvailableModelIds ? { availableModelIds: handoffAvailableModelIds } : {})} - onOpenSignIn={openAiProvidersSettings} + onOpenSignIn={openProviderSignIn} /> <ReasoningEffortPicker modelId={handoffModelId} @@ -9786,7 +9786,6 @@ export function AgentChatPane({ </button> </SmartTooltip> ) : null} - {chatTerminalVisible ? <ChatTerminalToggle open={effectiveTerminalPaneOpen} onToggle={toggleTerminalPanel} /> : null} {headerChips.map((chip) => ( <span key={`${chip.label}:${chip.tone ?? "accent"}`} @@ -10045,7 +10044,7 @@ export function AgentChatPane({ onRemoveIosElementContext={removeIosElementContext} onRemoveAppControlContext={removeAppControlContext} onRemoveBuiltInBrowserContext={removeBuiltInBrowserContext} - onOpenAiSettings={openAiProvidersSettings} + onOpenAiSettings={openProviderSignIn} onOpenLinearSettings={openLinearSettings} launchPromptClipboardEnabled={launchPromptClipboardEnabled} launchPromptClipboardNoticeEnabled={launchPromptClipboardNoticeEnabled} @@ -10814,6 +10813,7 @@ export function AgentChatPane({ onRevealChatTerminal={(terminal) => { revealChatTerminal(terminal); }} + turnDiffSummaries={selectedTurnDiffSummaries} onRewindFiles={selectedSession?.provider === "claude" || selectedSession?.provider === "codex" ? rewindFilesFromMessage : undefined} onApproval={(itemId, decision, responseText, answers) => { void handleApproval(itemId, decision, responseText, answers); @@ -10826,12 +10826,6 @@ export function AgentChatPane({ <span className="text-red-400/75">-{sessionDelta.deletions}</span> </div> ) : null} - {selectedTurnDiffSummaries.length && selectedSessionId ? ( - <ChatFileChangesPanel - summaries={selectedTurnDiffSummaries} - sessionId={selectedSessionId} - /> - ) : null} {appPanelOpen ? ( <div className="shrink-0 border-t border-white/[0.06]"> {authStickyBar} @@ -10855,7 +10849,6 @@ export function AgentChatPane({ <ChatPrPane laneId={laneId} branchName={laneGitBranch} - chatModelId={selectedSessionModelId ?? modelId} delta={prPaneDelta} onClose={() => setPrPaneOpen(false)} />, diff --git a/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx index 7eef9ac5e..a88effa69 100644 --- a/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatFileChangesPanel.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useMemo, useRef, useState } from "react"; import { + CaretDown, FileCode, FilePlus, FileX, @@ -53,14 +54,14 @@ function statusBadge(status: TurnDiffFile["status"]) { /* ── Aggregation ── */ -type AggregatedFile = TurnDiffFile & { +export type AggregatedFile = TurnDiffFile & { /** The turn whose SHA pair should be used to fetch the diff. */ beforeSha: string; afterSha: string; turnIndex: number; }; -function aggregateFiles(summaries: TurnDiffSummary[]): AggregatedFile[] { +export function aggregateFiles(summaries: TurnDiffSummary[]): AggregatedFile[] { // Summaries arrive in chronological order, so each subsequent turn is the // "latest" for any file it touches. We keep the first turn's beforeSha and // advance the afterSha/status/stats as later turns amend the same path. @@ -148,16 +149,39 @@ function renderDiffPane({ ); } -/* ── Component ── */ +/* ── Components ── */ -export const ChatFileChangesPanel = React.memo(function ChatFileChangesPanel({ - summaries, - sessionId, +function FileChangesSummary({ + files, + muted = false, }: { + files: TurnDiffFile[]; + muted?: boolean; +}) { + const totalAdditions = files.reduce((sum, file) => sum + file.additions, 0); + const totalDeletions = files.reduce((sum, file) => sum + file.deletions, 0); + return ( + <span className={cn("flex flex-wrap items-center gap-2 text-[12px]", muted ? "text-fg/45" : "text-fg/60")}> + <span>{files.length} file{files.length !== 1 ? "s" : ""}</span> + {totalAdditions > 0 && <span className="text-emerald-400/70">+{totalAdditions}</span>} + {totalDeletions > 0 && <span className="text-red-400/70">-{totalDeletions}</span>} + </span> + ); +} + +type FileChangesBrowserProps = { summaries: TurnDiffSummary[]; sessionId: string; -}) { - const [expanded, setExpanded] = useState(false); + className?: string; + maxHeight?: number; +}; + +const FileChangesBrowser = React.memo(function FileChangesBrowser({ + summaries, + sessionId, + className, + maxHeight = 400, +}: FileChangesBrowserProps) { const [selectedPath, setSelectedPath] = useState<string | null>(null); const [loadingPath, setLoadingPath] = useState<string | null>(null); const diffCache = useRef<Map<string, FileDiff>>(new Map()); @@ -166,9 +190,6 @@ export const ChatFileChangesPanel = React.memo(function ChatFileChangesPanel({ const [activeDiffLoadState, setActiveDiffLoadState] = useState<DiffLoadState>("idle"); const files = useMemo(() => aggregateFiles(summaries), [summaries]); - const totalAdditions = useMemo(() => files.reduce((sum, file) => sum + file.additions, 0), [files]); - const totalDeletions = useMemo(() => files.reduce((sum, file) => sum + file.deletions, 0), [files]); - const handleSelectFile = useCallback( async (filePath: string) => { setSelectedPath(filePath); @@ -225,12 +246,58 @@ export const ChatFileChangesPanel = React.memo(function ChatFileChangesPanel({ if (!files.length) return null; + return ( + <div className={cn("flex min-h-[220px] overflow-hidden", className)} style={{ maxHeight }}> + {/* File list (left pane) */} + <div className="w-[220px] shrink-0 overflow-y-auto border-r border-white/[0.04] max-sm:w-[150px]"> + {files.map((file) => { + const isSelected = selectedPath === file.path; + return ( + <button + key={file.path} + type="button" + className={cn( + "flex w-full items-center gap-2 px-3 py-2 text-left transition-colors", + isSelected ? "bg-white/[0.05]" : "hover:bg-white/[0.03]", + )} + onClick={() => void handleSelectFile(file.path)} + > + {statusIcon(file.status)} + <span className="min-w-0 flex-1 truncate text-[12px] text-fg/60" title={file.path}> + {basename(file.path)} + </span> + <div className="flex shrink-0 items-center gap-1.5 max-sm:hidden"> + {file.additions > 0 && <span className="text-[11px] text-emerald-400/70">+{file.additions}</span>} + {file.deletions > 0 && <span className="text-[11px] text-red-400/70">-{file.deletions}</span>} + {statusBadge(file.status)} + </div> + </button> + ); + })} + </div> + + {/* Diff viewer (right pane) */} + <div className="min-w-0 flex-1"> + {renderDiffPane({ selectedPath, loadingPath, activeDiff, loadState: activeDiffLoadState })} + </div> + </div> + ); +}); + +export const ChatFileChangesPanel = React.memo(function ChatFileChangesPanel({ + summaries, + sessionId, +}: { + summaries: TurnDiffSummary[]; + sessionId: string; +}) { + const [expanded, setExpanded] = useState(false); + const files = useMemo(() => aggregateFiles(summaries), [summaries]); + + if (!files.length) return null; + const summaryContent = ( - <span className="flex items-center gap-2 text-[12px]"> - <span className="text-fg/50">{files.length} file{files.length !== 1 ? "s" : ""}</span> - {totalAdditions > 0 && <span className="text-emerald-400/70">+{totalAdditions}</span>} - {totalDeletions > 0 && <span className="text-red-400/70">-{totalDeletions}</span>} - </span> + <FileChangesSummary files={files} /> ); return ( @@ -241,40 +308,73 @@ export const ChatFileChangesPanel = React.memo(function ChatFileChangesPanel({ expanded={expanded} onToggle={() => setExpanded((v) => !v)} > - <div className="flex" style={{ maxHeight: 400 }}> - {/* File list (left pane) */} - <div className="w-[220px] shrink-0 overflow-y-auto border-r border-white/[0.04]"> - {files.map((file) => { - const isSelected = selectedPath === file.path; - return ( - <button - key={file.path} - type="button" - className={cn( - "flex w-full items-center gap-2 px-3 py-2 text-left transition-colors", - isSelected ? "bg-white/[0.05]" : "hover:bg-white/[0.03]", - )} - onClick={() => void handleSelectFile(file.path)} - > - {statusIcon(file.status)} - <span className="min-w-0 flex-1 truncate text-[12px] text-fg/60" title={file.path}> - {basename(file.path)} - </span> - <div className="flex shrink-0 items-center gap-1.5"> - {file.additions > 0 && <span className="text-[11px] text-emerald-400/70">+{file.additions}</span>} - {file.deletions > 0 && <span className="text-[11px] text-red-400/70">-{file.deletions}</span>} - {statusBadge(file.status)} - </div> - </button> - ); - })} - </div> - - {/* Diff viewer (right pane) */} - <div className="min-w-0 flex-1"> - {renderDiffPane({ selectedPath, loadingPath, activeDiff, loadState: activeDiffLoadState })} - </div> - </div> + <FileChangesBrowser summaries={summaries} sessionId={sessionId} /> </BottomDrawerSection> ); }); + +function NestedFileChangesSection({ + label, + summaries, + sessionId, +}: { + label: string; + summaries: TurnDiffSummary[]; + sessionId: string; +}) { + const files = useMemo(() => aggregateFiles(summaries), [summaries]); + if (!files.length) return null; + return ( + <details className="group/changes overflow-hidden rounded-lg border border-white/[0.06] bg-black/[0.16]"> + <summary className="flex min-h-9 cursor-pointer list-none items-center gap-2 px-3 py-2 outline-none transition-colors hover:bg-white/[0.03]"> + <CaretDown + size={12} + weight="bold" + className="shrink-0 text-fg/38 transition-transform group-open/changes:rotate-180" + /> + <span className="font-sans text-[12px] font-medium text-fg/72">{label}</span> + <span className="ml-auto"> + <FileChangesSummary files={files} muted /> + </span> + </summary> + <div className="border-t border-white/[0.05]"> + <FileChangesBrowser + summaries={summaries} + sessionId={sessionId} + maxHeight={360} + /> + </div> + </details> + ); +} + +export const ChatTurnFileChangesPanel = React.memo(function ChatTurnFileChangesPanel({ + turnSummary, + threadSummaries, + sessionId, +}: { + turnSummary: TurnDiffSummary; + threadSummaries: TurnDiffSummary[]; + sessionId: string; +}) { + const thread = threadSummaries.length ? threadSummaries : [turnSummary]; + const turnFiles = useMemo(() => aggregateFiles([turnSummary]), [turnSummary]); + if (!turnFiles.length) return null; + + return ( + <details className="group rounded-xl border border-white/[0.06] bg-white/[0.025] px-3 py-2 font-mono text-[length:calc(var(--chat-font-size)*10/14)] shadow-[0_10px_30px_rgba(0,0,0,0.08)]"> + <summary className="flex cursor-pointer list-none items-center gap-2 text-left outline-none"> + <GitDiff size={12} className="text-fg/42" /> + <span className="font-sans text-[12px] font-semibold text-fg/74">Files changed</span> + <FileChangesSummary files={turnFiles} muted /> + <span className="ml-auto rounded-md border border-white/[0.07] bg-white/[0.035] px-2 py-0.5 font-sans text-[10px] font-medium text-fg/45 transition-colors group-open:text-fg/68"> + View diffs + </span> + </summary> + <div className="mt-2 space-y-2"> + <NestedFileChangesSection label="This turn" summaries={[turnSummary]} sessionId={sessionId} /> + <NestedFileChangesSection label="Full thread" summaries={thread} sessionId={sessionId} /> + </div> + </details> + ); +}); diff --git a/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx b/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx index cd74867a5..43ed9858e 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx @@ -6,7 +6,6 @@ import { CircleNotch, GitPullRequest, LockSimple, - Sparkle, Warning, } from "@phosphor-icons/react"; import { BranchIcon } from "../ui/vcsIcons"; @@ -15,11 +14,7 @@ import { LaneLogoMark, laneDisplayColor } from "../terminals/LaneChip"; import { useAppStore } from "../../state/appStore"; import type { PrSummary } from "../../../shared/types"; import { branchNameFromRef, resolveLaneBaseBranch } from "../prs/shared/laneBranchTargets"; -import { - buildLinearPrReference, - buildLinearPrTitle, - ensureLinearPrReference, -} from "../../../shared/linearMagicWords"; +import { buildLinearPrReference } from "../../../shared/linearMagicWords"; /** * Lightweight pull-request creator embedded in the left PR floating pane. A @@ -30,11 +25,8 @@ import { * PR-type selector here — queue/integration live in the full composer ("Open in * PRs tab"). * - * The "AI draft" button runs ADE's `pr_descriptions` background job (same engine - * as auto-commit) using the CHAT's active model, and surfaces real failures - * (`requireAi`) instead of silently returning a template. We deliberately do NOT - * inject Linear magic words or the "Open in ADE" deeplink footer here — prService - * owns those trailers on create (idempotently), so the editable fields stay clean. + * Linear magic words and the "Open in ADE" deeplink footer are owned by + * prService on create (idempotently), so the editable fields stay clean. * * On success we do NOT navigate: we hand the freshly-created PR up via `onCreated` * so ChatPrPane swaps to the live PR-details panel immediately (the subsequent @@ -51,13 +43,10 @@ const inputBase = export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ laneId, branchName, - chatModelId, onCreated, }: { laneId: string; branchName?: string | null; - /** The active chat session's model — the AI draft runs on this exact model. */ - chatModelId?: string | null; /** * Called the instant `createFromLane` resolves, with the freshly-created PR. * The parent swaps to the live PR-details view immediately instead of waiting @@ -109,7 +98,6 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [busy, setBusy] = useState(false); - const [drafting, setDrafting] = useState(false); const [error, setError] = useState<string | null>(null); const targetTouchedRef = useRef(false); @@ -130,15 +118,16 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ () => (targetLane ? branchNameFromRef(targetLane.branchRef) : defaultBase) || "", [targetLane, defaultBase], ); + const defaultTitle = useMemo(() => { + const targetName = targetLane?.name?.trim() || resolvedBaseBranch || "target"; + return `${laneName} -> ${targetName}`; + }, [laneName, resolvedBaseBranch, targetLane?.name]); - // Default the title: Linear-flavored when linked, else lane name / branch. + // Default the title to the merge direction until the user types their own. useEffect(() => { if (titleTouchedRef.current) return; - const next = linearIssue - ? buildLinearPrTitle(linearIssue) - : lane?.name?.trim() || branchName?.trim() || ""; - if (next) setTitle(next); - }, [linearIssue, lane?.name, branchName]); + setTitle(defaultTitle); + }, [defaultTitle]); // Seed the body with the Linear reference line when linked (idempotent with // prService's server-side linkage, which owns the canonical trailers). @@ -153,44 +142,11 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ "", ); - const handleDraftAI = useCallback(async () => { - setDrafting(true); - setError(null); - try { - // Runs the pr_descriptions background job on the chat's active model and - // surfaces real failures (requireAi) instead of returning a stub template. - const result = await window.ade.prs.draftDescription({ - laneId, - requireAi: true, - ...(resolvedBaseBranch ? { baseBranch: resolvedBaseBranch } : {}), - ...(chatModelId ? { model: chatModelId } : {}), - }); - const nextTitle = - linearIssue && !result.title.includes(linearIssue.identifier) - ? buildLinearPrTitle(linearIssue) - : result.title; - const nextBody = linearIssue - ? ensureLinearPrReference(result.body, linearIssue, true, { preserveExisting: false }) - : result.body; - titleTouchedRef.current = true; - bodyTouchedRef.current = true; - setTitle(nextTitle); - setBody(nextBody); - } catch (err: unknown) { - setError(cleanError(err)); - } finally { - setDrafting(false); - } - }, [resolvedBaseBranch, laneId, linearIssue, chatModelId]); - const handleCreate = useCallback(async () => { setBusy(true); setError(null); try { - const resolvedTitle = - linearIssue && !title.trim() - ? buildLinearPrTitle(linearIssue) - : title.trim() || lane?.name || branchName || "PR"; + const resolvedTitle = title.trim() || defaultTitle; const created = await window.ade.prs.createFromLane({ laneId, title: resolvedTitle, @@ -214,7 +170,7 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ setError(cleanError(err)); setBusy(false); } - }, [body, branchName, lane?.name, laneId, linearIssue, onCreated, resolvedBaseBranch, title]); + }, [body, defaultTitle, laneId, linearIssue, onCreated, resolvedBaseBranch, title]); // The full composer (queue / integration + multi-lane ordering) lives in the // PRs tab; this just hands off with the lane pre-selected. @@ -223,7 +179,7 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ navigate(`/prs?${params.toString()}`); }, [laneId, navigate]); - const interactive = !busy && !drafting; + const interactive = !busy; return ( <div className="flex flex-col gap-3.5"> @@ -232,15 +188,15 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ <div className="flex flex-col gap-1.5"> <span className={sectionLabel}>Source lane and branch</span> <div - className="flex min-h-[40px] items-center gap-1.5 rounded-[6px] bg-white/[0.02] px-2 py-1" + className="flex min-h-[46px] items-center justify-center gap-1.5 rounded-[6px] bg-white/[0.02] px-2 py-1 text-center" style={{ border: "1px solid var(--work-pane-border)" }} > <LaneLogoMark color={sourceColor} size={12} /> - <div className="flex min-w-0 flex-1 flex-col leading-[1.2]"> - <span className="truncate text-[12px] font-semibold" style={{ color: sourceColor }} title={laneName}> + <div className="flex min-w-0 flex-initial flex-col items-center leading-[1.2]"> + <span className="max-w-[190px] truncate text-[12px] font-semibold" style={{ color: sourceColor }} title={laneName}> {laneName} </span> - <span className="mt-0.5 inline-flex min-w-0 items-center gap-1 text-[10px] text-muted-fg/85" title={sourceBranch}> + <span className="mt-0.5 inline-flex max-w-[190px] min-w-0 items-center justify-center gap-1 text-[10px] text-muted-fg/85" title={sourceBranch}> <BranchIcon size={9} weight="regular" className="shrink-0 opacity-55" /> <span className="min-w-0 truncate font-mono">{sourceBranch}</span> </span> @@ -268,7 +224,7 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ {/* Comparison — compact ahead / behind / clean from lane.status. */} {lane?.status ? ( - <div className="flex items-center gap-3 text-[11px]"> + <div className="flex items-center justify-center gap-4 text-[11px]"> <span><span className="font-semibold text-fg/85">{lane.status.ahead}</span> <span className="text-fg/40">ahead</span></span> <span><span className="font-semibold text-fg/85">{lane.status.behind}</span> <span className="text-fg/40">behind</span></span> <span @@ -282,23 +238,7 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ {/* Title. */} <div className="flex flex-col gap-1.5"> - <div className="flex items-center justify-between"> - <label htmlFor="chat-pr-title" className={sectionLabel}>Title</label> - <button - type="button" - onClick={() => void handleDraftAI()} - disabled={!interactive} - title="Draft title & description with AI (uses this chat's model)" - className="inline-flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wide text-[color:var(--color-accent)] transition-opacity hover:opacity-80 disabled:opacity-50" - > - {drafting ? ( - <CircleNotch size={11} weight="bold" className="animate-spin" /> - ) : ( - <Sparkle size={11} weight="fill" /> - )} - {drafting ? "Drafting…" : "AI draft"} - </button> - </div> + <label htmlFor="chat-pr-title" className={sectionLabel}>Title</label> <input id="chat-pr-title" value={title} @@ -307,7 +247,7 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ setTitle(e.target.value); }} disabled={!interactive} - placeholder={lane?.name ?? branchName ?? "Pull request title"} + placeholder={defaultTitle} className={inputBase} /> </div> diff --git a/apps/desktop/src/renderer/components/chat/ChatPrPane.test.tsx b/apps/desktop/src/renderer/components/chat/ChatPrPane.test.tsx index 5c1cd0b20..0651d6341 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPrPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatPrPane.test.tsx @@ -233,7 +233,7 @@ describe("ChatPrPane", () => { render( <MemoryRouter> - <ChatPrPane laneId="lane-1" branchName="feature/pr-pane" chatModelId="openai/gpt-5" /> + <ChatPrPane laneId="lane-1" branchName="feature/pr-pane" /> </MemoryRouter>, ); @@ -290,7 +290,7 @@ describe("ChatPrPane", () => { const { rerender } = render( <MemoryRouter> - <ChatPrPane laneId="lane-1" branchName="feature/pr-pane" chatModelId="openai/gpt-5" /> + <ChatPrPane laneId="lane-1" branchName="feature/pr-pane" /> </MemoryRouter>, ); @@ -298,7 +298,7 @@ describe("ChatPrPane", () => { rerender( <MemoryRouter> - <ChatPrPane laneId="lane-2" branchName="feature/pr-pane-2" chatModelId="openai/gpt-5" /> + <ChatPrPane laneId="lane-2" branchName="feature/pr-pane-2" /> </MemoryRouter>, ); diff --git a/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx b/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx index 445949a3c..a17c58f30 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx @@ -365,13 +365,10 @@ function PrDetails({ export const ChatPrPane = React.memo(function ChatPrPane({ laneId, branchName, - chatModelId, delta = null, }: { laneId: string; branchName?: string | null; - /** The active chat session's model — forwarded to the inline creator's AI draft. */ - chatModelId?: string | null; /** Describes the PR change that triggered this pane's auto-pop (owned by the parent). */ delta?: ChatPrDelta | null; /** Retained for the caller's toggle wiring; the pane no longer renders its own close affordance (the header PR pill toggles it). */ @@ -567,7 +564,6 @@ export const ChatPrPane = React.memo(function ChatPrPane({ <ChatPrInlineCreator laneId={laneId} branchName={branchName ?? null} - chatModelId={chatModelId ?? null} onCreated={handleCreated} /> )} diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx index 28c492fc2..a00bba828 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.test.tsx @@ -3,7 +3,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { ChatTerminalDrawer, ChatTerminalToggle } from "./ChatTerminalDrawer"; +import { ChatTerminalDrawer } from "./ChatTerminalDrawer"; vi.mock("../terminals/TerminalView", () => { const ReactMod = require("react") as typeof import("react"); @@ -117,18 +117,6 @@ describe("ChatTerminalDrawer", () => { expect(screen.getAllByText(/^Terminal \d+$/)).toHaveLength(1); }); - it("toggles the terminal drawer open and closed", () => { - const onToggle = vi.fn(); - const view = render(<ChatTerminalToggle open={false} onToggle={onToggle} />); - - fireEvent.click(screen.getByTitle("Open terminal")); - expect(onToggle).toHaveBeenCalledTimes(1); - - view.rerender(<ChatTerminalToggle open onToggle={onToggle} />); - fireEvent.click(screen.getByTitle("Close terminal")); - expect(onToggle).toHaveBeenCalledTimes(2); - }); - it("does not restore terminal tabs while the drawer is closed", async () => { render( <ChatTerminalDrawer diff --git a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx index 73c8f5d12..83f553db6 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTerminalDrawer.tsx @@ -521,11 +521,11 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ <div className={cn( - "flex shrink-0 items-center overflow-x-auto border-b border-white/[0.06] bg-black/10", - isPanel ? "h-8" : "h-6", + "flex shrink-0 items-stretch overflow-x-auto border-b border-white/[0.07] bg-[var(--color-surface-recessed)] px-1", + isPanel ? "h-9" : "h-7", )} > - <div className="flex min-w-0 items-center gap-0 overflow-x-auto scrollbar-none"> + <div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto scrollbar-none"> {tabs.map((tab) => { const appControlTone = appControlTabState && appControlTabState.terminalSessionId === tab.sessionId ? appControlTabState.tone @@ -535,11 +535,11 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ <div key={tab.id} className={cn( - "group relative flex shrink-0 items-center gap-1 border-r border-white/[0.04] px-2 font-mono text-[10px] transition-colors", - isPanel ? "h-8" : "h-6", + "group relative flex shrink-0 items-center gap-1 rounded-t-md border border-transparent px-2 font-mono text-[10px] transition-colors", + isPanel ? "h-9" : "h-7", isActive - ? "bg-white/[0.06] text-fg/85" - : "bg-transparent text-fg/35 hover:bg-white/[0.03] hover:text-fg/60", + ? "border-white/[0.07] border-b-transparent bg-black/[0.18] text-fg/85" + : "bg-transparent text-fg/38 hover:bg-white/[0.035] hover:text-fg/65", appControlTone ? "pl-4" : null, )} title={appControlTone ? appControlTabState?.title : undefined} @@ -603,8 +603,8 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ type="button" onClick={() => { void createTab(); }} className={cn( - "mx-1 flex shrink-0 items-center justify-center rounded-md border border-white/[0.06] text-white/30 transition-colors hover:bg-white/[0.04] hover:text-white/60", - isPanel ? "h-7 w-7" : "h-6 w-6", + "my-1 ml-1 flex shrink-0 items-center justify-center rounded-md border border-white/[0.07] bg-white/[0.025] text-white/34 transition-colors hover:bg-white/[0.055] hover:text-white/65 disabled:cursor-default disabled:opacity-45", + isPanel ? "h-7 w-7" : "h-5 w-5", )} title="New terminal" disabled={creatingTab} @@ -625,8 +625,17 @@ export const ChatTerminalDrawer = memo(function ChatTerminalDrawer({ className="h-full w-full" /> ) : ( - <div className="flex h-full items-center justify-center px-4 font-mono text-[11px] text-muted-fg"> - {emptyMessage} + <div className="flex h-full items-center justify-center px-4"> + <button + type="button" + onClick={() => { void createTab(); }} + disabled={creatingTab} + title={emptyMessage} + className="inline-flex h-8 items-center gap-2 rounded-md border border-white/[0.08] bg-white/[0.035] px-3 font-sans text-[12px] font-medium text-fg/72 transition-colors hover:border-violet-400/24 hover:bg-violet-500/[0.08] hover:text-fg disabled:cursor-default disabled:opacity-45" + > + <Plus size={13} weight="bold" /> + <span>New terminal</span> + </button> </div> )} </div> diff --git a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx index d8f60e8b9..b02ee877e 100644 --- a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx @@ -10,9 +10,15 @@ type ChatUserMinimapProps = { displayEntries: ChatUserMinimapDisplayEntry[]; activeDisplayIndex: number | null; onJumpToRow: (rowIndex: number) => void; + placement?: "inside" | "outsideRight"; }; -export function ChatUserMinimap({ displayEntries, activeDisplayIndex, onJumpToRow }: ChatUserMinimapProps) { +export function ChatUserMinimap({ + displayEntries, + activeDisplayIndex, + onJumpToRow, + placement = "inside", +}: ChatUserMinimapProps) { const chatUserMinimapEnabled = useAppStore((s) => s.chatUserMinimapEnabled); const [menuOpen, setMenuOpen] = useState(false); const openTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); @@ -69,9 +75,69 @@ export function ChatUserMinimap({ displayEntries, activeDisplayIndex, onJumpToRo return null; } + const dotRail = ( + <div className="flex flex-col items-center gap-1 py-0.5" aria-hidden={menuOpen}> + {displayEntries.map((entry, index) => { + const active = activeDisplayIndex != null && index === activeDisplayIndex; + return ( + <button + key={entry.key} + type="button" + title={entry.preview} + aria-label={`User message ${index + 1}`} + aria-current={active ? "true" : undefined} + className={cn( + "h-2 w-1.5 shrink-0 rounded-full transition-colors", + active ? "bg-[var(--chat-accent)]" : "bg-fg/25 hover:bg-fg/45", + )} + onClick={() => handleSelect(entry.rowIndex)} + /> + ); + })} + </div> + ); + + const menu = menuOpen ? ( + <div + className="max-h-[min(22rem,65vh)] w-[min(22rem,calc(100vw-6rem))] overflow-y-auto rounded-md border border-white/[0.06] bg-[color:rgb(12,12,16)]/95 px-1 py-1" + role="menu" + aria-label="Jump to user message" + > + <ul className="flex flex-col gap-0.5 p-0.5"> + {displayEntries.map((entry, index) => { + const active = activeDisplayIndex != null && index === activeDisplayIndex; + return ( + <li key={entry.key}> + <button + type="button" + role="menuitem" + className={cn( + "w-full rounded px-2 py-1.5 text-left font-sans text-[11px] leading-snug transition-colors", + active + ? "bg-[color:color-mix(in_srgb,var(--chat-accent)_18%,transparent)] text-fg" + : "text-fg/80 hover:bg-white/[0.06]", + )} + onClick={() => handleSelect(entry.rowIndex)} + > + <span className="line-clamp-3">{entry.preview}</span> + </button> + </li> + ); + })} + </ul> + </div> + ) : null; + + const outside = placement === "outsideRight"; + return ( <div - className="pointer-events-none absolute right-3 top-3 z-20 flex flex-col items-end" + className={cn( + "pointer-events-none absolute top-3 z-20 flex flex-col", + outside + ? "left-full ml-3 items-start max-xl:left-auto max-xl:right-3 max-xl:ml-0 max-xl:items-end" + : "right-3 items-end", + )} role="region" aria-label="User message minimap" > @@ -80,55 +146,8 @@ export function ChatUserMinimap({ displayEntries, activeDisplayIndex, onJumpToRo onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > - {menuOpen ? ( - <div - className="max-h-[min(22rem,65vh)] w-[min(22rem,calc(100vw-6rem))] overflow-y-auto rounded-md border border-white/[0.06] bg-[color:rgb(12,12,16)]/95 px-1 py-1" - role="menu" - aria-label="Jump to user message" - > - <ul className="flex flex-col gap-0.5 p-0.5"> - {displayEntries.map((entry, index) => { - const active = activeDisplayIndex != null && index === activeDisplayIndex; - return ( - <li key={entry.key}> - <button - type="button" - role="menuitem" - className={cn( - "w-full rounded px-2 py-1.5 text-left font-sans text-[11px] leading-snug transition-colors", - active - ? "bg-[color:color-mix(in_srgb,var(--chat-accent)_18%,transparent)] text-fg" - : "text-fg/80 hover:bg-white/[0.06]", - )} - onClick={() => handleSelect(entry.rowIndex)} - > - <span className="line-clamp-3">{entry.preview}</span> - </button> - </li> - ); - })} - </ul> - </div> - ) : null} - <div className="flex flex-col items-center gap-1 py-0.5" aria-hidden={menuOpen}> - {displayEntries.map((entry, index) => { - const active = activeDisplayIndex != null && index === activeDisplayIndex; - return ( - <button - key={entry.key} - type="button" - title={entry.preview} - aria-label={`User message ${index + 1}`} - aria-current={active ? "true" : undefined} - className={cn( - "h-2 w-1.5 shrink-0 rounded-full transition-colors", - active ? "bg-[var(--chat-accent)]" : "bg-fg/25 hover:bg-fg/45", - )} - onClick={() => handleSelect(entry.rowIndex)} - /> - ); - })} - </div> + {outside ? dotRail : menu} + {outside ? menu : dotRail} </div> </div> ); diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx index c0db7f71f..9fb918948 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.test.tsx @@ -254,7 +254,7 @@ describe("CreatePrModal queue workflow", () => { expect(targetInput?.value).toBe("main"); }); - it("defaults single-PR title and body from a linked Linear issue", async () => { + it("defaults the single-PR title from the lane target while keeping Linear in the body", async () => { const user = userEvent.setup(); renderWithRouter(<CreatePrModal open onOpenChange={vi.fn()} />); @@ -263,7 +263,7 @@ describe("CreatePrModal queue workflow", () => { await user.click(screen.getByRole("button", { name: /next step/i })); - expect(screen.getByDisplayValue("ADE-123: Connect Linear issue dropdown")).toBeTruthy(); + expect(screen.getByDisplayValue("Linear linked lane -> main")).toBeTruthy(); expect(screen.getByDisplayValue(/Fixes ADE-123/)).toBeTruthy(); expect(screen.getByText(/PR body will include Fixes ADE-123/i)).toBeTruthy(); @@ -273,7 +273,7 @@ describe("CreatePrModal queue workflow", () => { expect(createFromLane).toHaveBeenCalledWith( expect.objectContaining({ laneId: "lane-linear", - title: "ADE-123: Connect Linear issue dropdown", + title: "Linear linked lane -> main", body: expect.stringContaining("Fixes ADE-123"), closeLinearIssueOnMerge: true, }), diff --git a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx index 78f402836..3f1cd1a05 100644 --- a/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx +++ b/apps/desktop/src/renderer/components/prs/CreatePrModal.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useNavigate } from "react-router-dom"; import * as Dialog from "@radix-ui/react-dialog"; -import { GitPullRequest, GitMerge, Stack as Layers, CheckCircle, Warning, CircleNotch, X, Sparkle, ArrowRight, ArrowLeft, Check, DotsSixVertical, Trash, ArrowUp, ArrowDown } from "@phosphor-icons/react"; +import { GitPullRequest, GitMerge, Stack as Layers, CheckCircle, Warning, CircleNotch, X, ArrowRight, ArrowLeft, Check, DotsSixVertical, Trash, ArrowUp, ArrowDown } from "@phosphor-icons/react"; import { BranchIcon } from "../ui/vcsIcons"; import { useAppStore } from "../../state/appStore"; import type { @@ -16,7 +16,6 @@ import type { } from "../../../shared/types"; import { buildLinearPrReference, - buildLinearPrTitle, ensureLinearPrReference, } from "../../../shared/linearMagicWords"; import { COLORS, MONO_FONT, LABEL_STYLE } from "../lanes/laneDesignTokens"; @@ -535,7 +534,7 @@ export function CreatePrModal({ const [normalBaseBranch, setNormalBaseBranch] = React.useState(""); const [normalCloseLinearIssueOnMerge, setNormalCloseLinearIssueOnMerge] = React.useState(true); const normalBaseBranchDefaultRef = React.useRef(""); - const normalLinearTitleDefaultRef = React.useRef(""); + const normalTitleDefaultRef = React.useRef(""); const normalLinearBodyDefaultRef = React.useRef(""); // Queue PRs @@ -544,9 +543,8 @@ export function CreatePrModal({ const [queueDragLaneId, setQueueDragLaneId] = React.useState<string | null>(null); const [queueTargetBranch, setQueueTargetBranch] = React.useState(""); - // Body & AI draft + // Body const [normalBody, setNormalBody] = React.useState(""); - const [drafting, setDrafting] = React.useState(false); // Integration PR const [integrationSources, setIntegrationSources] = React.useState<string[]>([]); @@ -562,8 +560,6 @@ export function CreatePrModal({ const [laneSyncLoadingById, setLaneSyncLoadingById] = React.useState<Record<string, boolean>>({}); const [knownPrs, setKnownPrs] = React.useState<PrSummary[]>([]); - const [draftError, setDraftError] = React.useState<string | null>(null); - // Available branches for target-branch dropdowns const [availableBranches, setAvailableBranches] = React.useState<GitBranchSummary[]>([]); const [branchLoadError, setBranchLoadError] = React.useState<string | null>(null); @@ -620,24 +616,6 @@ export function CreatePrModal({ return [v, ...targetBranchOptions.filter((b) => b !== v)]; }, [targetBranchOptions, integrationBaseBranch]); - const handleDraftAI = async (laneId: string) => { - setDrafting(true); - setDraftError(null); - try { - const result = await window.ade.prs.draftDescription({ laneId }); - if (mode === "normal") { - const lane = lanes.find((entry) => entry.id === laneId) ?? null; - const issue = lane?.linearIssue ?? null; - setNormalTitle(issue && !result.title.includes(issue.identifier) ? buildLinearPrTitle(issue) : result.title); - setNormalBody(issue ? ensureLinearPrReference(result.body, issue, normalCloseLinearIssueOnMerge, { preserveExisting: false }) : result.body); - } - } catch (err: unknown) { - setDraftError(err instanceof Error ? err.message : String(err)); - } finally { - setDrafting(false); - } - }; - // Execute const [busy, setBusy] = React.useState(false); const [execError, setExecError] = React.useState<string | null>(null); @@ -666,7 +644,7 @@ export function CreatePrModal({ setNormalTitle(""); setNormalDraft(false); setNormalCloseLinearIssueOnMerge(true); - normalLinearTitleDefaultRef.current = ""; + normalTitleDefaultRef.current = ""; normalLinearBodyDefaultRef.current = ""; setQueueLaneIds([]); setQueueDraft(false); @@ -676,8 +654,6 @@ export function CreatePrModal({ setExecError(null); setResults(null); setNormalBody(""); - setDrafting(false); - setDraftError(null); setIntegrationSources([]); setIntegrationBaseBranch(""); setIntegrationMergeIntoLaneId(""); @@ -753,38 +729,31 @@ export function CreatePrModal({ [lanes, normalLaneId], ); const selectedNormalLinearIssue = selectedNormalLane?.linearIssue ?? null; + const normalTargetLabel = React.useMemo(() => { + const targetBranch = branchNameFromRef(normalBaseBranch.trim() || primaryLane?.branchRef || "main"); + const targetLane = lanes.find((lane) => branchNameFromRef(lane.branchRef) === targetBranch); + return targetLane?.name ?? (targetBranch || "target"); + }, [lanes, normalBaseBranch, primaryLane?.branchRef]); + const normalDefaultTitle = React.useMemo(() => { + if (!selectedNormalLane) return ""; + return `${selectedNormalLane.name} -> ${normalTargetLabel}`; + }, [normalTargetLabel, selectedNormalLane]); React.useEffect(() => { if (!open) return; if (!selectedNormalLinearIssue) { // Lane no longer has a linked Linear issue — clear any auto-generated - // title/body fragments and reset the close-on-merge toggle so stale + // body fragments and reset the close-on-merge toggle so stale // Linear-flavored values don't follow the user to a non-Linear lane. - const previousAutoTitle = normalLinearTitleDefaultRef.current; - setNormalTitle((current) => - previousAutoTitle && current.trim() === previousAutoTitle.trim() ? "" : current, - ); const previousAutoBody = normalLinearBodyDefaultRef.current; setNormalBody((current) => previousAutoBody && current.trim() === previousAutoBody.trim() ? "" : current, ); setNormalCloseLinearIssueOnMerge(true); - normalLinearTitleDefaultRef.current = ""; normalLinearBodyDefaultRef.current = ""; return; } - const nextTitle = buildLinearPrTitle(selectedNormalLinearIssue); - setNormalTitle((current) => { - const previousAutoTitle = normalLinearTitleDefaultRef.current; - if (!current.trim() || (previousAutoTitle && current === previousAutoTitle)) { - normalLinearTitleDefaultRef.current = nextTitle; - return nextTitle; - } - normalLinearTitleDefaultRef.current = nextTitle; - return current; - }); - const nextBody = `${buildLinearPrReference(selectedNormalLinearIssue, normalCloseLinearIssueOnMerge)}\n`; setNormalBody((current) => { const previousAutoBody = normalLinearBodyDefaultRef.current; @@ -797,6 +766,19 @@ export function CreatePrModal({ }); }, [open, normalCloseLinearIssueOnMerge, selectedNormalLinearIssue]); + React.useEffect(() => { + if (!open || !normalDefaultTitle) return; + setNormalTitle((current) => { + const previousDefault = normalTitleDefaultRef.current; + if (!current.trim() || (previousDefault && current === previousDefault)) { + normalTitleDefaultRef.current = normalDefaultTitle; + return normalDefaultTitle; + } + normalTitleDefaultRef.current = normalDefaultTitle; + return current; + }); + }, [normalDefaultTitle, open]); + React.useEffect(() => { if (!open) return; const primaryBranch = branchNameFromRef(primaryLane?.branchRef ?? "main"); @@ -904,9 +886,7 @@ export function CreatePrModal({ if (mode === "normal") { const lane = lanes.find((l) => l.id === normalLaneId); const linearIssue = lane?.linearIssue ?? null; - const title = linearIssue && !normalTitle.trim() - ? buildLinearPrTitle(linearIssue) - : normalTitle || lane?.name || "PR"; + const title = normalTitle.trim() || normalDefaultTitle || lane?.name || "PR"; const body = linearIssue ? ensureLinearPrReference(normalBody, linearIssue, normalCloseLinearIssueOnMerge, { preserveExisting: false }) : normalBody; @@ -1043,13 +1023,11 @@ export function CreatePrModal({ const goToStep2 = () => { setExecError(null); - setDraftError(null); setNumericStep(2); }; const goBackToStep1 = () => { setExecError(null); - setDraftError(null); setNumericStep(1); }; @@ -1985,7 +1963,7 @@ export function CreatePrModal({ value={normalTitle} onChange={(e) => setNormalTitle(e.target.value)} style={inputStyle} - placeholder="Auto-generated from lane name" + placeholder={normalDefaultTitle || "Source lane -> target"} data-tour="prs.createModal.title" onFocus={(e) => { e.currentTarget.style.borderColor = C.accent; }} onBlur={(e) => { e.currentTarget.style.borderColor = C.borderSubtle; }} @@ -1993,42 +1971,7 @@ export function CreatePrModal({ </div> <div> - <div style={{ - display: "flex", - alignItems: "center", - justifyContent: "space-between", - marginBottom: 8, - }}> - <span style={{ ...labelStyle, marginBottom: 0 }}>DESCRIPTION</span> - <button - disabled={!normalLaneId || drafting} - onClick={() => void handleDraftAI(normalLaneId)} - style={{ - background: "transparent", - border: `1px solid ${C.accentBorder}`, - borderRadius: 0, - color: (!normalLaneId || drafting) ? C.textDisabled : C.accent, - fontFamily: "var(--font-sans)", - fontSize: 10, - fontWeight: 700, - textTransform: "uppercase" as const, - letterSpacing: "1px", - padding: "6px 12px", - cursor: (!normalLaneId || drafting) ? "not-allowed" : "pointer", - opacity: (!normalLaneId || drafting) ? 0.5 : 1, - display: "flex", - alignItems: "center", - gap: 6, - }} - > - {drafting ? ( - <CircleNotch size={12} className="animate-spin" /> - ) : ( - <Sparkle size={12} weight="fill" /> - )} - {drafting ? "DRAFTING..." : "DRAFT DESCRIPTION"} - </button> - </div> + <span style={labelStyle}>DESCRIPTION</span> <textarea value={normalBody} onChange={(e) => setNormalBody(e.target.value)} @@ -2287,10 +2230,6 @@ export function CreatePrModal({ </> )} - {draftError && ( - <div style={errorBannerStyle}>Draft failed: {draftError}</div> - )} - {execError && ( <div style={errorBannerStyle}>{execError}</div> )} diff --git a/apps/desktop/src/renderer/components/run/RunPage.test.tsx b/apps/desktop/src/renderer/components/run/RunPage.test.tsx index 7a1a94f2f..ff48fd467 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.test.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.test.tsx @@ -463,7 +463,7 @@ describe("RunPage Advanced lane runtime drawer", () => { render(<RunPage />); fireEvent.click(screen.getByRole("button", { name: /open terminal/i })); - expect(await screen.findByText("Open a shell or run a command to attach a terminal.")).toBeTruthy(); + expect((await screen.findAllByRole("button", { name: /new terminal/i })).length).toBeGreaterThan(0); expect(vi.mocked((window as unknown as { ade: { pty: { create: ReturnType<typeof vi.fn> } } }).ade.pty.create)).not.toHaveBeenCalled(); }); diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx index df2da854b..44c879442 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.test.tsx @@ -6,12 +6,12 @@ import { MemoryRouter, useLocation } from "react-router-dom"; import { AiFeaturesSection } from "./AiFeaturesSection"; const modelPickerProps = vi.hoisted(() => - [] as Array<{ surfaceKey: string; onOpenSignIn?: () => void }>, + [] as Array<{ surfaceKey: string; onOpenSignIn?: (family?: unknown) => void }>, ); vi.mock("../shared/ModelPicker/ModelPicker", () => { return { - ModelPicker: (props: { surfaceKey: string; onOpenSignIn?: () => void }) => { + ModelPicker: (props: { surfaceKey: string; onOpenSignIn?: (family?: unknown) => void }) => { modelPickerProps.push(props); return ( <button type="button" onClick={props.onOpenSignIn}> diff --git a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx index 3d1eb5a2c..331d2bb8b 100644 --- a/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/AiFeaturesSection.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; import type { AiFeatureKey, AiConfig, @@ -17,6 +16,7 @@ import { getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ChatCircleDots, GitPullRequest, GitCommit, ChatText, type Icon } from "@phosphor-icons/react"; +import { useOpenProviderSignIn } from "../shared/useOpenProviderSignIn"; type FeatureInfo = { key: AiFeatureKey; @@ -120,10 +120,7 @@ function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean } export function AiFeaturesSection() { - const navigate = useNavigate(); - const openAiProvidersSettings = useCallback(() => { - navigate("/settings?tab=ai#ai-providers"); - }, [navigate]); + const openProviderSignIn = useOpenProviderSignIn(); const [status, setStatus] = useState<AiSettingsStatus | null>(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -444,7 +441,7 @@ export function AiFeaturesSection() { onChange={(modelId) => void handleModelChange(feature.key, modelId)} surfaceKey={`ai-feature-${feature.key}`} availableModelIds={availableModelIds} - onOpenSignIn={openAiProvidersSettings} + onOpenSignIn={openProviderSignIn} disabled={!enabled} /> <ReasoningEffortPicker @@ -552,7 +549,7 @@ export function AiFeaturesSection() { }} surfaceKey="ai-feature-chat-auto-title" availableModelIds={availableModelIds} - onOpenSignIn={openAiProvidersSettings} + onOpenSignIn={openProviderSignIn} disabled={!chatAutoTitleEnabled} /> <ReasoningEffortPicker diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index 0db36f7fd..39b296af7 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ProvidersSection } from "./ProvidersSection"; import type { AgentChatEventEnvelope, AiSettingsStatus } from "../../../shared/types"; @@ -179,6 +180,14 @@ function buildStatus( }; } +function renderProvidersSection() { + return render( + <MemoryRouter> + <ProvidersSection /> + </MemoryRouter>, + ); +} + describe("ProvidersSection", () => { const originalAde = globalThis.window.ade; let emitChatEvent: ((envelope: AgentChatEventEnvelope) => void) | null = null; @@ -232,7 +241,7 @@ describe("ProvidersSection", () => { }); it("refreshes provider status after an auth-related chat failure", async () => { - render(<ProvidersSection />); + renderProvidersSection(); const ade = window.ade as any; await waitFor(() => { @@ -268,7 +277,7 @@ describe("ProvidersSection", () => { }); it("shows Ready while the bundled Claude runtime is authenticated", async () => { - render(<ProvidersSection />); + renderProvidersSection(); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -288,7 +297,7 @@ describe("ProvidersSection", () => { claudeAuthReady: false, })); - render(<ProvidersSection />); + renderProvidersSection(); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -300,7 +309,7 @@ describe("ProvidersSection", () => { }); it("renders local runtime details and loaded local models", async () => { - render(<ProvidersSection />); + renderProvidersSection(); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -326,7 +335,7 @@ describe("ProvidersSection", () => { }), ); - const view = render(<ProvidersSection />); + const view = renderProvidersSection(); const current = within(view.container); await waitFor(() => { @@ -349,7 +358,7 @@ describe("ProvidersSection", () => { .mockResolvedValueOnce([]) .mockResolvedValue(["cursor"]); - render(<ProvidersSection />); + renderProvidersSection(); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -392,7 +401,7 @@ describe("ProvidersSection", () => { verifiedAt: "2026-03-17T19:00:00.000Z", }); - render(<ProvidersSection />); + renderProvidersSection(); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); @@ -429,7 +438,7 @@ describe("ProvidersSection", () => { listApiKeysMock.mockReset(); listApiKeysMock.mockResolvedValue(["cursor"]); - render(<ProvidersSection />); + renderProvidersSection(); await waitFor(() => { expect(window.ade.ai.getStatus).toHaveBeenCalledTimes(1); diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 5e151aef5..8e1a15b75 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import type { AiConfig, AiApiKeyVerificationResult, @@ -39,6 +40,7 @@ import { import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; import { shouldRefreshAiStatusForChatEvent } from "../../lib/aiProviderStatus"; +import { ClaudeLoginPromptButton, revealTerminalSessionInWork } from "../work/ClaudeLoginPromptButton"; type CliName = "claude" | "codex" | "cursor" | "droid"; type ApiKeySource = "config" | "env" | "store"; @@ -287,6 +289,7 @@ function buildLocalProviderDrafts( } export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefreshOnMount?: boolean }) { + const navigate = useNavigate(); const [status, setStatus] = useState<(AiSettingsStatus & { runtimeConnections?: Record<string, AiRuntimeConnectionStatus> }) | null>(null); const [projectConfigSnapshot, setProjectConfigSnapshot] = useState<ProjectConfigSnapshot | null>(null); const [storedProviders, setStoredProviders] = useState<string[]>([]); @@ -304,6 +307,9 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh const [verifyingProvider, setVerifyingProvider] = useState<string | null>(null); const [verificationByProvider, setVerificationByProvider] = useState<Record<string, AiApiKeyVerificationResult>>({}); const pendingRefreshTimerRef = useRef<number | null>(null); + const revealClaudeLoginTerminalInWork = useCallback((terminal: { terminalId: string; laneId: string }) => { + revealTerminalSessionInWork(navigate, terminal); + }, [navigate]); const refreshStatus = useCallback(async (options?: { force?: boolean; silent?: boolean; refreshOpenCodeInventory?: boolean }) => { if (!options?.silent) { @@ -711,6 +717,16 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh </div> </div> <div style={{ fontSize: 10, fontFamily: MONO_FONT, color: COLORS.textMuted, lineHeight: 1.5, marginTop: 10 }}>{message}</div> + {!isInitialCheckInFlight && availability?.binary.present && !availability.auth.ready ? ( + <div style={{ display: "flex", marginTop: 10 }}> + <ClaudeLoginPromptButton + visible + storageKey="settings:claude-auth" + dismissible={false} + onTerminalCreated={revealClaudeLoginTerminalInWork} + /> + </div> + ) : null} {credentialSourceDesc && !availability?.auth.ready && !isInitialCheckInFlight ? <div style={{ fontSize: 10, fontFamily: MONO_FONT, color: COLORS.info, marginTop: 4 }}>{credentialSourceDesc}</div> : null} {binaryPath && !isInitialCheckInFlight ? <code style={{ display: "block", marginTop: 6, fontSize: 10, fontFamily: MONO_FONT, color: COLORS.textSecondary, background: "color-mix(in srgb, var(--color-muted-fg) 12%, transparent)", border: `1px solid ${COLORS.border}`, padding: "6px 8px", overflowWrap: "anywhere", wordBreak: "break-all" }}>{binaryPath}</code> : null} </section> diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx index ab42bc936..d213a2e45 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.test.tsx @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { createDynamicCursorCliModelDescriptor, type ModelDescriptor } from "../../../../shared/modelRegistry"; +import { + createDynamicCursorCliModelDescriptor, + createDynamicOpenCodeModelDescriptor, + type ModelDescriptor, +} from "../../../../shared/modelRegistry"; import type { AgentChatModelCatalog } from "../../../../shared/types"; vi.mock("@lobehub/icons", () => { @@ -673,7 +677,30 @@ describe("ModelPicker", () => { expect(banner).toBeTruthy(); expect(banner.getAttribute("data-provider-family")).toBe("anthropic"); await user.click(banner); - expect(onOpenSignIn).toHaveBeenCalledOnce(); + expect(onOpenSignIn).toHaveBeenCalledWith("anthropic"); + expect(screen.getByRole("button", { name: /Select model/i }).getAttribute("aria-expanded")).toBe("false"); + }); + + it("passes row auth type when opening sign-in for unavailable Anthropic models", async () => { + const user = userEvent.setup(); + const anthropicApiKeyModel = createDynamicOpenCodeModelDescriptor("", { + openCodeProviderId: "anthropic", + openCodeModelId: "claude-sonnet-5", + displayName: "Claude Sonnet via API key", + }); + const onOpenSignIn = vi.fn(); + renderPicker({ + value: anthropicApiKeyModel.id, + models: [anthropicApiKeyModel], + availableModelIds: [], + onOpenSignIn, + }); + + await user.click(screen.getByRole("button", { name: /Select model/i })); + await user.click(screen.getByRole("button", { name: /Sign in/i })); + + expect(anthropicApiKeyModel.family).toBe("anthropic"); + expect(onOpenSignIn).toHaveBeenCalledWith("anthropic", ["api-key"]); expect(screen.getByRole("button", { name: /Select model/i }).getAttribute("aria-expanded")).toBe("false"); }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx index b8dd1a1fd..5cd67096d 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPicker.tsx @@ -3,6 +3,7 @@ import * as Popover from "@radix-ui/react-popover"; import { CaretDown, Lightning } from "@phosphor-icons/react"; import { modelSupportsFastMode, + type AuthType, type ModelDescriptor, type ProviderFamily, } from "../../../../shared/modelRegistry"; @@ -38,7 +39,7 @@ export type ModelPickerProps = { filter?: (model: ModelDescriptor) => boolean; models?: readonly ModelDescriptor[]; providerAuthStatus?: Partial<Record<ProviderFamily, AuthStatus>>; - onOpenSignIn?: () => void; + onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; onRuntimeCatalogRefreshed?: (provider: AgentChatModelCatalogRefreshProvider) => void; constrainToAvailableModelIds?: boolean; fastModeActive?: boolean; @@ -265,9 +266,13 @@ export const ModelPicker = memo(function ModelPicker({ setOpen(false); }, []); - const handleOpenSignIn = useCallback(() => { + const handleOpenSignIn = useCallback((family?: ProviderFamily, authTypes?: readonly AuthType[]) => { setOpen(false); - onOpenSignIn?.(); + if (authTypes == null) { + onOpenSignIn?.(family); + return; + } + onOpenSignIn?.(family, authTypes); }, [onOpenSignIn]); const triggerFastSupported = diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx index 3deda9a4a..e02389442 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelPickerContent.tsx @@ -9,7 +9,7 @@ import { } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { MagnifyingGlass } from "@phosphor-icons/react"; -import { MODEL_REGISTRY, type ModelDescriptor, type ProviderFamily } from "../../../../shared/modelRegistry"; +import { MODEL_REGISTRY, type AuthType, type ModelDescriptor, type ProviderFamily } from "../../../../shared/modelRegistry"; import { cn } from "../../ui/cn"; import { ModelListRow } from "./ModelListRow"; import { ModelPickerRail, type RailEntry, type RailSelection, type AuthStatus } from "./ModelPickerRail"; @@ -121,7 +121,7 @@ export type ModelPickerContentProps = { */ hidePermissionRail?: boolean; refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; - onOpenSignIn?: () => void; + onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; allowCliOnlyModels?: boolean; cursorAvailabilityMode?: "chat" | "cli" | "all"; allowRegistryExpansion?: boolean; @@ -472,7 +472,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ const target = visibleModels[focusedIndex]; if (!target) return; if (!isAvailableForUse(target)) { - onOpenSignIn?.(); + onOpenSignIn?.(target.family); return; } recordUsage(target.id); @@ -734,7 +734,7 @@ export const ModelPickerContent = memo(function ModelPickerContent({ onToggleFavorite={toggleFavorite} onCopyId={handleCopyId} onSetSurfaceDefault={handleSetSurfaceDefault} - {...(onOpenSignIn ? { onSignIn: onOpenSignIn } : {})} + {...(onOpenSignIn ? { onSignIn: () => onOpenSignIn(m.family, m.authTypes) } : {})} /> </div> ); @@ -770,7 +770,7 @@ function EmptyState({ opencodeBinaryKnown: boolean; refreshingProvider?: AgentChatModelCatalogRefreshProvider | null; providerAuthStatus?: Partial<Record<ProviderFamily, AuthStatus>>; - onOpenSignIn?: () => void; + onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; }) { if (!searchActive && selection !== "favorites" && selection !== "recents") { const family = selection.slice("provider:".length) as ProviderFamily; diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx index e83e09321..c4169aa8a 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.test.tsx @@ -37,7 +37,7 @@ describe("ProviderEmptyState", () => { expect(screen.getByText("Connect Cursor")).toBeTruthy(); expect(screen.getByText(/Add a Cursor API key/i)).toBeTruthy(); await userEvent.click(screen.getByRole("button", { name: /Open Settings/i })); - expect(onOpenSignIn).toHaveBeenCalledOnce(); + expect(onOpenSignIn).toHaveBeenCalledWith("cursor"); await userEvent.click(screen.getByRole("button", { name: /Get Cursor API key/i })); expect(openExternalCalls).toContain("https://cursor.com/dashboard/api"); }); @@ -99,7 +99,7 @@ describe("ProviderEmptyState", () => { const onOpenSignIn = vi.fn(); render(<ProviderEmptyState mode="opencode-required" family="ollama" onOpenSignIn={onOpenSignIn} />); await userEvent.click(screen.getByRole("button", { name: /Open Settings/i })); - expect(onOpenSignIn).toHaveBeenCalledOnce(); + expect(onOpenSignIn).toHaveBeenCalledWith("ollama"); await userEvent.click(screen.getByRole("button", { name: /OpenCode site/i })); expect(openExternalCalls).toContain("https://opencode.ai/"); }); @@ -120,7 +120,7 @@ describe("ProviderSetupBanner", () => { const banner = screen.getByRole("button", { name: /Set up Cursor/i }); expect(banner).toBeTruthy(); await userEvent.click(banner); - expect(onOpenSignIn).toHaveBeenCalledOnce(); + expect(onOpenSignIn).toHaveBeenCalledWith("cursor"); }); it("uses Droid label for the 'factory' family", () => { @@ -130,7 +130,7 @@ describe("ProviderSetupBanner", () => { it("uses Claude label for the 'anthropic' family", () => { render(<ProviderSetupBanner family="anthropic" onOpenSignIn={vi.fn()} />); - expect(screen.getByRole("button", { name: /Set up Claude/i })).toBeTruthy(); + expect(screen.getByRole("button", { name: /Login to Claude/i })).toBeTruthy(); }); it("uses OpenAI Codex label for the 'openai' family", () => { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx index d36325e0f..8fe8dafb4 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/providerEmptyState.tsx @@ -1,5 +1,5 @@ -import { Gear, ArrowSquareOut } from "@phosphor-icons/react"; -import type { ProviderFamily } from "../../../../shared/modelRegistry"; +import { Gear, ArrowSquareOut, Terminal } from "@phosphor-icons/react"; +import type { AuthType, ProviderFamily } from "../../../../shared/modelRegistry"; import { openExternalUrl } from "../../../lib/openExternal"; import { cn } from "../../ui/cn"; @@ -20,8 +20,8 @@ type ProviderCopy = { const PROVIDER_COPY: Partial<Record<ProviderFamily, ProviderCopy>> = { anthropic: { title: "Sign in to Claude", - body: "Install the Claude CLI and sign in, or set an Anthropic API key in Settings.", - primary: { label: "Open Settings", action: { kind: "open-settings" } }, + body: "Open a Claude login terminal on the primary lane, then complete the browser sign-in.", + primary: { label: "Login to Claude", action: { kind: "open-settings" } }, secondary: { label: "Claude Code docs", action: { kind: "open-external", url: "https://docs.claude.com/en/docs/agents-and-tools/claude-code/setup" }, @@ -95,12 +95,12 @@ export type ProviderEmptyStateProps = | { family: ProviderFamily; mode?: "default" | "discovery-empty"; - onOpenSignIn?: () => void; + onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; } | { mode: "opencode-required"; family: "opencode" | "ollama" | "lmstudio"; - onOpenSignIn?: () => void; + onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; }; const PROVIDER_DISPLAY_LABELS: Partial<Record<ProviderFamily, string>> = { @@ -154,7 +154,7 @@ function discoveryEmptyCopy(family: ProviderFamily): ProviderCopy { export type ProviderSetupBannerProps = { family: ProviderFamily; - onOpenSignIn?: () => void; + onOpenSignIn?: (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void; }; /** @@ -168,12 +168,13 @@ export type ProviderSetupBannerProps = { export function ProviderSetupBanner({ family, onOpenSignIn }: ProviderSetupBannerProps) { if (!onOpenSignIn) return null; const label = PROVIDER_DISPLAY_LABELS[family] ?? family; + const claude = family === "anthropic"; return ( <button type="button" data-model-picker-setup-banner="true" data-provider-family={family} - onClick={onOpenSignIn} + onClick={() => onOpenSignIn(family)} className={cn( "group sticky top-0 z-[6] mx-0.5 mb-1 flex items-center justify-between gap-2 rounded-md px-2 py-1.5", "border border-white/[0.06] bg-white/[0.025] backdrop-blur", @@ -182,8 +183,12 @@ export function ProviderSetupBanner({ family, onOpenSignIn }: ProviderSetupBanne )} > <span className="inline-flex items-center gap-1.5"> - <Gear size={11} weight="bold" className="opacity-70 group-hover:opacity-100" /> - <span>{`Set up ${label}`}</span> + {claude ? ( + <Terminal size={11} weight="bold" className="opacity-70 group-hover:opacity-100" /> + ) : ( + <Gear size={11} weight="bold" className="opacity-70 group-hover:opacity-100" /> + )} + <span>{claude ? "Login to Claude" : `Set up ${label}`}</span> </span> <ArrowSquareOut size={10} weight="bold" className="opacity-60 group-hover:opacity-100" /> </button> @@ -220,7 +225,7 @@ export function ProviderEmptyState(props: ProviderEmptyStateProps) { {copy.primary ? ( <button type="button" - onClick={() => dispatchAction(copy.primary!.action, onOpenSignIn)} + onClick={() => dispatchAction(copy.primary!.action, () => onOpenSignIn?.(family))} className={cn( "inline-flex h-6 items-center rounded-md border border-violet-400/30 bg-violet-500/[0.12] px-2.5", "text-[10px] font-semibold uppercase tracking-wide text-violet-100", diff --git a/apps/desktop/src/renderer/components/shared/useOpenProviderSignIn.ts b/apps/desktop/src/renderer/components/shared/useOpenProviderSignIn.ts new file mode 100644 index 000000000..4e49f1e33 --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/useOpenProviderSignIn.ts @@ -0,0 +1,22 @@ +import { useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import type { AuthType, ProviderFamily } from "../../../shared/modelRegistry"; +import { createClaudeLoginTerminalInWork } from "../work/ClaudeLoginPromptButton"; + +export function useOpenProviderSignIn(): (family?: ProviderFamily, authTypes?: readonly AuthType[]) => void { + const navigate = useNavigate(); + const openAiProvidersSettings = useCallback(() => { + navigate("/settings?tab=ai#ai-providers"); + }, [navigate]); + + return useCallback((family?: ProviderFamily, authTypes?: readonly AuthType[]) => { + const shouldOpenClaudeLogin = family === "anthropic" + && (authTypes == null || authTypes.includes("cli-subscription")); + if (!shouldOpenClaudeLogin) { + openAiProvidersSettings(); + return; + } + void createClaudeLoginTerminalInWork({ navigate }) + .catch(() => openAiProvidersSettings()); + }, [navigate, openAiProvidersSettings]); +} diff --git a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.test.tsx b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.test.tsx index 3b54cdb41..1ab687adc 100644 --- a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.test.tsx @@ -64,8 +64,6 @@ const lanes: LaneSummary[] = [ function renderHeader(overrides: Partial<TerminalSessionSummary> = {}, callbacks: { onInfoClick?: () => void; onContextMenu?: () => void; - onToggleTerminalPane?: () => void; - terminalPaneOpen?: boolean; } = {}) { const session = makeSession(overrides); return render( @@ -75,8 +73,6 @@ function renderHeader(overrides: Partial<TerminalSessionSummary> = {}, callbacks lanes={lanes} onInfoClick={callbacks.onInfoClick} onContextMenu={callbacks.onContextMenu} - onToggleTerminalPane={callbacks.onToggleTerminalPane} - terminalPaneOpen={callbacks.terminalPaneOpen} /> </MemoryRouter>, ); @@ -134,18 +130,6 @@ describe("CliSessionWorkSurfaceHeader", () => { expect(onInfoClick).toHaveBeenCalledTimes(1); }); - it("opens the attached terminal panel from running CLI sessions", () => { - const onToggleTerminalPane = vi.fn(); - renderHeader({}, { onToggleTerminalPane }); - fireEvent.click(screen.getByLabelText("Open terminal panel")); - expect(onToggleTerminalPane).toHaveBeenCalledTimes(1); - }); - - it("marks the terminal button active while the panel is open", () => { - renderHeader({}, { onToggleTerminalPane: vi.fn(), terminalPaneOpen: true }); - expect(screen.getByLabelText("Close terminal panel").getAttribute("aria-pressed")).toBe("true"); - }); - it("fires onContextMenu when the kebab is pressed", () => { const onContextMenu = vi.fn(); renderHeader({}, { onContextMenu }); diff --git a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx index 24da262d6..9f4affad0 100644 --- a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx @@ -1,5 +1,5 @@ import type { MouseEvent as ReactMouseEvent } from "react"; -import { DotsThreeVertical, Info, StopCircle, Terminal } from "@phosphor-icons/react"; +import { DotsThreeVertical, Info, StopCircle } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; @@ -127,39 +127,6 @@ function SessionInfoButton({ ); } -function SessionTerminalButton({ - open = false, - onToggleTerminalPane, -}: { - open?: boolean; - onToggleTerminalPane?: () => void; -}) { - return ( - <SmartTooltip - content={{ - label: open ? "Close terminal panel" : "Open terminal panel", - description: "Open attached shell terminals for this CLI session.", - }} - > - <button - type="button" - className={cn( - WORK_SURFACE_HEADER_ACTION_BASE, - open - ? "border-violet-400/22 bg-violet-500/10 px-1.5 text-violet-100/80" - : cn(WORK_SURFACE_HEADER_ACTION_IDLE, "px-1.5"), - )} - onClick={onToggleTerminalPane} - aria-label={open ? "Close terminal panel" : "Open terminal panel"} - aria-pressed={open} - disabled={!onToggleTerminalPane} - > - <Terminal size={13} weight={open ? "fill" : "regular"} /> - </button> - </SmartTooltip> - ); -} - function SessionActionsButton({ session, onContextMenu, @@ -210,22 +177,17 @@ export function CliSurfaceTrailingActions({ onInfoClick, onContextMenu, onStopRunningSession, - onToggleTerminalPane, - terminalPaneOpen = false, }: { session: TerminalSessionSummary; stopping?: boolean; onInfoClick?: SessionMouseHandler; onContextMenu?: SessionMouseHandler; onStopRunningSession?: (session: TerminalSessionSummary) => void; - onToggleTerminalPane?: () => void; - terminalPaneOpen?: boolean; }) { return ( <> <StopSessionButton session={session} stopping={stopping} onStopRunningSession={onStopRunningSession} /> <SessionRunMenu session={session} /> - <SessionTerminalButton open={terminalPaneOpen} onToggleTerminalPane={onToggleTerminalPane} /> <SessionInfoButton session={session} onInfoClick={onInfoClick} /> <SessionActionsButton session={session} onContextMenu={onContextMenu} /> </> @@ -284,8 +246,6 @@ export function CliSessionWorkSurfaceHeader({ sessionsPaneCount, onToggleToolsPane, toolsPaneOpen, - onToggleTerminalPane, - terminalPaneOpen, onTogglePrPane, prPaneOpen, }: { @@ -301,8 +261,6 @@ export function CliSessionWorkSurfaceHeader({ sessionsPaneCount?: number; onToggleToolsPane?: () => void; toolsPaneOpen?: boolean; - onToggleTerminalPane?: () => void; - terminalPaneOpen?: boolean; /** When set, the PR pill toggles the floating PR pane over the terminal * instead of opening the inline slide-out menu. */ onTogglePrPane?: () => void; @@ -364,8 +322,6 @@ export function CliSessionWorkSurfaceHeader({ onInfoClick={onInfoClick} onContextMenu={onContextMenu} onStopRunningSession={onStopRunningSession} - onToggleTerminalPane={onToggleTerminalPane} - terminalPaneOpen={terminalPaneOpen} /> </> } diff --git a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx index 843ac6ba8..40cb3d693 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalsPage.test.tsx @@ -466,7 +466,7 @@ describe("TerminalsPage chat session activation", () => { expect(workMocks.currentWork.setWorkSidebarTab).not.toHaveBeenCalled(); }); - it("opens and closes the Work Terminal sidebar from session headers", async () => { + it("opens and closes the Work Terminal sidebar from the Work surface", async () => { Object.defineProperty(window, "ade", { configurable: true, value: { builtInBrowser: { onEvent: vi.fn(() => vi.fn()) } }, diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index 33348a279..c66a8b167 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -652,8 +652,6 @@ function CliSessionSurface({ sessionsPaneCount, onToggleToolsPane, toolsPaneOpen, - onToggleTerminalPane, - terminalPaneOpen, }: { session: TerminalSessionSummary & { ptyId: string }; lanes: LaneSummary[]; @@ -670,8 +668,6 @@ function CliSessionSurface({ sessionsPaneCount?: number; onToggleToolsPane?: () => void; toolsPaneOpen?: boolean; - onToggleTerminalPane?: () => void; - terminalPaneOpen?: boolean; }) { const { prPaneOpen, setPrPaneOpen, prPaneDelta } = useChatPrAutoPop(session.laneId); const supportsSplit = layoutVariant !== "grid-tile"; @@ -691,8 +687,6 @@ function CliSessionSurface({ sessionsPaneCount={sessionsPaneCount} onToggleToolsPane={onToggleToolsPane} toolsPaneOpen={toolsPaneOpen} - onToggleTerminalPane={onToggleTerminalPane} - terminalPaneOpen={terminalPaneOpen} onTogglePrPane={session.laneId ? () => setPrPaneOpen((v) => !v) : undefined} prPaneOpen={prPaneOpen} /> @@ -828,8 +822,6 @@ function SessionSurface({ sessionsPaneCount={sessionsPaneCount} onToggleToolsPane={onToggleToolsPane} toolsPaneOpen={toolsPaneOpen} - onToggleTerminalPane={onToggleTerminalPane} - terminalPaneOpen={terminalPaneOpen} /> ); } diff --git a/apps/desktop/src/renderer/components/work/ClaudeLoginPromptButton.test.tsx b/apps/desktop/src/renderer/components/work/ClaudeLoginPromptButton.test.tsx index 38a2f98bb..eed66bb40 100644 --- a/apps/desktop/src/renderer/components/work/ClaudeLoginPromptButton.test.tsx +++ b/apps/desktop/src/renderer/components/work/ClaudeLoginPromptButton.test.tsx @@ -65,6 +65,7 @@ describe("ClaudeLoginPromptButton", () => { }); }); expect(onRevealTerminal).toHaveBeenCalledWith({ + laneId: "lane-1", terminalId: "terminal-claude-login", ptyId: "pty-claude-login", label: "Claude login", diff --git a/apps/desktop/src/renderer/components/work/ClaudeLoginPromptButton.tsx b/apps/desktop/src/renderer/components/work/ClaudeLoginPromptButton.tsx index 1702f2f9f..fb8c96cde 100644 --- a/apps/desktop/src/renderer/components/work/ClaudeLoginPromptButton.tsx +++ b/apps/desktop/src/renderer/components/work/ClaudeLoginPromptButton.tsx @@ -9,6 +9,83 @@ type RevealTerminalRequest = { label: string; }; +type ClaudeLoginTerminalCreated = RevealTerminalRequest & { + laneId: string; +}; + +type WorkNavigate = (path: string) => void; + +async function resolveClaudeLoginLaneId(laneId?: string | null): Promise<string> { + if (laneId) return laneId; + const listLanes = window.ade?.lanes?.list; + const availableLanes = typeof listLanes === "function" ? await listLanes({ + includeArchived: false, + includeStatus: false, + }) : []; + const primaryLane = availableLanes.find((lane) => lane.laneType === "primary") ?? null; + const resolvedLaneId = primaryLane?.id ?? availableLanes[0]?.id ?? null; + if (!resolvedLaneId) { + throw new Error("No active lane is available for this project."); + } + return resolvedLaneId; +} + +export async function createClaudeLoginTerminal({ + laneId, + chatSessionId, +}: { + laneId?: string | null; + chatSessionId?: string | null; +} = {}): Promise<ClaudeLoginTerminalCreated> { + if (!window.ade?.pty?.create) { + throw new Error("Terminal sessions are not available in this ADE runtime."); + } + const resolvedLaneId = await resolveClaudeLoginLaneId(laneId); + const created = await window.ade.pty.create({ + laneId: resolvedLaneId, + ...(chatSessionId ? { chatSessionId } : {}), + cols: 100, + rows: 28, + title: "Claude login", + tracked: true, + toolType: "shell", + startupCommand: CLAUDE_AUTH_LOGIN_COMMAND, + }); + return { + laneId: resolvedLaneId, + terminalId: created.sessionId, + ptyId: created.ptyId, + label: "Claude login", + }; +} + +export function revealTerminalSessionInWork( + navigate: WorkNavigate, + terminal: { terminalId: string; laneId: string }, + delayMs = 80, +): void { + navigate("/work"); + window.setTimeout(() => { + window.dispatchEvent(new CustomEvent("ade:work:select-session", { + detail: { sessionId: terminal.terminalId, laneId: terminal.laneId }, + })); + }, delayMs); +} + +export async function createClaudeLoginTerminalInWork({ + navigate, + laneId, + chatSessionId, +}: { + navigate: WorkNavigate; + laneId?: string | null; + chatSessionId?: string | null; +}): Promise<ClaudeLoginTerminalCreated> { + const terminal = await createClaudeLoginTerminal({ laneId, chatSessionId }); + revealTerminalSessionInWork(navigate, terminal); + return terminal; +} + function dismissedKey(storageKey: string): string { return `ade.claudeLoginPrompt.dismissed.v1:${storageKey}`; } @@ -43,6 +120,8 @@ export function ClaudeLoginPromptButton({ laneId, chatSessionId, onRevealTerminal, + onTerminalCreated, + dismissible = true, className, }: { visible: boolean; @@ -50,6 +129,8 @@ export function ClaudeLoginPromptButton({ laneId?: string | null; chatSessionId?: string | null; onRevealTerminal?: (terminal: RevealTerminalRequest) => void; + onTerminalCreated?: (terminal: ClaudeLoginTerminalCreated) => void; + dismissible?: boolean; className?: string; }) { const [dismissed, setDismissed] = useState(() => readDismissed(storageKey)); @@ -78,39 +159,12 @@ export function ClaudeLoginPromptButton({ setOpening(true); setError(null); void (async () => { - let resolvedLaneId = laneId ?? null; - if (!resolvedLaneId) { - const listLanes = window.ade?.lanes?.list; - const availableLanes = typeof listLanes === "function" ? await listLanes({ - includeArchived: false, - includeStatus: false, - }) : []; - resolvedLaneId = availableLanes[0]?.id ?? null; - } - if (!resolvedLaneId) { - throw new Error("No active lane is available for this project."); - } - - const created = await window.ade.pty.create({ - laneId: resolvedLaneId, - ...(chatSessionId ? { chatSessionId } : {}), - cols: 100, - rows: 28, - title: "Claude login", - tracked: true, - toolType: "shell", - startupCommand: CLAUDE_AUTH_LOGIN_COMMAND, - }); - - const reveal = { - terminalId: created.sessionId, - ptyId: created.ptyId, - label: "Claude login", - }; + const reveal = await createClaudeLoginTerminal({ laneId, chatSessionId }); onRevealTerminal?.(reveal); + onTerminalCreated?.(reveal); if (!chatSessionId) { window.dispatchEvent(new CustomEvent("ade:work:select-session", { - detail: { sessionId: created.sessionId, laneId: resolvedLaneId }, + detail: { sessionId: reveal.terminalId, laneId: reveal.laneId }, })); } })() @@ -118,9 +172,9 @@ export function ClaudeLoginPromptButton({ setError(err instanceof Error ? err.message : String(err)); }) .finally(() => setOpening(false)); - }, [chatSessionId, laneId, onRevealTerminal, opening]); + }, [chatSessionId, laneId, onRevealTerminal, onTerminalCreated, opening]); - if (!visible || dismissed) return null; + if (!visible || (dismissible && dismissed)) return null; return ( <div className={cn("relative inline-flex shrink-0 items-center", className)}> @@ -136,15 +190,17 @@ export function ClaudeLoginPromptButton({ {opening ? <SpinnerGap size={12} className="animate-spin" aria-hidden /> : <Terminal size={12} weight="bold" aria-hidden />} <span className="whitespace-nowrap">Login to Claude</span> </button> - <button - type="button" - onClick={dismiss} - className="inline-flex h-full w-5 items-center justify-center border-l border-[#d97757]/25 text-[#ffd7c2]/65 transition-colors hover:bg-[#d97757]/18 hover:text-[#ffe4d5]" - aria-label="Dismiss Claude login prompt" - title="Dismiss" - > - <X size={10} weight="bold" aria-hidden /> - </button> + {dismissible ? ( + <button + type="button" + onClick={dismiss} + className="inline-flex h-full w-5 items-center justify-center border-l border-[#d97757]/25 text-[#ffd7c2]/65 transition-colors hover:bg-[#d97757]/18 hover:text-[#ffe4d5]" + aria-label="Dismiss Claude login prompt" + title="Dismiss" + > + <X size={10} weight="bold" aria-hidden /> + </button> + ) : null} </div> {error ? ( <span className="absolute right-0 top-[calc(100%+4px)] z-20 max-w-[16rem] rounded-md border border-red-400/20 bg-red-950/90 px-2 py-1 text-right text-[10px] text-red-100 shadow-lg"> diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index fd6f62c11..8a85d7223 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -447,8 +447,8 @@ export type DraftPrDescriptionArgs = { closeLinearIssueOnMerge?: boolean; /** * When true, surface real AI failures to the caller (throw) instead of silently - * returning the deterministic template. Used by the in-chat "AI draft" button so - * the user sees why it failed rather than getting a stub. + * returning the deterministic template. Used by explicit AI-draft callers that + * need a hard failure instead of a stub. */ requireAi?: boolean; }; diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ef9c2f1b6..8c3690470 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -5688,6 +5688,22 @@ final class SyncService: ObservableObject { _ = try await sendCommand(action: "work.runQuickCommand", args: args) } + func startClaudeLoginTerminal(laneId: String) async throws -> StartCliSessionResult { + try await sendDecodableCommand( + action: "work.runQuickCommand", + args: [ + "laneId": laneId, + "title": "Claude login", + "startupCommand": "claude auth login", + "toolType": "shell", + "tracked": true, + "cols": 100, + "rows": 28, + ], + as: StartCliSessionResult.self + ) + } + func startCliSession( laneId: String, provider: String, diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index 61d8471e4..4458e2bba 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -325,6 +325,7 @@ struct HubInlineComposer: View { currentReasoningEffort: reasoningEffort, currentCodexFastMode: codexFastMode, cursorAvailabilityMode: sessionMode == .cli ? .cli : .chat, + lanes: lanesForPickedProject.map { $0.asLaneSummary() }, isBusy: false, onSelect: { option, pickedReasoning, runtimeProvider, pickedFastMode in selectedModelOption = option diff --git a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift index 2553de459..a9ca13694 100644 --- a/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift +++ b/apps/ios/ADE/Views/PRs/CreatePrWizardView.swift @@ -73,15 +73,14 @@ struct CreatePrWizardView: View { @State private var baseBranch = "" @State private var title = "" @State private var bodyText = "" - @State private var draft = true + @State private var draft = false @State private var strategy: PrStrategyChoice = .prTarget @State private var labelsInput = "" @State private var reviewersInput = "" - @State private var isGenerating = false @State private var isSubmitting = false @State private var errorMessage: String? @State private var editPresented = false - @State private var draftLoadedOnce = false + @State private var lastAutoTitle = "" // Queue / integration-only state. @State private var queueName = "" @State private var autoRebase = true @@ -110,7 +109,6 @@ struct CreatePrWizardView: View { title: eligibility.laneName, branchRef: lanes.first(where: { $0.id == eligibility.laneId })?.branchRef ?? eligibility.laneName, defaultBaseBranch: eligibility.defaultBaseBranch, - defaultTitle: eligibility.defaultTitle, subtitle: Self.laneProgressSubtitle(for: eligibility) ) } @@ -121,7 +119,6 @@ struct CreatePrWizardView: View { title: lane.name, branchRef: lane.branchRef, defaultBaseBranch: lane.baseRef, - defaultTitle: lane.name, subtitle: nil ) } @@ -139,7 +136,6 @@ struct CreatePrWizardView: View { title: lane.name, branchRef: lane.branchRef, defaultBaseBranch: eligibility?.defaultBaseBranch ?? lane.baseRef, - defaultTitle: eligibility?.defaultTitle ?? lane.name, subtitle: eligibility.map { Self.laneProgressSubtitle(for: $0) } ?? nil ) } @@ -176,6 +172,14 @@ struct CreatePrWizardView: View { ?? "main" } + private func displayNameForTargetBranch(_ branch: String) -> String { + let normalizedTarget = normalizedPrBranchName(branch) + guard !normalizedTarget.isEmpty else { return "target" } + return lanes.first { lane in + normalizedPrBranchName(lane.branchRef) == normalizedTarget + }?.name ?? normalizedTarget + } + private var branchTargetOptions: [PrBranchTargetOption] { var targets = [ PrBranchTargetOption( @@ -254,19 +258,62 @@ struct CreatePrWizardView: View { return nil } + private static func defaultPrTitle(source: String, target: String) -> String { + let sourceName = source.trimmingCharacters(in: .whitespacesAndNewlines) + let targetName = target.trimmingCharacters(in: .whitespacesAndNewlines) + return "\(sourceName.isEmpty ? "Source lane" : sourceName) -> \(targetName.isEmpty ? "target" : targetName)" + } + + private var currentDefaultTitle: String { + let target = baseBranch.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedTarget = target.isEmpty ? defaultTargetBranch : target + let targetDisplayName = displayNameForTargetBranch(resolvedTarget) + switch createMode { + case .single: + guard let option = selectedOption else { return "" } + return Self.defaultPrTitle(source: option.title, target: targetDisplayName) + case .integration: + let source = integrationLaneName.trimmingCharacters(in: .whitespacesAndNewlines) + return Self.defaultPrTitle(source: source.isEmpty ? "Integration" : source, target: targetDisplayName) + case .queue: + return "" + } + } + + private func applyDefaultTitleIfUntouched() { + let next = currentDefaultTitle + guard !next.isEmpty else { return } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty || title == lastAutoTitle { + title = next + } + lastAutoTitle = next + } + + private func clearLaneScopedDetailsIfChangingSource(from oldLaneId: String, to newLaneId: String) { + guard !oldLaneId.isEmpty, !newLaneId.isEmpty, oldLaneId != newLaneId else { return } + bodyText = "" + labelsInput = "" + reviewersInput = "" + } + private var canSubmit: Bool { if isSubmitting { return false } switch createMode { case .single: guard selectedLane != nil, selectedLaneCanCreate else { return false } - let hasTitle = !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveTitle = trimmedTitle.isEmpty ? currentDefaultTitle : trimmedTitle + let hasTitle = !effectiveTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasBase = !baseBranch.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return hasTitle && hasBase case .queue: return selectedLaneIds.count >= 1 case .integration: let hasName = !integrationLaneName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - let hasTitle = !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let effectiveTitle = trimmedTitle.isEmpty ? currentDefaultTitle : trimmedTitle + let hasTitle = !effectiveTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return selectedLaneIds.count >= 2 && hasName && hasTitle } } @@ -299,7 +346,7 @@ struct CreatePrWizardView: View { switch createMode { case .single: branchesSection - aiTitleSection + detailsSection strategySection stanceSection reviewersSection @@ -317,7 +364,7 @@ struct CreatePrWizardView: View { multiLaneSection(mode: .integration) targetBranchSection(title: "Target branch") integrationSettingsSection - aiTitleSection + detailsSection stanceSection reviewersSection labelsSection @@ -358,22 +405,22 @@ struct CreatePrWizardView: View { if baseBranch.isEmpty { baseBranch = defaultTargetBranch } - if !draftLoadedOnce, selectedLane != nil, selectedLaneCanCreate { - draftLoadedOnce = true - Task { await generateDraft(initial: true) } - } + applyDefaultTitleIfUntouched() } - .onChange(of: selectedLaneId) { _, _ in + .onChange(of: selectedLaneId) { oldLaneId, newLaneId in // Reset base-branch default when lane changes so the target picker // tracks the new lane's recommended base instead of a stale one. + clearLaneScopedDetailsIfChangingSource(from: oldLaneId, to: newLaneId) baseBranch = defaultTargetBranch - title = selectedOption?.defaultTitle ?? "" - bodyText = "" - labelsInput = "" - reviewersInput = "" + applyDefaultTitleIfUntouched() errorMessage = nil - if selectedLaneCanCreate { - Task { await generateDraft(initial: false) } + } + .onChange(of: baseBranch) { _, _ in + applyDefaultTitleIfUntouched() + } + .onChange(of: integrationLaneName) { _, _ in + if createMode == .integration { + applyDefaultTitleIfUntouched() } } .onChange(of: createMode) { _, newValue in @@ -382,19 +429,20 @@ struct CreatePrWizardView: View { errorMessage = nil if newValue == .single { // Restore single-mode defaults using the currently selected lane. - title = selectedOption?.defaultTitle ?? "" - if selectedOption != nil { - Task { await generateDraft(initial: false) } - } + applyDefaultTitleIfUntouched() } else { // Queue + integration share the multi-select; reset the title input // so it doesn't carry over the single-lane suggestion. title = "" + lastAutoTitle = "" bodyText = "" // Seed integration name so the form feels started. if newValue == .integration && integrationLaneName.isEmpty { integrationLaneName = "integration/\(Int(Date().timeIntervalSince1970))" } + if newValue == .integration { + applyDefaultTitleIfUntouched() + } } } .sheet(isPresented: $editPresented) { @@ -520,64 +568,24 @@ struct CreatePrWizardView: View { } } - // MARK: - AI-drafted title card + // MARK: - Details - private var aiTitleSection: some View { + private var detailsSection: some View { VStack(spacing: 0) { - PrSectionHdr(title: "Title") { - HStack(spacing: 4) { - Image(systemName: "sparkles") - .font(.system(size: 9, weight: .semibold)) - PrMonoText(text: "sonnet-4.6", color: ADEColor.purpleAccent, size: 10) - } - .foregroundStyle(ADEColor.purpleAccent) - } + PrSectionHdr(title: "Details") VStack(alignment: .leading, spacing: 10) { - if isGenerating && title.isEmpty { - HStack(spacing: 8) { - ProgressView().controlSize(.small) - Text("Drafting title and body…") - .font(.footnote) - .foregroundStyle(ADEColor.textSecondary) - } - } else { - Text(title.isEmpty ? "Untitled change" : title) - .font(.system(size: 15, weight: .bold)) - .tracking(-0.15) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(3) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - } + Text(title.isEmpty ? currentDefaultTitle : title) + .font(.system(size: 15, weight: .bold)) + .tracking(-0.15) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(3) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) bodyPreviewCard HStack(spacing: 6) { - Button { - Task { await generateDraft(initial: false) } - } label: { - HStack(spacing: 4) { - Image(systemName: "sparkles") - .font(.system(size: 10, weight: .semibold)) - Text("Regenerate") - .font(.system(size: 11, weight: .semibold)) - } - .foregroundStyle(ADEColor.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(ADEColor.textPrimary.opacity(0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(ADEColor.border.opacity(0.5), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .disabled(isGenerating || selectedLane == nil || !selectedLaneCanCreate) - Button { editPresented = true } label: { @@ -609,7 +617,7 @@ struct CreatePrWizardView: View { private var bodyPreviewCard: some View { let previewText = bodyText.isEmpty - ? "Generate or edit a description to summarize the change." + ? "Edit the description to summarize the change." : bodyText return Text(previewText) .font(.system(size: 11.5)) @@ -1056,9 +1064,10 @@ struct CreatePrWizardView: View { isSubmitting = false return } + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let completed = await onCreateSingle( option.id, - title.trimmingCharacters(in: .whitespacesAndNewlines), + trimmedTitle.isEmpty ? currentDefaultTitle : trimmedTitle, bodyText, draft, baseBranch.trimmingCharacters(in: .whitespacesAndNewlines), @@ -1101,7 +1110,7 @@ struct CreatePrWizardView: View { CreateIntegrationRequest( sourceLaneIds: laneIds, integrationLaneName: trimmedName, - title: trimmedTitle, + title: trimmedTitle.isEmpty ? currentDefaultTitle : trimmedTitle, body: bodyText, draft: draft, baseBranch: targetBranch @@ -1124,38 +1133,6 @@ struct CreatePrWizardView: View { didCacheLaneOptions = true } - // MARK: - Draft generation - - @MainActor - private func generateDraft(initial: Bool) async { - guard selectedLaneCanCreate, let option = selectedOption else { return } - isGenerating = true - defer { isGenerating = false } - - do { - let suggestion: PullRequestDraftSuggestion - if syncService.supportsRemoteAction("prs.draftDescription") { - suggestion = try await syncService.draftPullRequestDescription(laneId: option.id) - } else if let lane = lanes.first(where: { $0.id == option.id }) { - let detail = try? await syncService.refreshLaneDetail(laneId: option.id) - suggestion = prHeuristicDraft(lane: lane, detail: detail) - } else { - suggestion = PullRequestDraftSuggestion(title: option.defaultTitle, body: "") - } - - // On the initial auto-fetch, don't stomp user edits if they appeared - // between the onAppear trigger and the await resolving. - if initial && (!title.isEmpty || !bodyText.isEmpty) { - return - } - title = suggestion.title - bodyText = suggestion.body - errorMessage = nil - } catch { - errorMessage = error.localizedDescription - } - } - // MARK: - Edit sheet private var editorSheet: some View { @@ -1534,7 +1511,6 @@ struct CreatePrLaneOption: Identifiable, Equatable { let title: String let branchRef: String let defaultBaseBranch: String - let defaultTitle: String let subtitle: String? } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index d4fcfce5c..c1245d2f2 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -1027,6 +1027,7 @@ struct WorkChatSessionView: View { currentProvider: chatSummaryContext.provider, currentReasoningEffort: chatSummaryContext.reasoningEffort, currentCodexFastMode: chatSummaryContext.effectiveFastMode, + lanes: lanes, isBusy: modelUpdateInFlight, onSelect: { option, pickedReasoning, _, pickedFastMode in Task { @MainActor in diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 34a25a770..47c4a609b 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -20,6 +20,7 @@ struct WorkModelPickerSheet: View { let currentCodexFastMode: Bool let availableModelIds: [String]? let cursorAvailabilityMode: WorkCursorAvailabilityMode + let lanes: [LaneSummary] let isBusy: Bool let onSelect: (WorkModelOption, String?, String, Bool) -> Void @@ -30,6 +31,7 @@ struct WorkModelPickerSheet: View { currentCodexFastMode: Bool = false, availableModelIds: [String]? = nil, cursorAvailabilityMode: WorkCursorAvailabilityMode = .chat, + lanes: [LaneSummary] = [], isBusy: Bool, onSelect: @escaping (WorkModelOption, String?, String, Bool) -> Void ) { @@ -39,6 +41,7 @@ struct WorkModelPickerSheet: View { self.currentCodexFastMode = currentCodexFastMode self.availableModelIds = availableModelIds self.cursorAvailabilityMode = cursorAvailabilityMode + self.lanes = lanes self.isBusy = isBusy self.onSelect = onSelect _selectedModelId = State(initialValue: currentModelId) @@ -58,6 +61,9 @@ struct WorkModelPickerSheet: View { @State private var selectedRuntimeProvider: String @State private var selectedReasoningEffort: String @State private var selectedCodexFastMode: Bool + @State private var fallbackLoginLanes: [LaneSummary] = [] + @State private var claudeLoginBusy = false + @State private var claudeLoginError: String? private var catalog: [WorkModelCatalogGroup] { if let liveCatalog { @@ -113,6 +119,14 @@ struct WorkModelPickerSheet: View { return cursorCatalogSourceValue } + private var claudeLoginLane: LaneSummary? { + let candidateLanes = lanes.isEmpty ? fallbackLoginLanes : lanes + let activeLanes = candidateLanes.filter { $0.archivedAt == nil } + return activeLanes.first { $0.laneType == "primary" } + ?? activeLanes.first + ?? candidateLanes.first + } + var body: some View { NavigationStack { VStack(spacing: 0) { @@ -155,7 +169,10 @@ struct WorkModelPickerSheet: View { onSelectReasoning: { model, effort in select(reasoningEffort: effort, for: model) }, onToggleFastMode: { model, enabled in select(fastMode: enabled, for: model) }, onSelectProviderTab: { selectedProviderTabKey = $0 }, - onToggleFavorite: { picker.toggleFavorite($0, syncService: syncService) } + onToggleFavorite: { picker.toggleFavorite($0, syncService: syncService) }, + onClaudeLogin: { Task { await openClaudeLoginTerminal() } }, + isClaudeLoginBusy: claudeLoginBusy, + claudeLoginError: claudeLoginError ) } Divider().overlay(ADEColor.glassBorder) @@ -503,6 +520,30 @@ struct WorkModelPickerSheet: View { ) } + @MainActor + private func openClaudeLoginTerminal() async { + guard !claudeLoginBusy else { return } + claudeLoginBusy = true + claudeLoginError = nil + defer { claudeLoginBusy = false } + + do { + if claudeLoginLane == nil { + fallbackLoginLanes = try await syncService.fetchLanes(includeArchived: false) + } + guard let lane = claudeLoginLane else { + claudeLoginError = "No active lane is available." + return + } + let result = try await syncService.startClaudeLoginTerminal(laneId: lane.id) + let sessionId = result.session?.id ?? result.sessionId + syncService.requestedWorkSessionNavigation = WorkSessionNavigationRequest(sessionId: sessionId) + dismiss() + } catch { + claudeLoginError = error.localizedDescription + } + } + private func select(model: WorkModelOption) { guard model.isAvailable else { return } @@ -753,6 +794,9 @@ struct ModelPickerContentPane: View { let onToggleFastMode: (WorkModelOption, Bool) -> Void let onSelectProviderTab: (String) -> Void let onToggleFavorite: (String) -> Void + let onClaudeLogin: (() -> Void)? + let isClaudeLoginBusy: Bool + let claudeLoginError: String? private var favoritesSet: Set<String> { Set(favorites) } @@ -771,10 +815,22 @@ struct ModelPickerContentPane: View { } } + private var showsClaudeLoginAction: Bool { + guard onClaudeLogin != nil else { return false } + let rows = groupedRows.flatMap(\.models) + if case .providerGroup(let key, _) = selection, providerFamilyKey(key) == "claude" { + return rows.contains { !$0.isAvailable } + } + return rows.contains { !$0.isAvailable && providerFamilyKey($0.provider) == "claude" } + } + var body: some View { VStack(alignment: .leading, spacing: 0) { header providerTabStrip + if showsClaudeLoginAction { + claudeLoginBanner + } Divider().overlay(ADEColor.glassBorder) if groupedRows.allSatisfy({ $0.models.isEmpty }) { emptyState @@ -804,7 +860,9 @@ struct ModelPickerContentPane: View { onSelect: { onSelect(model) }, onSelectReasoning: { effort in onSelectReasoning(model, effort) }, onToggleFastMode: { enabled in onToggleFastMode(model, enabled) }, - onToggleFavorite: { onToggleFavorite(model.id) } + onToggleFavorite: { onToggleFavorite(model.id) }, + onClaudeLogin: onClaudeLogin, + isClaudeLoginBusy: isClaudeLoginBusy ) } } @@ -857,6 +915,71 @@ struct ModelPickerContentPane: View { ?? providerTabs.first?.key } + @ViewBuilder + private var claudeLoginBanner: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 10) { + Image(systemName: "terminal.fill") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(providerTint("claude")) + .frame(width: 24, height: 24) + .background(providerTint("claude").opacity(0.14), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + VStack(alignment: .leading, spacing: 2) { + Text("Claude is signed out") + .font(.footnote.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Open a primary-lane terminal to finish Claude Code login.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + Button { + onClaudeLogin?() + } label: { + HStack(spacing: 6) { + if isClaudeLoginBusy { + ProgressView() + .controlSize(.small) + .tint(ADEColor.textPrimary) + } else { + Image(systemName: "arrow.right.circle.fill") + .font(.caption.weight(.bold)) + } + Text("Login to Claude") + .font(.caption.weight(.bold)) + .lineLimit(1) + } + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(ADEColor.accent.opacity(0.22), in: Capsule()) + .overlay( + Capsule(style: .continuous) + .stroke(ADEColor.accent.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .disabled(isClaudeLoginBusy) + .accessibilityLabel("Login to Claude") + } + if let claudeLoginError, !claudeLoginError.isEmpty { + Text(claudeLoginError) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(10) + .background(ADEColor.surfaceBackground.opacity(0.5), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.glassBorder.opacity(0.75), lineWidth: 0.6) + ) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } + @ViewBuilder private var header: some View { ZStack { @@ -1000,6 +1123,8 @@ struct ModelPickerListRow: View { let onSelectReasoning: (String) -> Void let onToggleFastMode: (Bool) -> Void let onToggleFavorite: () -> Void + let onClaudeLogin: (() -> Void)? + let isClaudeLoginBusy: Bool private var isHighlighted: Bool { isSelected @@ -1018,6 +1143,10 @@ struct ModelPickerListRow: View { } } + private var showsClaudeLoginAction: Bool { + !model.isAvailable && providerFamilyKey(model.provider) == "claude" && onClaudeLogin != nil + } + var body: some View { VStack(alignment: .leading, spacing: 0) { Button { @@ -1048,6 +1177,11 @@ struct ModelPickerListRow: View { } .padding(.top, style == .detailed ? 8 : 6) } + + if showsClaudeLoginAction { + claudeLoginButton + .padding(.top, style == .detailed ? 7 : 5) + } } .padding(.horizontal, style == .compact ? 10 : 11) .padding(.vertical, style == .compact ? 7 : 8) @@ -1108,6 +1242,38 @@ struct ModelPickerListRow: View { .accessibilityLabel("\(model.displayName)\(isSelected ? ". Selected." : "")") } + @ViewBuilder + private var claudeLoginButton: some View { + Button { + onClaudeLogin?() + } label: { + HStack(spacing: 6) { + if isClaudeLoginBusy { + ProgressView() + .controlSize(.small) + .tint(ADEColor.textPrimary) + } else { + Image(systemName: "terminal.fill") + .font(.caption.weight(.semibold)) + } + Text("Login to Claude") + .font(.caption.weight(.bold)) + .lineLimit(1) + } + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(ADEColor.accent.opacity(0.18), in: Capsule()) + .overlay( + Capsule(style: .continuous) + .stroke(ADEColor.accent.opacity(0.24), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .disabled(isClaudeLoginBusy) + .accessibilityLabel("Login to Claude") + } + @ViewBuilder private var favoriteButton: some View { Button { diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index b259b6e95..acc860d81 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -782,6 +782,7 @@ struct WorkNewChatScreen: View { currentReasoningEffort: reasoningEffort, currentCodexFastMode: codexFastMode, cursorAvailabilityMode: sessionMode == .cli ? .cli : .chat, + lanes: lanes, isBusy: false, onSelect: { option, pickedReasoning, runtimeProvider, pickedFastMode in selectedModelOption = option diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift index bdbe68dbe..14e945917 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift @@ -161,7 +161,7 @@ extension WorkSessionSettingsSheet { func runtimeCard(option: WorkRuntimeOption, isSelected: Bool) -> some View { let tint = workRuntimeModeTint(provider: summary.provider, mode: option.id) - VStack(alignment: .leading, spacing: 8) { + return VStack(alignment: .leading, spacing: 8) { HStack(spacing: 10) { VStack(alignment: .leading, spacing: 4) { Text(option.title) diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index f03df41fc..bf4287f3f 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -140,7 +140,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt - **Header** — project name, active lane, branch, and the terminal client frame. - **Drawer** (toggled with the configured shortcut) — two modes, **lanes** (default) and **chats**, switched with `Tab` while the drawer is focused. Lane cards show name + status (no branch ref — that lives in lane details). Every lane shows its chats: the selected lane expands the full chat block (the same tight single-row chats every lane shows, distinguished only by a violet border plus a trailing `+ new chat` row — there is no `CHATS` header), while every other lane renders a compact always-visible preview (the lane's chats as single rows, plus a `+N more` tail only when the row budget can't fit them all) whose rows are clickable and select lane + chat in one step. Ended tracked CLI sessions are hidden behind a `closed (N)` row in the expanded lane; expanding it shows dim one-line rows with provider glyph, title, and relative end time, and `↵` resumes a resumable closed CLI session through the same terminal resume path as desktop. Row layout and mouse hit-testing share one pure model (`drawerLayout.ts: computeDrawerLayout` / `drawerMouseHitForLayout`) so open chats, closed toggles, closed sessions, and `+ new chat` cannot drift. In **lanes** mode, `↑`/`↓` move lane cards; `↓` on an available lane enters **chats** mode for that lane; `↵` opens lane details or resumes the lane's last chat. In **chats** mode, `↑`/`↓` move within the lane's chat rows, closed group, and `+ new chat`; highlighting a chat previews it in the centre pane via `resolveTuiChatRefreshTarget` before `↵` commits the session. `Esc` returns from the chat list to **lanes**. Lane and chat selection drive the right pane's context. -- **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. For the non-Claude runtimes the transcript mirrors the desktop work log: each tool call is one stacked line (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`), `web_search` events group with tool calls and include the first provider action query/title/URL when available, reasoning renders as a collapsed `Thinking…`/`Thought` row with a one-line preview, and every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. +- **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. For the non-Claude runtimes the transcript mirrors the desktop work log: each tool call is one stacked line (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`), `web_search` events group with tool calls and include the first provider action query/title/URL when available, reasoning renders as a collapsed `Thinking…`/`Thought` row with a one-line preview, file-change groups collapse to one summary row and expand to typed file rows whose `diff` action opens the turn diff in the right pane, and every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. - **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Both triggers are detected cursor-relatively through the shared `apps/desktop/src/shared/composerTriggers.ts` module (`detectComposerTrigger`), so a `/command` or `@file` token is recognized anywhere in the draft — not just at position 0 (`fix @src/foo.ts then run /test`). Both palettes stay visible with a no-match row while the user is actively typing. Selecting a suggestion splices exactly the trigger span (`replaceComposerTriggerSpan`) rather than replacing the whole prompt; a lone leading `/command` keeps the legacy fill-the-prompt behavior. `Tab` completes the highlighted slash command, and for a **mid-sentence** slash trigger `Enter` completes into the draft (instead of submitting/running), mirroring the desktop command menu — a leading-only command still runs on `Enter`. Confirmed tokens render as colored chips in the prompt rows via `findConfirmedComposerTokens` + `segmentPromptLineText`: inserted `@file` mentions and `/command` names matching the built-in or runtime catalog paint cyan (files) or violet (commands) and bold, while unmatched `@`/`/` text stays plain. Mention completion publishes local lane/chat hits immediately, then debounces remote file/git/PR RPCs; file results are cached per lane+query and git/PR results are cached per lane for the open TUI session. Pending tool approvals surface as `ApprovalPrompt`. AskUserQuestion-style answer requests (one or more questions, each with options) render every question inline with its option list and an `N of M answered` header. While such a request is pending and the composer is empty, keyboard input drives the picker instead of the prompt: `↑`/`↓` move the selected option (or move between questions when the active question has no options), `←`/`→` switch the active question, `1`-`9` within the option count highlights that option without submitting, and `Enter` submits the active question's current selection (advancing to the next unanswered question, or finalizing the whole request once every question is answered). If the next printable input after a digit quick-select is text, that digit becomes the start of a free-text answer and the previous option highlight is restored; digits above the option count type directly into the composer. Clicking an option still submits it immediately. The deny chip still declines the whole request. Selection lives in `pendingInput.ts`'s `PendingQuestionSelectionState`. - **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. `/secrets` opens a masked project-secret list and copies the selected secret value to the local system clipboard with `Enter` or `c`; it never reveals values inline and only uses the read actions behind the existing project-secret RPC path. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): provider/model header, lane label, streaming/idle indicator with context-percent + token summary, plan steps for the current turn (plus the provider's plan explanation / streaming text when present), Codex `/goal` block when present, a roster of subagents (running first, then teammates and background), and — below the roster, like the Droid Missions block — **TASKS** (latest `todo_update` snapshot, desktop ChatTasksPanel parity), **SCHEDULE** (Claude wakeups/cron/background work from `scheduled_work_update`, desktop Chat Info parity), and **PR** (the lane's PR state + checks rollup with `/pr` hand-off hints, desktop ChatPrPane parity). PR rows refresh from runtime PR update notifications when available and still keep the 30s poll as a fallback. Codex goal state comes from the shared chat event stream and is normalized so provider token budgets do not show as ADE-side limits. Selecting a subagent row with `↵` first probes for a usable subagent transcript; if one is available the centre transcript swaps to it, otherwise the local reconstruction stays visible with a notice. `Esc` returns to the main chat. For an active lane with no chat focus, the default switches to the wireframe **`lane-details`** view: **STATUS** (clean/dirty, ahead/behind), optional **SETUP** (lane setup progress or retryable failure; press `r` on a failed setup to retry), **CHANGES** (file list + staged/unstaged counts from `diff.listLaneDiffStats`), **ACTIONS** (lane shortcuts — `new chat`, `open / create PR`, `stage all`, `move unstaged to new lane`, `commit`, `push`, `diff`, `reparent`, `delete lane`; each row carries a semantic glyph color so additive actions are green, navigational actions are violet, the rescue-unstaged action is amber, and `delete lane` is red), optional **PR #N** (state chip, CI activity via `checksPending` / `checksFailed`, `↵` opens the PR URL when the PR row is selected), and **CHATS** (active / closed / killed counts from `computeLaneChatCounts`). A `worktreeAvailable` guard surfaces a recoverable warning when the lane worktree path is missing from disk. `/model` opens a separate **`model-setup`** pane for provider/model/reasoning/permission picks before the first prompt. - **FooterControls** — two-row footer. The top row (mode bar, only present when there's content) shows provider glyph + label, model display, fast-mode badge, reasoning effort, permission summary, pending steer count, a 10-cell token usage bar (`TokenBar`) that recolors at 50 / 80 / 95 %, and the cached context-percent / token summary. The bottom row shows pane toggles (`^o` lanes, `^p` pane, `^a` chat info) and pane-specific hints (drawer mode lanes/chats, details navigation, chat scroll position, `/steer` reminder when steers are queued). The `⊚ chat info` chip shows the live subagent count when greater than zero. `footerControlsForAvailability(agentsAvailable)` decides which toggles are wired. @@ -217,7 +217,7 @@ Right pane (open contextual content): | `/lane archived` | List archived lanes. | | `/lane delete` | Open a right-pane confirmation form for deleting the active lane. | | `/pr` | Open PR details with summary and checks. | -| `/pr open` | Create or open a PR for the active lane. | +| `/pr open` | Create or open a PR for the active lane; new PR forms default to `source lane -> target lane` and submit normal PRs (`draft: false`). | | `/pr review` | Show PR reviews. | | `/pr comments` | Show actionable PR comments. | | `/pr comment <text>` | Comment on the active PR. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 0c8f0fdf5..fcb01f8f0 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -481,15 +481,19 @@ other bottom drawer panels. ## File changes panel -`ChatFileChangesPanel` aggregates `turn_diff_summary` events across the -session using `aggregateFiles(summaries)`: +`ChatTurnFileChangesPanel` renders `turn_diff_summary` events inline at +the bottom of the turn that produced them. The collapsed row shows the +turn's file count and aggregate insert/delete totals. Expanding it shows +two nested diff scopes: -- Advances `afterSha` and stats as later turns amend the same file. -- Renders a compact list with status badges (`A`, `D`, `M`, `R`, `C`) - and basename. -- Clicking a file lazily fetches the diff via - `ade.agentChat.getTurnFileDiff` and shows it in `AdeDiffViewer` - (compact toolbar hidden). +- **This turn** — fetches the selected turn diff via + `ade.agentChat.getTurnFileDiff`. +- **Full thread** — aggregates all available turn summaries for the + session, advancing `afterSha` and stats as later turns amend the same + file, then fetches the combined diff through the same API. + +Both scopes render the shared `AdeDiffViewer`; the former bottom-of-chat +aggregate bar is not mounted on ADE chat surfaces. ## Rewind files confirmation @@ -613,12 +617,12 @@ checks the existing tab list by `sessionId` / `ptyId` before pushing a new entry, and the `AgentChatPane` `revealCreatedTerminal` effect calls the same drawer with the recovered `{ terminalId, ptyId, label }`. -`ChatTerminalToggle` is the header button that shows the active tab -count. The drawer is mounted only when lane tool drawers are visible on -the chat surface. Work-grid tiles pass `hideLaneToolDrawers` because the -Work sidebar owns lane-scoped tools there; in that mode the header -toggle is absent and the pane does not call `ade.terminal.list` just to -hydrate a hidden drawer. +The drawer is mounted only when lane tool drawers are visible on the +chat surface. Work-grid tiles pass `hideLaneToolDrawers` because the +Work sidebar owns lane-scoped tools there; chat headers no longer expose +a separate Terminal shortcut. Other chat-owned terminal creation paths +still reveal the matching drawer tab through the shared +`revealCreatedTerminal` flow. ## Pending input card diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index c1153e56f..684d9a044 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -419,7 +419,7 @@ changing rather than which service backs it: |---|---|---| | General | `GeneralSection.tsx` (GitHub/Linear connections, voice input, launch prompts, completion sound, PR transcripts, project files, environment) | Consolidated day-to-day preferences and integrations. GitHub and Linear auth live here (not a separate Integrations tab). Legacy `?tab=integrations`, `?tab=github`, and `?tab=linear` redirect to General with hash anchors (`#github-connection`, `#linear-connection`). Also receives `?tab=onboarding`, `?tab=help`, `?tab=tours`, and `?tab=keybindings` via `TAB_ALIASES`. | | Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, chat font size, transcript density, chrome tint, shell geometry, and the user-message minimap toggle. Persisted to `localStorage` under `ade.userPreferences.v1`. | -| AI Connections | `ProvidersSection.tsx` | Provider CLIs, models, API-key status, provider readiness, OpenCode runtime diagnostics. Legacy `?tab=providers` lands here. | +| AI Connections | `ProvidersSection.tsx` | Provider CLIs, models, API-key status, provider readiness, OpenCode runtime diagnostics. When Claude is installed but unauthenticated, the shared `Login to Claude` CTA opens a primary-lane terminal running `claude auth login` and navigates to Work. Legacy `?tab=providers` lands here. | | Background Jobs | `AiFeaturesSection.tsx` | AI-powered automations: summaries, PR descriptions, commit messages, auto-naming. Legacy `?tab=automations` lands here. Each feature row has an independent reasoning-effort override (`ReasoningEffortPicker` with `useFamilyDefaults={false}`). | | Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes and lane lifecycle policy | | Stats | `AdeUsageSection.tsx` | Local runtime token / cost summaries and GitHub-backed PR, commit, and code movement totals. Deep links from `?tab=usage` and `?tab=stats` land here. | diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 1e25b9774..7d213c565 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -97,7 +97,7 @@ Renderer components (`apps/desktop/src/renderer/components/prs/`): | `PRsPage.tsx` | Top-level tab shell (GitHub vs Workflows) with URL-driven state. Consumes create-PR handoff params from either router search or hash search (`create=1`, `sourceLaneId` / `laneId`, `target=primary`) and the `prs.create` dialog bus props, then opens `CreatePrModal` with matching initial values without persisting the one-shot route as the last PR route. | | `state/PrsContext.tsx` | PR data provider (list, selection, queue groups, rebase needs). Selected-PR primary reads apply progressively as status/check/review/comment requests resolve, so one slow piece does not hold the whole detail pane busy; cached snapshots stay visible during GitHub rate limits. Workflow queue-state reads tolerate older remote runtimes that do not expose the optional `pr.listQueueStates` action by rendering an empty queue-state set instead of failing the whole PR refresh. | | `prsRouteState.ts` | URL ↔ page state mapping plus project-scoped last-route storage. When a project root is known, the PRs tab reads only that project's stored route and does not fall back to the legacy global route from another project. | -| `CreatePrModal.tsx` | Draft/queue/integration PR creation with lane warnings, branch name validation, and optional initial values for single-PR handoffs from lane/chat surfaces. A `target: "primary"` handoff resolves the base branch from the primary lane (falling back to `main`). | +| `CreatePrModal.tsx` | Normal/queue/integration PR creation with lane warnings, branch name validation, and optional initial values for single-PR handoffs from lane/chat surfaces. Normal PRs default the title to `source lane -> target lane`; a `target: "primary"` handoff resolves the base branch from the primary lane (falling back to `main`). | | `tabs/NormalTab.tsx` | Normal PR list | | `tabs/GitHubTab.tsx` | Repository PR browser with label filters, CI badges, review indicators, ADE-vs-unmanaged scope counts, and linked-lane context. State filter is one of `open` / `closed` / `merged` / `all`. The tab ignores legacy cross-repo `externalPullRequests` payloads; the "External" scope means repo PRs that are not managed by ADE. The "create lane from PR branch" affordance has been removed — open/closed PRs on branches without a lane no longer offer the preflight + create dialog (`prsPreflightCreateLaneFromPrBranch` / `prsCreateLaneFromPrBranch` IPC channels have been deleted), so creating a lane for an existing PR now goes through the standard lane creation flow. Snapshot rows are mapped through `reconcileLinkedPrState` (using `isTerminalPrState` from `renderer/lib/prState.ts`) into `displayedItems`, so a terminal ADE PR state (merged/closed) overrides a stale non-terminal GitHub row for the same linked PR across the list, filter counts, and selection (see [Terminal-state precedence](#terminal-state-precedence)). | | `tabs/QueueTab.tsx` | Merge queue UI showing queued stack members and their landing state. | @@ -722,8 +722,12 @@ 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 client calls `getMobileSnapshot` -on open and re-fetches on focus or after a successful mutation. +`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. +The mobile client calls `getMobileSnapshot` on open and re-fetches on +focus or after a successful mutation. The mobile PR **detail** screen (`PrDetailView`, a single-column adaptation of the desktop Timeline+Rails layout) pulls its per-PR action diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 19a1d89ea..a0d3fb050 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -1002,6 +1002,16 @@ broken attachment. | **CTO** | `brain` | `/cto` | The CTO chat thread rendered inline as the tab body (single persistent session via `CtoSessionDestinationView`), with a top-bar gear opening the settings sheet (identity, read-only Linear status, memory via `cto.getMemory`, re-run setup). | | **Settings** | `gearshape` | `/settings` (sync subset) | Pairing — scan the QR (`SettingsPairingScannerSheet`), discover on network, or enter machine details manually — plus PIN entry (`SettingsPinSheet`), appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget, and a **Push delivery** panel (`SettingsPushDeliverySection`: registration/permission state, APNs environment, relay reachability from `push.getStatus`, and notification / Live-Activity / quiet-hours toggles). `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` / `SettingsPushDeliverySnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`, `SettingsPushDeliverySection`) instead of having them reach into `SyncService` directly. | +`WorkModelPickerSheet` shows the same Claude authentication affordance +as desktop when Claude-family models are unavailable: a compact +`Login to Claude` action opens a primary-lane terminal by calling +`SyncService.startClaudeLoginTerminal`, which sends +`work.runQuickCommand` with `startupCommand: "claude auth login"` and +`toolType: "run-shell"`, then navigates to the created Work session. +Call sites with lane context pass their lanes; otherwise the sheet +fetches the current lane list before reporting that no active lane is +available. + ### Planned - Automations, Graph, History tabs. @@ -1087,8 +1097,11 @@ earlier Mode → Source → Details → Review stepper was removed): a mode selector (hidden when the wizard is opened with `singleModeOnly`, e.g. from a lane that can only create one PR), a source-branches section, and a target-branch picker rendered by `PrTargetBranchPickerDropdown` -(searchable dropdown over the lane's eligible base branches). Per-mode -submit handlers route through the sync command surface: +(searchable dropdown over the lane's eligible base branches). The title +defaults to `source lane -> target lane`, submit defaults to a normal +PR (`draft: false`), and the wizard no longer calls the AI PR draft +flow before submission. Per-mode submit handlers route through the sync +command surface: - single → `prs.createFromLane` (via `onCreateSingle` callback) - queue → `prs.createQueue` and `prs.startQueueAutomation`, returning diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 085993bc3..b5b1c23eb 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -216,9 +216,9 @@ Branches on `session.toolType`: - chat tool types → `AgentChatPane` for the matching chat session - PTY sessions → `TerminalView` wired to the session's `ptyId` -- running tracked agent CLI sessions add the Terminal button in their work - header, opening the Work sidebar's Terminal tab with that CLI session as - the owner +- lane-scoped terminal tools are opened from the Work sidebar's + Terminal tab; tracked agent CLI sessions no longer add a separate + Terminal shortcut in their work header When a tile is suspended (grid layout where the tile is not visible), it renders a static preview card instead of mounting the terminal.