diff --git a/src/components/chat/composer-connection-status.tsx b/src/components/chat/composer-connection-status.tsx index 30f9fa6ca..e20e53113 100644 --- a/src/components/chat/composer-connection-status.tsx +++ b/src/components/chat/composer-connection-status.tsx @@ -18,13 +18,17 @@ import { cn } from "@/lib/utils" // and anything unknown falls back to "disconnected". type ConnStatusKey = "connected" | "connecting" | "error" | "disconnected" -// Colour-coded heart icons per connection state (HeartCrack stands in for the -// requested HeartX, which lucide does not ship). +// Heart icons per connection state (HeartCrack stands in for the requested +// HeartX, which lucide does not ship). The steady "connected" state carries no +// colour override, so it inherits the row's default `text-muted-foreground` +// (matching the sibling context-usage circle) — the common resting state +// shouldn't stand out. Only the transient/abnormal states stay colour-coded: +// connecting amber, error red, disconnected dimmed. const STATUS_ICON: Record< ConnStatusKey, { Icon: LucideIcon; className: string } > = { - connected: { Icon: HeartHandshake, className: "text-emerald-500" }, + connected: { Icon: HeartHandshake, className: "" }, connecting: { Icon: HeartPulse, className: "text-amber-500 animate-pulse" }, error: { Icon: HeartCrack, className: "text-red-500" }, disconnected: { Icon: HeartOff, className: "text-muted-foreground/60" }, diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 6506ead05..4f5d31599 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -3595,7 +3595,13 @@ export function MessageInput({
-
+ {/* `pr-px` offsets the composer chrome's 1px border: the send button + sits INSIDE that border while this status row sits outside it, so + without the 1px nudge the trailing icon hangs 1px past the button. + With it, the connection icon's RIGHT edge is flush (0px) with the + send button's right edge in the action bar above — no centring + slot, which would inset the narrow icon and break the alignment. */} +
diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index a37bb9043..b6e894dce 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -17,6 +17,7 @@ import { reuseSet, selectChatConversationsWithReuse, selectPinnedWithReuse, + worktreeChildrenByParent, type SidebarRow, } from "./sidebar-conversation-grouping" @@ -185,6 +186,64 @@ describe("groupByFolderWithReuse", () => { }) }) +describe("worktreeChildrenByParent", () => { + const folder = ( + id: number, + parent_id: number | null, + sort_order = 0, + name = `folder-${id}` + ) => ({ id, parent_id, sort_order, name }) + + it("returns an empty map when no repo has worktree children", () => { + const folders = [folder(10, null), folder(20, null)] + expect(worktreeChildrenByParent([10, 20], folders).size).toBe(0) + }) + + it("groups each repo's open worktree children under it", () => { + const folders = [ + folder(10, null), + folder(12, 10, 2, "beta"), + folder(11, 10, 1, "alpha"), + folder(20, null), + folder(21, 20, 0, "wt"), + ] + const map = worktreeChildrenByParent([10, 20], folders) + // 11 (sort_order 1) before 12 (sort_order 2). + expect(map.get(10)).toEqual([11, 12]) + expect(map.get(20)).toEqual([21]) + // Only repos WITH children are keys. + expect([...map.keys()].sort((a, b) => a - b)).toEqual([10, 20]) + }) + + it("orders children by sort_order, then name, then id", () => { + const folders = [ + folder(10, null), + folder(13, 10, 0, "b"), + folder(12, 10, 0, "a"), + folder(11, 10, 0, "a"), + ] + // equal sort_order → by name ("a" < "b"): 11/12 before 13; tie on name → id. + expect(worktreeChildrenByParent([10], folders).get(10)).toEqual([ + 11, 12, 13, + ]) + }) + + it("omits an orphan worktree whose parent is not a top-level entry", () => { + // Parent 10 is closed → absent from the top-level set, so worktree 11 is + // nobody's child here and no container is produced. + const folders = [folder(11, 10), folder(20, null)] + expect(worktreeChildrenByParent([11, 20], folders).size).toBe(0) + }) + + it("does not mutate the inputs", () => { + const folders = [folder(10, null), folder(11, 10)] + const top = [10] + worktreeChildrenByParent(top, folders) + expect(top).toEqual([10]) + expect(folders.map((f) => f.id)).toEqual([10, 11]) + }) +}) + describe("reuseSet", () => { it("returns the previous set when membership is unchanged", () => { const prev = new Set(["a:1", "b:2"]) @@ -783,6 +842,154 @@ describe("buildRows", () => { }) }) +describe("buildRows — Show worktrees container tree", () => { + // Trim the trailing (always-present) Chat section for exact folder assertions. + const trimChats = (rows: SidebarRow[]): SidebarRow[] => { + const i = rows.findIndex( + (r) => r.kind === "section" && r.section === "chats" + ) + return i === -1 ? rows : rows.slice(0, i) + } + + it("nests a container repo's root sub-group + worktrees, both at depth 1", () => { + const rootConv = conv(1, 10) + const wtConv = conv(2, 11) + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([ + [10, [rootConv]], + [11, [wtConv]], + ]), + folderExpanded: {}, + folderTotalCounts: new Map([ + [10, 1], + [11, 1], + ]), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + }) + expect(trimChats(rows)).toEqual([ + { kind: "section", section: "folders", expanded: true, count: 1 }, + { kind: "folder", folderId: 10 }, // container + { kind: "root-group", folderId: 10 }, // repo's own sessions + { kind: "conversation", conversation: rootConv, depth: 1 }, + { kind: "folder", folderId: 11 }, // worktree + { kind: "conversation", conversation: wtConv, depth: 1 }, + ]) + }) + + it("hides the whole subtree when the container is collapsed", () => { + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([ + [10, [conv(1, 10)]], + [11, [conv(2, 11)]], + ]), + folderExpanded: { 10: false }, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + }) + // Container header only — no root-group, no worktree rows. + expect(trimChats(rows)).toEqual([ + { kind: "section", section: "folders", expanded: true, count: 1 }, + { kind: "folder", folderId: 10 }, + ]) + }) + + it("collapses only the root sub-group, leaving worktrees visible", () => { + const wtConv = conv(2, 11) + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([ + [10, [conv(1, 10)]], + [11, [wtConv]], + ]), + folderExpanded: {}, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + rootGroupCollapsed: new Set([10]), + }) + expect(trimChats(rows)).toEqual([ + { kind: "section", section: "folders", expanded: true, count: 1 }, + { kind: "folder", folderId: 10 }, + { kind: "root-group", folderId: 10 }, // header shown, sessions hidden + { kind: "folder", folderId: 11 }, + { kind: "conversation", conversation: wtConv, depth: 1 }, + ]) + }) + + it("emits an empty hint for an empty root sub-group / worktree", () => { + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map(), + folderExpanded: {}, + folderTotalCounts: new Map([ + [10, 4], + [11, 0], + ]), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + }) + expect(trimChats(rows)).toEqual([ + { kind: "section", section: "folders", expanded: true, count: 1 }, + { kind: "folder", folderId: 10 }, + { kind: "root-group", folderId: 10 }, + { kind: "empty", folderId: 10, totalConversationCount: 4 }, + { kind: "folder", folderId: 11 }, + { kind: "empty", folderId: 11, totalConversationCount: 0 }, + ]) + }) + + it("renders a repo with no worktree children flat at depth 0 (no root-group)", () => { + const c = conv(1, 20) + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10, 20], + byFolder: new Map([ + [11, [conv(9, 11)]], + [20, [c]], + ]), + folderExpanded: {}, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + // Only repo 10 is a container; repo 20 stays a plain flat folder. + containerChildren: new Map([[10, [11]]]), + }) + const trimmed = trimChats(rows) + // Repo 20 (plain): header + its own session at depth 0 — no root-group. + expect(trimmed).toContainEqual({ kind: "folder", folderId: 20 }) + expect(trimmed).toContainEqual({ + kind: "conversation", + conversation: c, + depth: 0, + }) + expect( + trimmed.some((r) => r.kind === "root-group" && r.folderId === 20) + ).toBe(false) + }) +}) + describe("mergeChildrenById", () => { it("keeps live events over the snapshot by id and adds new children", () => { const snapA = conv(100, 1, { status: "pending" }) diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index ec66295aa..58b1770de 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -1,4 +1,4 @@ -import type { DbConversationSummary } from "@/lib/types" +import type { DbConversationSummary, FolderDetail } from "@/lib/types" import type { SidebarSortMode, SidebarSectionOrder, @@ -259,6 +259,60 @@ export function selectChatConversationsWithReuse( return arraysShallowEqual(prev, next) ? prev : next } +// ── Folder display ordering (worktree nesting) ────────────────────────────── + +/** The folder fields needed to nest worktree children under their repo root. */ +export type FolderOrderInput = Pick< + FolderDetail, + "id" | "parent_id" | "sort_order" | "name" +> + +/** + * Group each top-level repo's OPEN worktree child folders under it, returning a + * `repoId → [worktreeChildId, ...]` map that only contains repos with at least + * one worktree child. A folder is a worktree child of `p` when its `parent_id` + * is `p` and `p` is a top-level entry in `topLevelFolderIds`; children are + * ordered `sort_order`, then `name`, then `id` for a stable, input-order- + * independent sequence. + * + * Drives the "Show worktrees" container tree: a repo present as a key renders as + * a container (its own sessions move into an indented "root" sub-group, and each + * worktree becomes an indented sub-group after it). An orphan worktree whose + * parent is closed/removed is not a top-level entry's child, so it appears in no + * value list and stands alone as an ordinary top-level folder. Pure — no side + * effects, never mutates inputs. + */ +export function worktreeChildrenByParent( + topLevelFolderIds: readonly number[], + folders: readonly FolderOrderInput[] +): Map { + const topLevel = new Set(topLevelFolderIds) + const childrenByParent = new Map() + for (const f of folders) { + if (f.parent_id == null) continue + if (!topLevel.has(f.parent_id)) continue + const list = childrenByParent.get(f.parent_id) + if (list) list.push(f) + else childrenByParent.set(f.parent_id, [f]) + } + + const out = new Map() + for (const [parentId, list] of childrenByParent) { + // Stable order within a repo: sort_order, then name, then id as a final + // tie-break so the sequence is deterministic regardless of input order. + list.sort((a, b) => { + if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order + const byName = a.name.localeCompare(b.name) + return byName !== 0 ? byName : a.id - b.id + }) + out.set( + parentId, + list.map((f) => f.id) + ) + } + return out +} + // ── Flat row model (Phase 2 virtualization) ───────────────────────────────── // The sidebar tree (folders → their conversation rows) is flattened into a // single linear array so it can be windowed by `virtua`. Each visible folder @@ -270,6 +324,21 @@ export interface FolderHeaderRow { folderId: number } +/** + * The "root" sub-group header shown (only under "Show worktrees") directly below + * a repo CONTAINER header: it groups the repo's OWN (non-worktree) conversations + * under an indented, FolderRoot-glyph "root folder" heading, parallel to each + * worktree sub-group. Distinct from {@link FolderHeaderRow} so it can share the + * container's numeric `folderId` (the repo id, used for its bucket / count / + * theme) without colliding with the container's own folder header row, and so + * the sticky-header machinery — keyed on `kind === "folder"` — deliberately + * skips it (single-level sticky: the container stands in for its root sessions). + */ +export interface RootGroupHeaderRow { + kind: "root-group" + folderId: number +} + export interface ConversationRow { kind: "conversation" /** @@ -350,6 +419,7 @@ export interface SubsessionLoadingRow { export type SidebarRow = | SectionHeaderRow | FolderHeaderRow + | RootGroupHeaderRow | ConversationRow | EmptyHintRow | ChatsEmptyRow @@ -364,6 +434,10 @@ const MAX_RENDER_DEPTH = 32 const EMPTY_EXPANDED: ReadonlySet = new Set() const EMPTY_CHILDREN: ReadonlyMap = new Map() +// No repo is a worktree container by default (Show worktrees off) — every folder +// then takes the flat path, identical to the pre-worktree row model. +const EMPTY_CONTAINER_CHILDREN: ReadonlyMap = + new Map() /** * Merge a freshly-fetched children snapshot with child summaries already applied @@ -503,6 +577,17 @@ export function buildRows(args: { * spinner; an empty array WITHOUT membership is a settled-empty subtree * (renders nothing). Optional. */ childrenLoading?: ReadonlySet + /** "Show worktrees" container map: `repoId → [worktreeChildId, ...]`. A repo + * present here renders as a CONTAINER — its header is followed (when the + * container is expanded via `folderExpanded[repoId]`) by a `root-group` header + * + the repo's own sessions (depth 1), then each worktree's header + sessions + * (depth 1). Absent/empty (the default) = the flat model: every folder renders + * its header + its own bucket at depth 0, exactly as before. Optional. */ + containerChildren?: ReadonlyMap + /** Repo ids whose `root-group` sub-group is collapsed (its own sessions + * hidden). Absent = expanded (the default). Only consulted for container + * repos. Optional. */ + rootGroupCollapsed?: ReadonlySet }): SidebarRow[] { const { pinned, @@ -518,6 +603,8 @@ export function buildRows(args: { conversationExpanded = EMPTY_EXPANDED, childrenByParent = EMPTY_CHILDREN, childrenLoading = EMPTY_EXPANDED, + containerChildren = EMPTY_CONTAINER_CHILDREN, + rootGroupCollapsed = EMPTY_EXPANDED, } = args const rows: SidebarRow[] = [] @@ -547,6 +634,32 @@ export function buildRows(args: { // the order they emit into `rows` is a one-line swap below — the row logic // inside each (both headers always present, each with its own empty hint) // stays intact regardless of position. + // Emit one folder's body (its conversation rows, or a single empty hint) at + // `baseDepth` — 0 for a plain top-level folder, 1 for a container's root + // sub-group or a worktree sub-group. The empty hint carries no depth; the + // renderer derives its indent from the folder id (worktree/container → 1). + const pushFolderBody = (folderId: number, baseDepth: number) => { + const convs = byFolder.get(folderId) + if (!convs || convs.length === 0) { + rows.push({ + kind: "empty", + folderId, + totalConversationCount: folderTotalCounts.get(folderId) ?? 0, + }) + return + } + for (const conv of convs) { + pushConversationRow( + rows, + conv, + baseDepth, + conversationExpanded, + childrenByParent, + childrenLoading + ) + } + } + const pushFolders = () => { // The Folders section header is always present (a permanent entry point), // mirroring the Chat section — so a workspace with chats but no open folders @@ -565,27 +678,26 @@ export function buildRows(args: { return } for (const folderId of orderedFolderIds) { - rows.push({ kind: "folder", folderId }) - const expanded = folderExpanded[folderId] ?? true - if (!expanded) continue - const convs = byFolder.get(folderId) - if (!convs || convs.length === 0) { - rows.push({ - kind: "empty", - folderId, - totalConversationCount: folderTotalCounts.get(folderId) ?? 0, - }) + const worktrees = containerChildren.get(folderId) + if (!worktrees || worktrees.length === 0) { + // Plain top-level folder (or, under Show worktrees, a repo with no open + // worktrees): header + its own bucket at depth 0 — the flat model. + rows.push({ kind: "folder", folderId }) + if (folderExpanded[folderId] ?? true) pushFolderBody(folderId, 0) continue } - for (const conv of convs) { - pushConversationRow( - rows, - conv, - 0, - conversationExpanded, - childrenByParent, - childrenLoading - ) + // Container repo: its header gates the WHOLE subtree (root sub-group + + // worktrees). Collapsing it hides everything below, so the connector spine + // only spans an expanded container. + rows.push({ kind: "folder", folderId }) + if (!(folderExpanded[folderId] ?? true)) continue + // The repo's OWN sessions move into an indented "root" sub-group, first. + rows.push({ kind: "root-group", folderId }) + if (!rootGroupCollapsed.has(folderId)) pushFolderBody(folderId, 1) + // Then each worktree as its own indented sub-group. + for (const worktreeId of worktrees) { + rows.push({ kind: "folder", folderId: worktreeId }) + if (folderExpanded[worktreeId] ?? true) pushFolderBody(worktreeId, 1) } } } diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index d063750e2..602017183 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -26,7 +26,7 @@ import enMessages from "@/i18n/messages/en.json" // FolderOpen lucide icon renders once per FolderHeader body → counts folder // re-renders. Both increment only when the owning memoized component does NOT // bail out, so they measure exactly the production memo path. -const probes = vi.hoisted(() => ({ card: 0, folder: 0 })) +const probes = vi.hoisted(() => ({ card: 0, folder: 0, root: 0 })) // Mutable backing store the mocked tab-context hook reads from (workspace data // now lives in the real zustand store, seeded per test below). `tabs` is @@ -133,10 +133,14 @@ vi.mock("virtua", () => ({ }, })) -// FolderHeader renders exactly one of FolderClosed/FolderOpen in its body → -// folder re-render probe. Every other icon stays real. (The Folders section -// header's Open Folder / Clone Repository actions use FolderOpenDot / FolderGit2, -// which are NOT mocked here, so they never inflate this probe.) +// FolderHeader renders exactly one glyph in its body per variant: FolderClosed/ +// FolderOpen (a repo / plain folder / repo container header) → `probes.folder`, +// or FolderRoot (a container's "root" sub-group header) → `probes.root`. Every +// other icon stays real. FolderRoot is used ONLY by the root sub-group, so it is +// an exact re-render probe. Worktree headers use FolderGit2, which the Folders +// section header's Clone action ALSO renders — so it is deliberately left real +// (not a probe); worktree headers are asserted via `data-folder-id` + the branch +// label instead. vi.mock("lucide-react", async (importOriginal) => { const actual = await importOriginal() return { @@ -149,6 +153,10 @@ vi.mock("lucide-react", async (importOriginal) => { probes.folder++ return null }, + FolderRoot: () => { + probes.root++ + return null + }, } }) @@ -799,3 +807,166 @@ describe("SidebarConversationList — folder ⋯ opens the same menu as right-cl expect(document.body.textContent).toContain("Manage conversations") }) }) + +describe("SidebarConversationList — worktree grouping (Show worktrees)", () => { + // A repo (folder 1) with two conversations, plus a worktree child (folder 2, + // branch "feature-x") holding one conversation. + const wtHarness: { rerender: () => void } = { rerender: () => {} } + function WtHarness({ showWorktrees }: { showWorktrees: boolean }) { + const [, setTick] = useState(0) + useEffect(() => { + wtHarness.rerender = () => setTick((n) => n + 1) + }, []) + return ( + + ) + } + function wtTree(showWorktrees: boolean) { + return ( + + + + ) + } + + function folderHeaderIds(): number[] { + return Array.from(document.querySelectorAll("[data-folder-id]")).map((el) => + Number(el.getAttribute("data-folder-id")) + ) + } + + beforeEach(() => { + probes.card = 0 + probes.folder = 0 + probes.root = 0 + const wt = { + ...folder(2, "wt-feature", 1), + git_branch: "feature-x", + } as unknown as FolderDetail + const folders = [folder(1, "Repo"), wt] + store.activeTabId = null + store.tabSpec = [] + useAppWorkspaceStore.setState({ + folders, + allFolders: folders, + conversations: [conv(11, 1), conv(12, 1), conv(21, 2)], + }) + }) + + it("merges worktree conversations flat under the parent when off", () => { + render(wtTree(false)) + // Only the repo gets a header; the worktree child is hidden and its + // conversation is merged into the repo bucket — no container/root split. + expect(folderHeaderIds()).toEqual([1]) + const text = document.body.textContent ?? "" + expect(text).toContain("conv-11") + expect(text).toContain("conv-12") + expect(text).toContain("conv-21") + // No worktree header → no branch label; no root sub-group (probes.root===0, + // the unambiguous check — the "root" label is a generic word to match on). + expect(text).not.toContain("feature-x") + expect(probes.root).toBe(0) + }) + + it("splits the repo into a container + root sub-group + worktree sub-group when on", () => { + render(wtTree(true)) + // Container header (repo id 1) → root sub-group header (also repo id 1, its + // own sessions) → worktree header (id 2). The repo id appears twice: the + // container and its root sub-group both carry it. + expect(folderHeaderIds()).toEqual([1, 1, 2]) + const text = document.body.textContent ?? "" + // Order: container "Repo" → "root" sub-group + its own convs → worktree + // branch "feature-x" + the worktree's conv. + const iRoot = text.indexOf("root") + const iRepoConv = text.indexOf("conv-11") + const iBranch = text.indexOf("feature-x") + const iWtConv = text.indexOf("conv-21") + expect(iRoot).toBeGreaterThanOrEqual(0) + expect(iRepoConv).toBeGreaterThan(iRoot) + expect(iBranch).toBeGreaterThan(iRepoConv) + expect(iWtConv).toBeGreaterThan(iBranch) + // Exactly one container header (FolderOpen) + one root sub-group (FolderRoot). + expect(probes.folder).toBe(1) + expect(probes.root).toBe(1) + }) + + it("collapsing the root sub-group hides the repo's own sessions but keeps the worktree", () => { + render(wtTree(true)) + expect(document.body.textContent).toContain("conv-11") + + // Toggle the root sub-group header (the SECOND data-folder-id=1 button, after + // the container). Its own sessions collapse; the worktree stays visible. + const repoHeaders = document.querySelectorAll('[data-folder-id="1"]') + expect(repoHeaders).toHaveLength(2) + act(() => { + fireEvent.click(repoHeaders[1] as HTMLElement) + }) + + const text = document.body.textContent ?? "" + expect(text).not.toContain("conv-11") + expect(text).not.toContain("conv-12") + // The worktree sub-group is untouched. + expect(text).toContain("feature-x") + expect(text).toContain("conv-21") + }) + + it("keeps the connector spine continuous through an empty worktree sub-group", () => { + // Add a second worktree (folder 3) with NO conversations → it renders the + // empty-folder hint. That empty row must still draw the container spine + // (ancestor rail) so the vertical connector doesn't break at "no + // conversations". + const wtEmpty = { + ...folder(3, "wt-empty", 1), + git_branch: "feature-y", + } as unknown as FolderDetail + const cur = useAppWorkspaceStore.getState() + useAppWorkspaceStore.setState({ + folders: [...cur.folders, wtEmpty], + allFolders: [...cur.allFolders, wtEmpty], + }) + render(wtTree(true)) + + const hint = enMessages.Folder.sidebar.emptyFolderHint + const hintSpan = Array.from(document.querySelectorAll("span")).find( + (s) => s.textContent === hint + ) + expect(hintSpan).toBeTruthy() + // The empty row (the hint span's row container) carries an ancestor rail. + const row = hintSpan!.closest("div") + expect(row?.querySelector("[data-subsession-rail]")).not.toBeNull() + }) + + it("keeps the single-status-event budget (1 card, 0 headers) with worktrees on", () => { + render(wtTree(true)) + // Initial mount: all three conversations render a card. + expect(probes.card).toBe(3) + + // Replace exactly the worktree's conversation (conv 21) with a new object; + // every other summary keeps its identity (mirrors updateConversationLocal). + const prev = useAppWorkspaceStore.getState().conversations + const next = prev.slice() + const idx = next.findIndex((c) => c.id === 21) + next[idx] = { ...next[idx], status: "completed" } + + probes.card = 0 + probes.folder = 0 + probes.root = 0 + act(() => { + useAppWorkspaceStore.setState({ conversations: next }) + }) + act(() => wtHarness.rerender()) + + // Only the changed card re-renders; the container header AND the root + // sub-group header (both keyed off `folders`, unchanged by a status event) + // bail out, so the container split costs nothing extra per event. (The + // worktree header shares the same memo + folder-derived props, so 0 root/ + // folder re-renders is sufficient evidence it bails too.) + expect(probes.card).toBe(1) + expect(probes.folder).toBe(0) + expect(probes.root).toBe(0) + }) +}) diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 44dd42878..6a2200fb7 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -25,6 +25,7 @@ import { FolderGit2, FolderOpen, FolderOpenDot, + FolderRoot, ListChecks, Loader2, MoreHorizontal, @@ -102,6 +103,7 @@ import { reuseSet, selectChatConversationsWithReuse, selectPinnedWithReuse, + worktreeChildrenByParent, type SidebarRow, } from "./sidebar-conversation-grouping" import { useSubsessionSync } from "@/hooks/use-subsession-sync" @@ -149,6 +151,18 @@ import { toErrorMessage } from "@/lib/app-error" const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect +// Shared empty merge map used when "Show worktrees" is on: worktree children +// then keep their own conversation bucket / count / theme instead of being +// folded into the parent. A module constant so the reference is stable across +// renders (the `byFolder` / `folderTotalCounts` memos depend on it). +const EMPTY_CHILD_TO_PARENT: ReadonlyMap = new Map() + +// Shared empty container map used when "Show worktrees" is off: no repo is a +// worktree container, so every folder renders flat. Stable reference so the +// `containerChildren` memo (and buildRows through it) doesn't churn. +const EMPTY_CONTAINER_CHILDREN: ReadonlyMap = + new Map() + const FolderHeader = memo(function FolderHeader({ folderId, folderName, @@ -174,6 +188,9 @@ const FolderHeader = memo(function FolderHeader({ isDragging, onGripPointerDown, suppressed = false, + depth = 0, + variant = "repo", + worktreeBranch = null, }: { folderId: number folderName: string @@ -219,6 +236,27 @@ const FolderHeader = memo(function FolderHeader({ * virtua still keeps it mounted in the buffer. */ suppressed?: boolean + /** + * Nesting depth of the header row. 0 for a top-level repo / plain folder / repo + * container; 1 for a worktree or "root" sub-group shown under its container + * when "Show worktrees" is on. Drives the left indent and the connector-spine + * ancestor rails (a pure function of this number), mirroring the conversation + * card's per-level rail step. + */ + depth?: number + /** + * Which glyph + label this header renders: + * - `repo` (default): a top-level repo / plain folder / repo container — the + * FolderOpen/FolderClosed glyph and the repo-name alias label. + * - `worktree`: a git worktree sub-group — the FolderGit2 glyph and the branch + * label (`worktreeBranch`). + * - `root`: a repo container's own-sessions sub-group — the FolderRoot glyph + * and a fixed, non-localized "root" label. + */ + variant?: "repo" | "worktree" | "root" + /** The worktree's branch name (its own `git_branch`), shown as the label when + * `variant === "worktree"`. Falls back to the folder name when absent. */ + worktreeBranch?: string | null }) { // Own the translations here rather than receiving `t` as a prop: next-intl // returns a fresh `t` on every parent render, so passing it down would defeat @@ -294,9 +332,15 @@ const FolderHeader = memo(function FolderHeader({ isDragging ? "cursor-grabbing" : "cursor-grab" )} style={{ - paddingLeft: "calc(var(--conv-rail-axis) + 0.875rem)", + paddingLeft: `calc(var(--conv-rail-axis) + 0.875rem + ${depth} * ${CONV_RAIL_DEPTH_STEP})`, }} > + {/* Connector spine (Show worktrees): a depth-1 sub-group header + draws its container's vertical rail at the depth-0 axis, so + stacked across the container's children (root + worktree + headers and their session cards) it forms one continuous line + down from the container. Renders nothing at depth 0. */} + - {expanded ? ( + {variant === "worktree" ? ( + + ) : variant === "root" ? ( + + ) : expanded ? ( ) : ( @@ -322,11 +370,21 @@ const FolderHeader = memo(function FolderHeader({ "min-w-0 flex-shrink truncate text-left text-[0.875rem] font-normal text-sidebar-foreground/75" )} > - + {variant === "worktree" ? ( + (worktreeBranch ?? folderName) + ) : variant === "root" ? ( + // The container repo's own-sessions sub-group is labeled + // with a fixed, non-localized "root" (its glyph is + // FolderRoot); it stands for the repo root regardless of UI + // language. + "root" + ) : ( + + )} }) { @@ -679,6 +741,7 @@ export function SidebarConversationList({ path: string color: string defaultAgentType: AgentType | null + gitBranch: string | null } >() for (const f of allFolders) @@ -688,6 +751,7 @@ export function SidebarConversationList({ path: f.path, color: f.color, defaultAgentType: f.default_agent_type, + gitBranch: f.git_branch, }) return map }, [allFolders]) @@ -727,6 +791,14 @@ export function SidebarConversationList({ const [folderExpanded, setFolderExpanded] = useState>( {} ) + // Repo ids whose "root" sub-group (a container repo's own sessions, shown only + // under "Show worktrees") is collapsed. Session-only, not persisted: the + // container's own collapse — which hides the whole subtree — IS persisted via + // `folderExpanded`, so this indented sub-toggle is kept deliberately + // lightweight. Default (absent) = expanded. + const [rootGroupCollapsed, setRootGroupCollapsed] = useState>( + () => new Set() + ) // Collapsed state of the two top-level sections ("Pinned", "Folders"). Absent // key = expanded (default). Hydrated from localStorage after mount. const [sectionCollapsed, setSectionCollapsed] = @@ -809,7 +881,7 @@ export function SidebarConversationList({ const dragCleanupRef = useRef<(() => void) | null>(null) const autoscrollRef = useRef(null) // Snapshots read by the imperative drag listeners without re-subscribing them. - const orderedFolderIdsRef = useRef([]) + const reorderableFolderIdsRef = useRef([]) const reorderingRef = useRef(false) useEffect(() => { @@ -970,6 +1042,16 @@ export function SidebarConversationList({ return map }, [folders]) + // The merge map used for DISPLAY (grouping, counts, theming). When "Show + // worktrees" is on it is empty, so each worktree child keeps its own bucket / + // count / theme and renders under its own header. The raw `childToParent` + // above is still used to know which folders ARE worktree children (nesting + // order, indent, grip gating). Off → identical to the historical behavior. + const displayChildToParent = useMemo( + () => (showWorktrees ? EMPTY_CHILD_TO_PARENT : childToParent), + [showWorktrees, childToParent] + ) + // Hold the previous grouping so unchanged folders keep their bucket array // reference across renders (lets memoized FolderGroupItems bail out). Updating // the ref inside the memo factory is a deliberate cache, idempotent under @@ -980,11 +1062,11 @@ export function SidebarConversationList({ folderConversations, sortMode, byFolderRef.current, - childToParent + displayChildToParent ) byFolderRef.current = grouped return grouped - }, [folderConversations, sortMode, childToParent]) + }, [folderConversations, sortMode, displayChildToParent]) // Counts the unfiltered-but-non-pinned conversations per display group, so the // empty-hint renderer distinguishes a truly empty folder from one whose rows @@ -994,17 +1076,22 @@ export function SidebarConversationList({ const map = new Map() for (const conv of conversations) { if (conv.pinned_at != null) continue - const groupId = childToParent.get(conv.folder_id) ?? conv.folder_id + const groupId = displayChildToParent.get(conv.folder_id) ?? conv.folder_id map.set(groupId, (map.get(groupId) ?? 0) + 1) } return map - }, [conversations, childToParent]) - - const orderedFolderIds = useMemo(() => { + }, [conversations, displayChildToParent]) + + // The reorderable, top-level folder sequence: worktree child folders are + // excluded (they follow their parent, never reorder on their own), so this is + // the source of truth for the custom drag gesture and its `pointerYToTargetIndex` + // math. When "Show worktrees" is off this is ALSO the display order; when on, + // `orderedFolderIds` below splices each repo's worktree children back in. + const reorderableFolderIds = useMemo(() => { const folderIdSet = new Set(folders.map((f) => f.id)) - // Worktree child folders are merged into their parent group, so they get no - // header row of their own. Hidden chat folders never reach this list — the - // backend already excludes them from the open-folder set + // Worktree child folders are excluded here (`childToParent.has` is always the + // raw map, independent of `showWorktrees`). Hidden chat folders never reach + // this list — the backend already excludes them from the open-folder set // (`folder_service::list_open_folder_details`). const isHidden = (id: number) => childToParent.has(id) // During drag we honour the optimistic order so sibling folders shift live @@ -1039,6 +1126,27 @@ export function SidebarConversationList({ return ids }, [folders, dragOrder, childToParent]) + // "Show worktrees" container map: repo id → its open worktree child folder ids + // (sorted). A repo present here renders as a CONTAINER — buildRows nests its + // own sessions (a "root" sub-group) plus each worktree beneath it, and the + // whole subtree is gated on the container's own `folderExpanded` entry. Empty + // when the toggle is off (every folder then renders flat). Depends only on + // `folders` (+ the toggle), never on `conversations`, so status events don't + // rebuild it — preserving the single-status-event re-render budget. + const containerChildren = useMemo( + () => + showWorktrees + ? worktreeChildrenByParent(reorderableFolderIds, folders) + : EMPTY_CONTAINER_CHILDREN, + [showWorktrees, reorderableFolderIds, folders] + ) + // The repo ids that are containers (have ≥1 open worktree child). Drives the + // header render's container/plain distinction (total count, subtree toggle). + const containerRepoIds = useMemo( + () => new Set(containerChildren.keys()), + [containerChildren] + ) + const darkMode = resolvedTheme === "dark" // Flat row model for windowing — the pinned section, the folders section, and @@ -1051,7 +1159,9 @@ export function SidebarConversationList({ buildRows({ pinned, pinnedExpanded, - orderedFolderIds, + // Top-level (reorderable) folders drive the outer order; buildRows nests + // each container's root sub-group + worktrees via `containerChildren`. + orderedFolderIds: reorderableFolderIds, byFolder, folderExpanded, folderTotalCounts, @@ -1062,11 +1172,13 @@ export function SidebarConversationList({ conversationExpanded, childrenByParent, childrenLoading, + containerChildren, + rootGroupCollapsed, }), [ pinned, pinnedExpanded, - orderedFolderIds, + reorderableFolderIds, byFolder, folderExpanded, folderTotalCounts, @@ -1077,6 +1189,8 @@ export function SidebarConversationList({ conversationExpanded, childrenByParent, childrenLoading, + containerChildren, + rootGroupCollapsed, ] ) @@ -1085,7 +1199,7 @@ export function SidebarConversationList({ // without being torn down and re-subscribed. const rowsRef = useRef(rows) rowsRef.current = rows - orderedFolderIdsRef.current = orderedFolderIds + reorderableFolderIdsRef.current = reorderableFolderIds reorderingRef.current = reordering // Sticky-overlay lookup tables, rebuilt only when the flat rows change @@ -1106,15 +1220,22 @@ export function SidebarConversationList({ expandAll() { setFolderExpanded((prev) => { const next: Record = { ...prev } - for (const id of orderedFolderIds) next[id] = true + for (const id of reorderableFolderIds) next[id] = true + // Worktree children (containers only) are separate folder ids. + for (const kids of containerChildren.values()) + for (const id of kids) next[id] = true saveFolderExpanded(next) return next }) + // Expand every container's root sub-group too (session-only state). + setRootGroupCollapsed((prev) => (prev.size === 0 ? prev : new Set())) }, collapseAll() { setFolderExpanded((prev) => { const next: Record = { ...prev } - for (const id of orderedFolderIds) next[id] = false + for (const id of reorderableFolderIds) next[id] = false + for (const kids of containerChildren.values()) + for (const id of kids) next[id] = false saveFolderExpanded(next) return next }) @@ -1170,11 +1291,52 @@ export function SidebarConversationList({ pendingScrollRef.current = true return } - // A worktree conversation is rendered under its parent group, so the - // row's visibility is gated by the parent's expansion — expand the - // display group, not the (never-rendered) child folder id. + // Under "Show worktrees" the conversation may sit inside a container's + // subtree, which is gated top-down: the container (repo root) must be + // expanded first, then — for the repo's OWN sessions — its root + // sub-group. Each step defers the scroll one render (pendingScrollRef). + if (showWorktrees) { + const containerRepoId = childToParent.get(conv.folder_id) ?? null + if ( + containerRepoId != null && + !(folderExpanded[containerRepoId] ?? true) + ) { + setFolderExpanded((prev) => { + const next = { ...prev, [containerRepoId]: true } + saveFolderExpanded(next) + return next + }) + pendingScrollRef.current = true + return + } + // The repo's own conversation lives in the indented root sub-group. + if (containerRepoIds.has(conv.folder_id)) { + if (!(folderExpanded[conv.folder_id] ?? true)) { + setFolderExpanded((prev) => { + const next = { ...prev, [conv.folder_id]: true } + saveFolderExpanded(next) + return next + }) + pendingScrollRef.current = true + return + } + if (rootGroupCollapsed.has(conv.folder_id)) { + setRootGroupCollapsed((prev) => { + const next = new Set(prev) + next.delete(conv.folder_id) + return next + }) + pendingScrollRef.current = true + return + } + } + } + // A worktree conversation is rendered under its display group's header, + // so the row's visibility is gated by that group's expansion. With + // "Show worktrees" off the display group is the parent repo; on, the + // worktree folder renders its own header, so expand the folder itself. const displayFolderId = - childToParent.get(conv.folder_id) ?? conv.folder_id + displayChildToParent.get(conv.folder_id) ?? conv.folder_id if (!(folderExpanded[displayFolderId] ?? true)) { setFolderExpanded((prev) => { const next = { ...prev, [displayFolderId]: true } @@ -1207,7 +1369,11 @@ export function SidebarConversationList({ selectedConversation, conversations, folderExpanded, + displayChildToParent, + showWorktrees, childToParent, + containerRepoIds, + rootGroupCollapsed, pinnedExpanded, foldersExpanded, chatsExpanded, @@ -1221,6 +1387,17 @@ export function SidebarConversationList({ }) }, []) + // Toggle a container repo's "root" sub-group (its own sessions). Session-only, + // so unlike `toggleFolder` there is no persistence write. + const toggleRootGroup = useCallback((repoId: number) => { + setRootGroupCollapsed((prev) => { + const next = new Set(prev) + if (next.has(repoId)) next.delete(repoId) + else next.add(repoId) + return next + }) + }, []) + // Lazily fetch a conversation's direct delegation children into the cache. // Deduped against both the cache and in-flight requests so a re-toggle or the // restore-time guard below can call it freely (idempotent, StrictMode-safe). @@ -1648,7 +1825,7 @@ export function SidebarConversationList({ const state = dragPointerRef.current const surface = dragSurfaceRef.current if (!state || !surface) return - const order = orderedFolderIdsRef.current + const order = reorderableFolderIdsRef.current // The surface's live rect already reflects scroll, so no scrollTop term. const targetIndex = pointerYToTargetIndex( clientY, @@ -1736,7 +1913,7 @@ export function SidebarConversationList({ if (moved < DRAG_THRESHOLD_PX) return state.started = true setDragging(state.folderId) - setDragOrder(orderedFolderIdsRef.current.slice()) + setDragOrder(reorderableFolderIdsRef.current.slice()) } updateDragTarget(event.clientY) maybeAutoscroll(event.clientY) @@ -1871,6 +2048,16 @@ export function SidebarConversationList({ ) } + // Total sessions across a container repo and all its worktrees — the count + // shown on the container header (its own sessions live in the root sub-group, + // so the bare `byFolder` count would understate the repo family). + const containerTotalCount = (repoId: number): number => { + let total = byFolder.get(repoId)?.length ?? 0 + const kids = containerChildren.get(repoId) + if (kids) for (const kid of kids) total += byFolder.get(kid)?.length ?? 0 + return total + } + const folderHeaderElement = ( folderId: number, opts: { @@ -1879,23 +2066,50 @@ export function SidebarConversationList({ grip: boolean onToggle?: (folderId: number) => void suppressed?: boolean + /** Render as the container repo's own-sessions "root" sub-group (FolderRoot + * glyph, indented, session-only collapse) rather than the repo header. */ + rootGroup?: boolean } ) => { const folderEntry = folderIndex.get(folderId) + const isRootGroup = opts.rootGroup ?? false + // A worktree child header (only under "Show worktrees"): indented, FolderGit2 + // glyph, branch label. Keyed off `childToParent` so it matches exactly which + // folders buildRows nests — an orphan worktree (parent closed) is neither a + // worktree child nor a container here, so it renders as a plain top-level + // folder, consistent with its slot. + const isWorktree = + !isRootGroup && showWorktrees && childToParent.has(folderId) + // A container repo (has ≥1 open worktree): plain repo glyph but a total count + // spanning the whole family. Its own sessions render in the root sub-group. + const isContainer = + !isRootGroup && showWorktrees && containerRepoIds.has(folderId) + const variant = isRootGroup ? "root" : isWorktree ? "worktree" : "repo" + const depth = isRootGroup || isWorktree ? 1 : 0 + const count = isContainer + ? containerTotalCount(folderId) + : (byFolder.get(folderId)?.length ?? 0) + const expanded = isRootGroup + ? !rootGroupCollapsed.has(folderId) + : opts.collapsed + ? false + : (folderExpanded[folderId] ?? true) return ( ) } @@ -1953,7 +2170,10 @@ export function SidebarConversationList({ row.folderId, folderHeaderElement(row.folderId, { dragging: dragging === row.folderId, - grip: true, + // Worktree child headers follow their parent and never reorder on + // their own, so they are not drag initiators (no grip). Only + // reorderable top-level repos keep the grip. + grip: !(showWorktrees && childToParent.has(row.folderId)), // While this folder's sticky overlay is showing, the overlay is the // accessible control; make the (occluded) in-list copy inert so it is // not a duplicate tab stop / announcement. @@ -1961,16 +2181,46 @@ export function SidebarConversationList({ }) ) } + if (row.kind === "root-group") { + // A container repo's own-sessions sub-group. Shares the repo id (for its + // bucket / count / theme) but is its own row kind with its own toggle, and + // is never draggable (it follows the container). + return themeWrap( + row.folderId, + folderHeaderElement(row.folderId, { + dragging: false, + grip: false, + rootGroup: true, + }) + ) + } if (row.kind === "empty") { + // A worktree / root sub-group's empty hint is indented one level so its + // text lines up under the (depth-1) sub-group's sessions, matching the + // header. A plain folder's hint stays at depth 0. + const nested = + showWorktrees && + (childToParent.has(row.folderId) || containerRepoIds.has(row.folderId)) + const depth = nested ? 1 : 0 return themeWrap( row.folderId, + // Full row height (h-[2rem], the fixed virtua item size) so the container + // connector spine stays continuous THROUGH an empty sub-group ("no + // conversations") instead of breaking at a shorter box. The ancestor rail + // spans this row; it renders nothing at depth 0 (a plain folder has no + // spine).
- {row.totalConversationCount === 0 - ? t("emptyFolderHint") - : t("noUnfinishedConversations")} + + + {row.totalConversationCount === 0 + ? t("emptyFolderHint") + : t("noUnfinishedConversations")} +
) } @@ -2015,9 +2265,11 @@ export function SidebarConversationList({ ) } const conv = row.conversation - // Worktree child folders render under their parent group, so theme the row - // by the display group (parent) for a unified look. - const groupId = childToParent.get(conv.folder_id) ?? conv.folder_id + // Theme the row by its display group. With "Show worktrees" off a worktree + // conversation renders under its parent group (themed as the parent); on, it + // renders under its own worktree header (themed as itself). `displayChildToParent` + // captures both — empty when the toggle is on. + const groupId = displayChildToParent.get(conv.folder_id) ?? conv.folder_id return themeWrap( groupId, { if (row.kind === "section") return `section-${row.section}` if (row.kind === "folder") return `folder-${row.folderId}` + if (row.kind === "root-group") return `rootgroup-${row.folderId}` if (row.kind === "empty") return `empty-${row.folderId}` if (row.kind === "chats-empty") return "chats-empty" if (row.kind === "folders-empty") return "folders-empty" @@ -2121,11 +2374,14 @@ export function SidebarConversationList({ )} > {dragging !== null ? ( - // Drag surface: every folder collapsed to its header so any - // folder (even one that was virtualized off-screen) is a valid - // drop target. Non-virtualized — folder counts are small. + // Drag surface: every reorderable (top-level) folder collapsed + // to its header so any folder (even one virtualized off-screen) + // is a valid drop target. Worktree children are excluded — they + // aren't independently reorderable, and their presence would + // break the `pointerYToTargetIndex` fixed-height row math. + // Non-virtualized — folder counts are small.
- {orderedFolderIds.map((folderId) => ( + {reorderableFolderIds.map((folderId) => (
{themeWrap( folderId, diff --git a/src/components/layout/aux-panel-git-log-tab.tsx b/src/components/layout/aux-panel-git-log-tab.tsx index 9859acaf3..2883fc941 100644 --- a/src/components/layout/aux-panel-git-log-tab.tsx +++ b/src/components/layout/aux-panel-git-log-tab.tsx @@ -174,6 +174,44 @@ export function removeRecentAuthor(recent: string[], author: string): string[] { return recent.filter((a) => a !== author) } +// The last branch/author filter, persisted per folder path so the tab reopens on +// the same view. `null` for either means the default (all branches / all +// authors); when both are null the entry is dropped to keep storage tidy. +const SELECTION_KEY_PREFIX = "codeg:gitlog:selection:" + +type GitLogSelection = { branch: string | null; author: string | null } + +function loadSelection(folderPath: string): GitLogSelection { + if (typeof window === "undefined") return { branch: null, author: null } + try { + const raw = window.localStorage.getItem(SELECTION_KEY_PREFIX + folderPath) + if (!raw) return { branch: null, author: null } + const parsed = JSON.parse(raw) + return { + branch: typeof parsed?.branch === "string" ? parsed.branch : null, + author: typeof parsed?.author === "string" ? parsed.author : null, + } + } catch { + return { branch: null, author: null } + } +} + +function saveSelection(folderPath: string, selection: GitLogSelection): void { + if (typeof window === "undefined") return + try { + if (selection.branch == null && selection.author == null) { + window.localStorage.removeItem(SELECTION_KEY_PREFIX + folderPath) + return + } + window.localStorage.setItem( + SELECTION_KEY_PREFIX + folderPath, + JSON.stringify(selection) + ) + } catch { + // Best-effort — a failed persist just means the view won't be restored. + } +} + const emitEvent = async (event: string, payload?: unknown) => { try { const { emit } = await import("@tauri-apps/api/event") @@ -1297,6 +1335,32 @@ export function GitLogTab() { const [recentAuthors, setRecentAuthors] = useState([]) const [selectedAuthor, setSelectedAuthor] = useState(null) + // The folder path whose persisted selection is currently applied. When the + // (deferred) folder changes we restore that folder's saved branch/author + // filter *synchronously during render* — before the first log fetch — so the + // reopened view lands on the same filter in a single fetch, with no + // all-branches flash. This is React's "adjust state during render" pattern + // (https://react.dev/reference/react/useState#storing-information-from-previous-renders); + // it re-renders in place, so fetchLog below is computed with the restored + // values. Reading localStorage here is a pure, idempotent read. + const [selectionFolder, setSelectionFolder] = useState(null) + const currentSelectionPath = folder?.path ?? null + if (currentSelectionPath !== selectionFolder) { + setSelectionFolder(currentSelectionPath) + const restored = currentSelectionPath + ? loadSelection(currentSelectionPath) + : { branch: null, author: null } + setSelectedBranch(restored.branch) + setSelectedAuthor(restored.author) + } + // Mirror the live selection into refs so refreshBranches (keyed only on the + // folder path) can validate/persist against the latest values without being + // recreated — and re-running its effect — on every filter change. + const selectedBranchRef = useRef(selectedBranch) + selectedBranchRef.current = selectedBranch + const selectedAuthorRef = useRef(selectedAuthor) + selectedAuthorRef.current = selectedAuthor + // Lazy per-commit file changes: the log list is fetched without file stats // (withFiles=false) for speed; a commit's files load on demand when its row is // expanded (mirrors branchesByCommit above). @@ -1359,20 +1423,16 @@ export function GitLogTab() { // pending request's finally still clears its loading flag (no latch). const filesGenRef = useRef(0) - // Close the create-branch / reset dialogs — and clear the author filter (it's - // repo-specific) — the instant the ACTIVE folder changes (keyed on the live - // id, not the deferred `folder`) so a dialog opened under the previous folder - // can't create-branch / reset against the new one after the deferred render - // settles, and so the new folder's first fetch never carries the old folder's - // author filter. + // Close the create-branch / reset dialogs the instant the ACTIVE folder + // changes (keyed on the live id, not the deferred `folder`) so a dialog opened + // under the previous folder can't create-branch / reset against the new one + // after the deferred render settles. The branch/author selection is NOT reset + // here — it's restored per folder during render (see the selectionFolder + // adjustment above), which lands each folder's saved filter before its first + // fetch, so the previous folder's filter never leaks into the new query. useEffect(() => { setNewBranchTarget(null) setResetTarget(null) - setSelectedAuthor(null) - // Reset to the default "all branches" view. refreshBranches no longer sets - // selectedBranch, so without this the previous folder's branch would leak - // into the new repo's query (empty/wrong log) since the tab stays mounted. - setSelectedBranch(null) }, [activeFolder?.id]) const pushStatusLabels = useMemo( @@ -1389,24 +1449,37 @@ export function GitLogTab() { return (parts[parts.length - 1] ?? path) || t("workspace") }, [folder?.path, t]) - const handleBranchChange = useCallback((branch: string | null) => { - setSelectedBranch(branch) - }, []) + const handleBranchChange = useCallback( + (branch: string | null) => { + setSelectedBranch(branch) + // Persist the pick (paired with the current author) so the tab restores it + // the next time this folder is opened. + if (folder?.path) { + saveSelection(folder.path, { branch, author: selectedAuthor }) + } + }, + [folder?.path, selectedAuthor] + ) const handleAuthorChange = useCallback( (author: string | null) => { setSelectedAuthor(author) - // Remember the chosen author so it surfaces in the dropdown next time. - if (author && folder?.path) { - const path = folder.path - setRecentAuthors((prev) => { - const next = addRecentAuthor(prev, author) - saveRecentAuthors(path, next) - return next - }) + if (folder?.path) { + // Persist the pick (paired with the current branch) so it restores the + // next time this folder is opened. + saveSelection(folder.path, { branch: selectedBranch, author }) + // Remember the chosen author so it surfaces in the dropdown next time. + if (author) { + const path = folder.path + setRecentAuthors((prev) => { + const next = addRecentAuthor(prev, author) + saveRecentAuthors(path, next) + return next + }) + } } }, - [folder?.path] + [folder?.path, selectedBranch] ) const handleRemoveRecent = useCallback( @@ -1439,16 +1512,34 @@ export function GitLogTab() { }, [folder?.path]) const refreshBranches = useCallback(async () => { - if (!folder?.path) return + const path = folder?.path + if (!path) return try { const [allBranches, current] = await Promise.all([ - gitListAllBranches(folder.path), - getGitBranch(folder.path), + gitListAllBranches(path), + getGitBranch(path), ]) + // Ignore a response that resolved after a folder switch. + if (folderPathRef.current !== path) return setBranchList(allBranches) setCurrentBranch(current) - // Do NOT touch selectedBranch: the default view is "all branches" (null) - // and any explicit selection is owned by the branch selector. + // A restored/selected branch may no longer exist (deleted since it was + // last used). Once we have this repo's authoritative branch list, drop it + // back to the all-branches view and forget it so we don't keep restoring a + // dead branch (which would make the first fetch error). Read via refs so + // this callback stays keyed only on the folder path — else it'd recreate, + // and re-run its effect, on every filter change. Otherwise leave + // selectedBranch alone: the default is "all branches" (null) and an + // explicit pick is owned by the branch selector / restored per folder. + const sel = selectedBranchRef.current + if ( + sel && + !allBranches.local.includes(sel) && + !allBranches.remote.includes(sel) + ) { + setSelectedBranch(null) + saveSelection(path, { branch: null, author: selectedAuthorRef.current }) + } } catch { // Silently ignore — branches dropdown won't appear } diff --git a/src/components/layout/branch-selector-list.tsx b/src/components/layout/branch-selector-list.tsx index adc9ce172..a84938fb7 100644 --- a/src/components/layout/branch-selector-list.tsx +++ b/src/components/layout/branch-selector-list.tsx @@ -58,8 +58,10 @@ interface BranchSelectorListProps { ) => void } -// Coarse per-row viewport estimate — only sizes the scroll window; virtua -// measures real rows itself. +// Coarse per-row estimate — only seeds the scroll window's height on the first +// paint, before the real content height is measured (see `measuredHeight`). A +// too-small estimate would otherwise leave a spurious scrollbar on a narrowed +// list, a too-big one dead space; virtua measures real rows itself either way. const ROW_ESTIMATE_PX = 34 const MAX_LIST_HEIGHT_PX = 480 // Approximate width of the right-side action bubble (`w-56`) — used only to @@ -150,9 +152,13 @@ export function BranchSelectorList({ const [activeIndex, setActiveIndex] = useState(0) const [bubble, setBubble] = useState(null) const [bubbleActiveIndex, setBubbleActiveIndex] = useState(0) + // Real rendered height of the row list, measured once the virtua viewport + // exists (null until then → the coarse estimate seeds the first paint). + const [measuredHeight, setMeasuredHeight] = useState(null) const rootRef = useRef(null) const inputRef = useRef(null) + const listboxRef = useRef(null) const virtualizerRef = useRef(null) const viewportRef = useRef(null) const [viewportEl, setViewportEl] = useState(null) @@ -217,6 +223,30 @@ export function BranchSelectorList({ return () => document.removeEventListener("keydown", onKeyDown) }, [closeBubble]) + // Track the list's true rendered height so the scroll window is exactly as + // tall as its rows (capped at the max), instead of trusting a per-row + // estimate that under-/over-shoots. Attaching only after the virtua viewport + // exists means the row content is already mounted, so we skip the padding-only + // pre-mount state (which would briefly collapse the popup). The observer then + // keeps firing as filtering grows/shrinks the list. + useEffect(() => { + const el = listboxRef.current + if (!viewportEl || !el) return + const observer = new ResizeObserver((entries) => { + const box = entries[0]?.borderBoxSize?.[0] + // Round up so the window is never a sub-pixel shorter than its content — + // that fractional gap is exactly what makes the stray scrollbar appear. + const next = Math.ceil( + box ? box.blockSize : el.getBoundingClientRect().height + ) + setMeasuredHeight((prev) => + prev != null && Math.abs(prev - next) < 1 ? prev : next + ) + }) + observer.observe(el) + return () => observer.disconnect() + }, [viewportEl]) + // Prefix groups start folded: seed the default-collapsed set with every group // key in the current trees (sections + multi-remote wrappers are keyed outside // the tree, so they stay open). @@ -470,9 +500,11 @@ export function BranchSelectorList({ } } + // Prefer the real measured content height; fall back to the coarse per-row + // estimate only until the first measurement lands. const listHeight = Math.min( MAX_LIST_HEIGHT_PX, - Math.max(rows.length, 1) * ROW_ESTIMATE_PX + measuredHeight ?? Math.max(rows.length, 1) * ROW_ESTIMATE_PX ) const activeFlatIndex = navigableRowIndices[activeIndexClamped] const showSpinner = branchLoading && localCount === 0 && remoteCount === 0 @@ -656,6 +688,7 @@ export function BranchSelectorList({
({ setSearchOpen: vi.fn(), setRoute: vi.fn(), openConversations: vi.fn(), + // Latest props the (stubbed) conversation list was rendered with, so tests can + // assert what the sidebar threads down (e.g. showWorktrees / showCompleted). + listProps: null as { + showWorktrees?: boolean + showCompleted?: boolean + } | null, })) const mockState = vi.hoisted(() => ({ activeFolder: { id: 7, path: "/x" } as { id: number; path: string } | null, @@ -21,7 +28,13 @@ const mockState = vi.hoisted(() => ({ // The conversation list is irrelevant here — stub it so the test exercises only // the sidebar's header + fixed New chat / Search region. vi.mock("@/components/conversations/sidebar-conversation-list", () => ({ - SidebarConversationList: () => null, + SidebarConversationList: (props: { + showWorktrees?: boolean + showCompleted?: boolean + }) => { + spies.listProps = props + return null + }, })) vi.mock("@/contexts/sidebar-context", () => ({ useSidebarContext: () => ({ isOpen: true, toggle: vi.fn() }), @@ -126,3 +139,60 @@ describe("Sidebar — fixed New chat / Search region", () => { expect(spies.openNewConversationTab).not.toHaveBeenCalled() }) }) + +describe("Sidebar — Show worktree folders toggle", () => { + beforeEach(() => { + localStorage.clear() + spies.listProps = null + mockState.activeFolder = { id: 7, path: "/x" } + }) + + it("defaults Show worktree folders on and threads it to the conversation list", () => { + renderSidebar() + expect(spies.listProps?.showWorktrees).toBe(true) + }) + + it("respects an explicitly-stored 'false' from localStorage", () => { + localStorage.setItem("workspace:sidebar-show-worktrees", "false") + renderSidebar() + // Hydration runs in a mount effect (flushed by render's act): a user who + // unchecked it keeps it off despite the default-on. + expect(spies.listProps?.showWorktrees).toBe(false) + }) + + it("toggling the funnel item off persists the choice and threads it down", async () => { + const user = userEvent.setup() + renderSidebar() + // Default on with a cleared store. + expect(spies.listProps?.showWorktrees).toBe(true) + + await user.click(screen.getByRole("button", { name: "View options" })) + await user.click( + screen.getByRole("menuitemcheckbox", { name: "Show worktree folders" }) + ) + + expect(localStorage.getItem("workspace:sidebar-show-worktrees")).toBe( + "false" + ) + expect(spies.listProps?.showWorktrees).toBe(false) + }) +}) + +describe("Sidebar — Show completed default", () => { + beforeEach(() => { + localStorage.clear() + spies.listProps = null + mockState.activeFolder = { id: 7, path: "/x" } + }) + + it("defaults Show completed on and threads it to the conversation list", () => { + renderSidebar() + expect(spies.listProps?.showCompleted).toBe(true) + }) + + it("respects an explicitly-stored 'false' from localStorage", () => { + localStorage.setItem("workspace:sidebar-show-completed", "false") + renderSidebar() + expect(spies.listProps?.showCompleted).toBe(false) + }) +}) diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index af240c975..52aa8edff 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -44,9 +44,11 @@ import { isDesktop } from "@/lib/platform" import { leftChromeReserve } from "@/lib/window-chrome" import { loadShowCompleted, + loadShowWorktrees, loadSortMode, loadSectionOrder, saveShowCompleted, + saveShowWorktrees, saveSortMode, saveSectionOrder, type SidebarSortMode, @@ -130,7 +132,11 @@ export function Sidebar() { // rem-sized overlay buttons. Mobile has no overlay (the sidebar is a Sheet). const leftReserve = leftChromeReserve(platformIsMac && isDesktop(), zoomLevel) - const [showCompleted, setShowCompleted] = useState(false) + // Both default ON (the mount effect below reconciles a persisted "false"). + // The initial value matches that default so the pre-hydration render doesn't + // flash from off → on. + const [showCompleted, setShowCompleted] = useState(true) + const [showWorktrees, setShowWorktrees] = useState(true) const [sortMode, setSortMode] = useState("created") const [sectionOrder, setSectionOrder] = useState("folders-first") @@ -155,6 +161,7 @@ export function Sidebar() { // Hydrate from localStorage after mount to keep SSR/CSR markup consistent. // eslint-disable-next-line react-hooks/set-state-in-effect setShowCompleted(loadShowCompleted()) + setShowWorktrees(loadShowWorktrees()) setSortMode(loadSortMode()) setSectionOrder(loadSectionOrder()) }, []) @@ -164,6 +171,11 @@ export function Sidebar() { saveShowCompleted(value) }, []) + const handleSetShowWorktrees = useCallback((value: boolean) => { + setShowWorktrees(value) + saveShowWorktrees(value) + }, []) + const handleSetSortMode = useCallback((value: string) => { const mode: SidebarSortMode = value === "updated" ? "updated" : "created" setSortMode(mode) @@ -324,6 +336,12 @@ export function Sidebar() { > {t("showCompleted")} + + {t("showWorktrees")} + {t("sortBy")} diff --git a/src/components/layout/status-bar.tsx b/src/components/layout/status-bar.tsx index 65956c6f5..4b278c60c 100644 --- a/src/components/layout/status-bar.tsx +++ b/src/components/layout/status-bar.tsx @@ -11,12 +11,15 @@ export function StatusBar() { const isMobile = useIsMobile() if (isMobile) { - // Mobile mirrors the desktop bar's right side: the command launcher + - // alerts. `h-8` (matching desktop) gives the h-6 command control room. The - // branch selector and context-window circle now live in the below-composer - // row, so the bar has nothing on the left and right-aligns its controls. + // Mobile mirrors the desktop bar: workspace stats on the left, the command + // launcher + alerts on the right. `h-8` (matching desktop) gives the h-6 + // command control room. The branch selector and context-window circle live + // in the below-composer row, so those are the bar's only two clusters. return ( -
+
+
+ +
diff --git a/src/components/message/reply-artifacts.test.tsx b/src/components/message/reply-artifacts.test.tsx new file mode 100644 index 000000000..0c3ff8b7c --- /dev/null +++ b/src/components/message/reply-artifacts.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { ReplyArtifacts } from "./reply-artifacts" +import type { FileChangeStat } from "@/lib/session-files" +import type { MessageTurn } from "@/lib/types" + +// Stable `t` (per next-intl mock guidance) returns the key verbatim — enough to +// address every label here (section headers, the per-file `viewDiff` / +// `revealInFolder` actions, the `noDiffDataAvailable` fallback). +const { stableT, mockOpenDiff, mockOpenFilePreview, mockReveal, mockExtract } = + vi.hoisted(() => ({ + stableT: (key: string) => key, + mockOpenDiff: vi.fn(), + mockOpenFilePreview: vi.fn(), + mockReveal: vi.fn(), + mockExtract: vi.fn(), + })) + +vi.mock("next-intl", () => ({ useTranslations: () => stableT })) +vi.mock("@/contexts/workspace-context", () => ({ + useWorkspaceActions: () => ({ + openFilePreview: mockOpenFilePreview, + openSessionFileDiff: mockOpenDiff, + }), +})) +vi.mock("@/contexts/active-folder-context", () => ({ + useActiveFolder: () => ({ activeFolder: { path: "/repo" } }), +})) +vi.mock("@/lib/platform", () => ({ + isLocalDesktop: () => true, + revealItemInDir: mockReveal, +})) +// Drive the card's file list directly — the extractor itself is covered by +// session-files' own tests; here we only wire the parsed shape into the UI. +vi.mock("@/lib/session-files", () => ({ + extractReplyFileChanges: (turns: unknown) => mockExtract(turns), +})) + +const MODIFIED_DIFF = + "diff --git a/src/a.ts b/src/a.ts\n@@ -1,2 +1,2 @@\n-old\n+new" +const DELETION_DIFF = "*** Delete File: src/gone.ts\n-a\n-b" + +// Only `sourceTurns[0].id` is read (it keys the diff tab); the file list comes +// from the mocked extractor, so a bare id is all this fixture needs. +const sourceTurns = [{ id: "reply-turn-1" }] as unknown as MessageTurn[] + +function renderCard(files: FileChangeStat[]) { + mockExtract.mockReturnValue(files) + return render() +} + +// The "Files changed" section is collapsed by default — expand it so the +// per-file action buttons mount. +function expandChanged() { + fireEvent.click(screen.getByText("title")) +} + +describe("ReplyArtifacts — view diff action", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("opens the file's diff in the editor, keyed by the reply turn", () => { + renderCard([ + { + id: "f1", + path: "src/a.ts", + additions: 1, + deletions: 1, + diff: MODIFIED_DIFF, + }, + ]) + expandChanged() + + fireEvent.click(screen.getByRole("button", { name: "viewDiff" })) + + expect(mockOpenDiff).toHaveBeenCalledWith( + "src/a.ts", + MODIFIED_DIFF, + "reply-turn-1" + ) + }) + + it("falls back to the placeholder when the file has no diff data", () => { + renderCard([ + { id: "f2", path: "src/b.ts", additions: 0, deletions: 0, diff: null }, + ]) + expandChanged() + + fireEvent.click(screen.getByRole("button", { name: "viewDiff" })) + + expect(mockOpenDiff).toHaveBeenCalledWith( + "src/b.ts", + "noDiffDataAvailable", + "reply-turn-1" + ) + }) + + it("places View Diff to the left of Show-in-file-manager", () => { + renderCard([ + { + id: "f1", + path: "src/a.ts", + additions: 1, + deletions: 1, + diff: MODIFIED_DIFF, + }, + ]) + expandChanged() + + const viewDiffBtn = screen.getByRole("button", { name: "viewDiff" }) + const revealBtn = screen.getByRole("button", { name: "revealInFolder" }) + // View Diff precedes the reveal button in document order. + expect( + viewDiffBtn.compareDocumentPosition(revealBtn) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) + + it("does not offer View Diff for a removed file (nothing to open)", () => { + renderCard([ + { + id: "f3", + path: "src/gone.ts", + additions: 0, + deletions: 2, + diff: DELETION_DIFF, + }, + ]) + expandChanged() + + expect( + screen.queryByRole("button", { name: "viewDiff" }) + ).not.toBeInTheDocument() + // The removed file still renders its static destructive badge. + expect(screen.getByText("remove")).toBeInTheDocument() + }) +}) diff --git a/src/components/message/reply-artifacts.tsx b/src/components/message/reply-artifacts.tsx index 3eb34483a..d687b44d6 100644 --- a/src/components/message/reply-artifacts.tsx +++ b/src/components/message/reply-artifacts.tsx @@ -66,8 +66,9 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({ isResponseComplete: boolean }) { const t = useTranslations("Folder.chat.replyArtifacts") + const tCommon = useTranslations("Folder.common") const { activeFolder: folder } = useActiveFolder() - const { openFilePreview } = useWorkspaceActions() + const { openFilePreview, openSessionFileDiff } = useWorkspaceActions() const [newFilesOpen, setNewFilesOpen] = useState(true) const [changedOpen, setChangedOpen] = useState(false) @@ -110,6 +111,19 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({ if (absolute) void revealItemInDir(absolute) } + // Open the file's unified diff in an editor tab. Keyed by the reply's first + // turn id so the same file changed by two different replies opens as two + // distinct diff tabs instead of colliding into one. Works in web too (unlike + // reveal), so it stays ungated by `isLocalDesktop()`. + const replyDiffKey = sourceTurns[0]?.id ?? "reply" + const viewDiff = (file: FileChangeStat) => { + openSessionFileDiff( + file.path, + file.diff ?? t("noDiffDataAvailable", { filePath: file.path }), + replyDiffKey + ) + } + const totalAdditions = changedFiles.reduce((sum, f) => sum + f.additions, 0) const totalDeletions = changedFiles.reduce((sum, f) => sum + f.deletions, 0) @@ -347,6 +361,22 @@ export const ReplyArtifacts = memo(function ReplyArtifacts({ + + + + + + {tCommon("viewDiff")} + + + {isLocalDesktop() && ( diff --git a/src/contexts/conversation-runtime-context.test.tsx b/src/contexts/conversation-runtime-context.test.tsx index 930a2b138..2b273feb0 100644 --- a/src/contexts/conversation-runtime-context.test.tsx +++ b/src/contexts/conversation-runtime-context.test.tsx @@ -1737,6 +1737,42 @@ describe("ConversationRuntimeProvider viewer user-turn synthesis", () => { }) }) +describe("buildStreamingTurnsFromLiveMessage — orphan status provenance", () => { + // The forward that lets the render layer drop an interrupted arg-less orphan + // after it is promoted into localTurns at COMPLETE_TURN (see + // dropEmptyInFlightToolCalls). Without it, the promoted block loses its ACP + // status and the orphan re-inflates the "运行 N 个命令" count post-completion. + it("forwards the ACP tool status onto the promoted tool_use block", () => { + const blocks = buildStreamingTurnsFromLiveMessage(1, { + id: "lm-orphan", + role: "assistant", + startedAt: 0, + content: [ + { + type: "tool_call", + info: { + tool_call_id: "tc-orphan", + title: "bash", + kind: "execute", + status: "pending", + content: null, + raw_input: "{}", + raw_output_chunks: [], + raw_output_total_bytes: 0, + locations: null, + meta: null, + images: [], + }, + }, + ], + }).turns.flatMap((t) => t.blocks) + const toolUse = blocks.find((b) => b.type === "tool_use") + expect(toolUse?.type === "tool_use" ? toolUse.status : "MISSING").toBe( + "pending" + ) + }) +}) + describe("buildStreamingTurnsFromLiveMessage — Kimi TodoList suppression", () => { function kimiToolCall( rawInput: string | null, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index fb07fc83f..414dac485 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "بحث عن محادثات...", "viewOptions": "خيارات العرض", "showCompleted": "عرض المحادثات المكتملة", + "showWorktrees": "عرض مجلدات worktree", "moreOptions": "المزيد من الخيارات", "sortBy": "الترتيب حسب", "sortByCreatedAt": "وقت الإنشاء", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d83bc5146..ad467e1f6 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "Konversationen suchen...", "viewOptions": "Anzeigeoptionen", "showCompleted": "Abgeschlossene Konversationen anzeigen", + "showWorktrees": "Worktree-Ordner anzeigen", "moreOptions": "Weitere Optionen", "sortBy": "Sortieren nach", "sortByCreatedAt": "Erstellungszeit", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d608b9b8f..bdfd937f5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "Search conversations...", "viewOptions": "View options", "showCompleted": "Show completed conversations", + "showWorktrees": "Show worktree folders", "moreOptions": "More options", "sortBy": "Sort by", "sortByCreatedAt": "Created time", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index bfdf66ea1..26f871e11 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "Buscar conversaciones...", "viewOptions": "Opciones de visualización", "showCompleted": "Mostrar conversaciones completadas", + "showWorktrees": "Mostrar carpetas de worktree", "moreOptions": "Más opciones", "sortBy": "Ordenar por", "sortByCreatedAt": "Fecha de creación", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 224a37655..8b615adab 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "Rechercher des conversations...", "viewOptions": "Options d'affichage", "showCompleted": "Afficher les conversations terminées", + "showWorktrees": "Afficher les dossiers de worktree", "moreOptions": "Plus d'options", "sortBy": "Trier par", "sortByCreatedAt": "Date de création", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 904a170ae..a4ff328ae 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "会話を検索...", "viewOptions": "表示オプション", "showCompleted": "完了した会話を表示", + "showWorktrees": "ワークツリーフォルダを表示", "moreOptions": "その他のオプション", "sortBy": "並び替え", "sortByCreatedAt": "作成時刻順", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index e99f82e41..b99b77753 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "대화 검색...", "viewOptions": "보기 옵션", "showCompleted": "완료된 대화 표시", + "showWorktrees": "워크트리 폴더 표시", "moreOptions": "더 많은 옵션", "sortBy": "정렬 기준", "sortByCreatedAt": "생성 시간순", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 7bf84ab65..572880228 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "Buscar conversas...", "viewOptions": "Opções de exibição", "showCompleted": "Mostrar conversas concluídas", + "showWorktrees": "Mostrar pastas de worktree", "moreOptions": "Mais opções", "sortBy": "Ordenar por", "sortByCreatedAt": "Data de criação", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 39017ea22..f96243f57 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "搜索会话...", "viewOptions": "显示选项", "showCompleted": "显示已完成会话", + "showWorktrees": "显示工作树文件夹", "moreOptions": "更多选项", "sortBy": "排序方式", "sortByCreatedAt": "按创建时间排序", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5d259b834..16b45e45e 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1318,6 +1318,7 @@ "searchPlaceholder": "搜尋對話...", "viewOptions": "顯示選項", "showCompleted": "顯示已完成對話", + "showWorktrees": "顯示工作樹資料夾", "moreOptions": "更多選項", "sortBy": "排序方式", "sortByCreatedAt": "按建立時間排序", diff --git a/src/lib/adapters/ai-elements-adapter.test.ts b/src/lib/adapters/ai-elements-adapter.test.ts index fb4cd4886..1371f87bf 100644 --- a/src/lib/adapters/ai-elements-adapter.test.ts +++ b/src/lib/adapters/ai-elements-adapter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest" import { adaptMessageTurn, createMessageTurnAdapter, + dropEmptyInFlightToolCalls, dropHiddenFeedbackChecks, extractUserResourcesFromText, groupConsecutiveDelegationStatus, @@ -237,6 +238,183 @@ describe("dropHiddenFeedbackChecks", () => { }) }) +describe("dropEmptyInFlightToolCalls", () => { + // A generic, still-running tool call (arg-less by default) — the shape + // claude-agent-acp emits at content_block_start before the args arrive. + function running( + toolName: string, + extra: Partial = {} + ): AdaptedToolCallPart { + return { + type: "tool-call", + toolCallId: `${toolName}:live`, + toolName, + input: "{}", + state: "input-available", + ...extra, + } + } + + it("drops empty, still-running generic tool calls in every empty shape", () => { + expect( + dropEmptyInFlightToolCalls([ + running("bash", { input: "{}" }), + running("bash", { input: "" }), + running("bash", { input: null }), + running("bash", { input: " " }), + ]) + ).toHaveLength(0) + }) + + it("keeps an in-flight call that already carries a real command", () => { + const live = running("bash", { + input: JSON.stringify({ command: "pnpm build" }), + }) + expect(dropEmptyInFlightToolCalls([live])).toEqual([live]) + }) + + it("keeps an empty in-flight call that is already streaming output", () => { + const live = running("bash", { input: "{}", output: "...building..." }) + expect(dropEmptyInFlightToolCalls([live])).toEqual([live]) + }) + + it("keeps an in-flight call that surfaced an error", () => { + const live = running("bash", { input: "{}", errorText: "boom" }) + expect(dropEmptyInFlightToolCalls([live])).toEqual([live]) + }) + + it("keeps DB-history parts that carry no forwarded status", () => { + // Persisted rows have no `toolStatus` (undefined) → treated as settled, so + // an arg-less-but-completed historical tool is never mistaken for an orphan. + const hist = running("bash", { input: "{}", state: "output-available" }) + expect(dropEmptyInFlightToolCalls([hist])).toEqual([hist]) + }) + + it("drops a promoted orphan: state settled to output-available but status unsettled", () => { + // The COMPLETE_TURN promotion path: the same unpruned orphan is re-adapted + // with isStreaming=false, so its state flips to output-available while the + // forwarded ACP status stays pending/in_progress. + expect( + dropEmptyInFlightToolCalls([ + running("bash", { + input: "{}", + state: "output-available", + toolStatus: "pending", + }), + running("bash", { + input: "{}", + state: "output-available", + toolStatus: "in_progress", + }), + ]) + ).toHaveLength(0) + }) + + it("keeps a promoted part once its status settles (completed/failed)", () => { + const done = running("bash", { + input: "{}", + state: "output-available", + toolStatus: "completed", + }) + expect(dropEmptyInFlightToolCalls([done])).toEqual([done]) + }) + + it("keeps a promoted orphan that already streamed output", () => { + const withOutput = running("bash", { + input: "{}", + state: "output-available", + toolStatus: "in_progress", + output: "...partial...", + }) + expect(dropEmptyInFlightToolCalls([withOutput])).toEqual([withOutput]) + }) + + it("never touches specialized lanes (agent/delegation/ask/background/plan)", () => { + // Empty + in-flight, but each renders through its own card and handles its + // own empty polls (see commit 1ddf751b) — this filter must leave them be. + const lanes: AdaptedContentPart[] = [ + running("get_delegation_status"), + running("question"), + running("TaskOutput"), + running("switch_mode"), + ] + expect(dropEmptyInFlightToolCalls(lanes)).toEqual(lanes) + }) + + it("collapses the phantom scenario to a single one-command group", () => { + // Live turn: one real completed build + two orphaned arg-less bash blocks + // left by an interrupted/retried attempt. Settled transcript has only the + // real one, so the live count must converge to it. + const real: AdaptedToolCallPart = { + type: "tool-call", + toolCallId: "bash:real", + toolName: "bash", + input: JSON.stringify({ command: "pnpm build 2>&1 | tail -20" }), + state: "output-available", + output: "Build succeeded", + } + const grouped = groupConsecutiveToolCalls( + dropEmptyInFlightToolCalls([ + real, + running("bash", { input: "{}" }), + running("bash", { input: "{}" }), + ]) + ) + expect(grouped.map((p) => p.type)).toEqual(["tool-group"]) + const group = grouped[0] + if (group.type !== "tool-group") throw new Error("expected a tool-group") + expect(group.items).toHaveLength(1) + expect(group.items[0].toolCallId).toBe("bash:real") + }) + + it("prunes a promoted orphan end-to-end via adaptMessageTurn (isStreaming=false)", () => { + // Shape of a localTurn after COMPLETE_TURN: one real completed bash (with a + // matching result) plus one interrupted arg-less orphan — status still + // "pending", no result block. Adapted with isStreaming=false (promoted), + // the orphan's state becomes output-available; only its forwarded status + // reveals it. The group must still converge to the single real command. + const adapted = adaptMessageTurn( + { + id: "promoted-turn", + role: "assistant", + timestamp: "2026-07-23T00:00:00.000Z", + blocks: [ + { + type: "tool_use", + tool_use_id: "tc-real", + tool_name: "bash", + input_preview: JSON.stringify({ command: "pnpm build" }), + status: "completed", + }, + { + type: "tool_result", + tool_use_id: "tc-real", + output_preview: "Build succeeded", + is_error: false, + }, + { + type: "tool_use", + tool_use_id: "tc-orphan", + tool_name: "bash", + input_preview: "{}", + status: "pending", + }, + ], + }, + { + attachedResources: "Attached resources", + toolCallFailed: "Tool failed", + }, + false + ) + expect(adapted.content.map((p) => p.type)).toEqual(["tool-group"]) + const group = adapted.content[0] + if (group.type !== "tool-group") throw new Error("expected a tool-group") + expect(group.items).toHaveLength(1) + expect(group.items[0].toolCallId).toBe("tc-real") + }) +}) + describe("groupGoalRuns", () => { it("wraps create_goal through update_goal with intervening process parts", () => { const grouped = groupConsecutiveToolCalls([ diff --git a/src/lib/adapters/ai-elements-adapter.ts b/src/lib/adapters/ai-elements-adapter.ts index b0fba74b6..9f1abbd7f 100644 --- a/src/lib/adapters/ai-elements-adapter.ts +++ b/src/lib/adapters/ai-elements-adapter.ts @@ -14,6 +14,7 @@ import { } from "@/lib/adapters/tool-kind-classifier" import { normalizeToolName } from "@/lib/tool-call-normalization" import { isBackgroundTaskToolCall } from "@/lib/background-task" +import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" import { feedbackCheckHasContent } from "@/lib/feedback-check" import { isPlanLikeToolName, @@ -44,6 +45,17 @@ export type AdaptedToolCallPart = { output?: string | null errorText?: string agentStats?: AgentExecutionStats | null + /** + * Forwarded ACP tool-call status for live/promoted turns (`ContentBlock. + * tool_use.status`); absent/`null` for DB-persisted rows (the Rust `ToolUse` + * model has no status field). Consumed via `isUnsettledToolCall` by both the + * generic tool-group filter (`dropEmptyInFlightToolCalls`) and the specialized + * lane row-builders (`buildDelegationTaskRows` / `buildBackgroundTaskRows`) to + * recognise an interrupted arg-less orphan that survives `COMPLETE_TURN` + * promotion (its `state` flips to `output-available`, but its status stays + * unsettled). + */ + toolStatus?: string | null /** * ACP extensibility metadata forwarded from `ContentBlock.tool_use.meta`. * Opaque pass-through; the only consumer today is `` @@ -1147,6 +1159,89 @@ export function dropHiddenFeedbackChecks( }) } +/** + * Whether a tool-call's `input` string carries any real argument. Treats the + * empty shapes an arg-less initial `tool_call` serializes to — `null`, `""`, + * `"{}"`, `"[]"`, `"null"`, and any JSON that parses to an empty object/array — + * as "no input". Non-JSON but non-empty text counts as input. + */ +function toolCallHasInput(input: string | null | undefined): boolean { + if (input == null) return false + const trimmed = input.trim() + if ( + trimmed === "" || + trimmed === "{}" || + trimmed === "[]" || + trimmed === "null" + ) { + return false + } + try { + const parsed: unknown = JSON.parse(trimmed) + if (parsed == null) return false + if (Array.isArray(parsed)) return parsed.length > 0 + if (typeof parsed === "object") return Object.keys(parsed).length > 0 + } catch { + // Non-JSON but non-empty text → treat as real input. + } + return true +} + +/** + * Drop empty, unsettled generic tool-call parts. claude-agent-acp emits an + * arg-less initial `tool_call` at `content_block_start` (`rawInput = {}`) and + * fills the real args on a later same-id `tool_call_update`. When a turn is + * interrupted and retried (connection error / Claude API retry), the aborted + * attempt's arg-less `tool_call` — which carries its own id, never gets refined, + * and is never written to the JSONL transcript — lingers in `liveMessage`. It + * then inflates the "运行 N 个命令" tool-group count and renders as a blank + * `bash · 运行中` card. The settled view (rebuilt from the transcript) never + * contains it, hence the live-only mismatch. + * + * Two render passes see the orphan, so the predicate spans both: (1) during + * streaming its state is `input-available` (running); (2) after `COMPLETE_TURN` + * the same unpruned `liveMessage` is promoted into `localTurns` and re-adapted + * with `isStreaming=false`, flipping the unmatched orphan to `output-available` + * — still caught, because its forwarded ACP status stays unsettled until an + * authoritative detail reload replaces the promoted copy. DB-persisted history + * carries no forwarded status, so it is exempt. + * + * The lane guard mirrors `groupConsecutiveToolCalls`'s fold condition exactly, + * so this only ever removes parts that would fold into a generic tool-group. + * Every specialized lane (agent/delegation/ask/feedback/goal via + * `isAgentLikeToolName`, plan-mode, background-task) is left untouched — those + * render through their own cards and handle their own empty in-flight polls + * (see commit 1ddf751b, same disease in the other lanes). Runs before + * `groupConsecutiveToolCalls`. + */ +export function dropEmptyInFlightToolCalls( + parts: AdaptedContentPart[] +): AdaptedContentPart[] { + return parts.filter((part) => { + if (part.type !== "tool-call") return true + // Specialized lanes render standalone — never our concern. + if ( + isAgentLikeToolName(part.toolName) || + isPlanModeToolName(part.toolName) || + isBackgroundTaskToolCall(part) + ) { + return true + } + // Keep unless the part is unsettled — still running (live orphan) or carrying + // an unsettled forwarded status (a promoted orphan whose state flipped to + // output-available at COMPLETE_TURN). DB-persisted rows have no forwarded + // status → settled → always kept here. See `isUnsettledToolCall`. + if (!isUnsettledToolCall(part)) { + return true + } + if (part.state === "output-error" || part.errorText?.trim()) return true + if (part.output && part.output.trim().length > 0) return true // streaming output → keep + if (toolCallHasInput(part.input)) return true // has a real command/args → keep + // Empty args + unsettled + no output/error → orphaned arg-less initial call. + return false + }) +} + /** * Wrap each run of consecutive `get_delegation_status` poll parts into a single * `delegation-status-group` part. Runs after `groupConsecutiveToolCalls`, which @@ -1695,6 +1790,10 @@ export function adaptMessageTurn( toolName: block.tool_name, input: block.input_preview, state: isStreaming ? "input-available" : "output-available", + // Forward status so a promoted arg-less orphan (unmatched, no + // result) can be recognised after COMPLETE_TURN flips its state to + // output-available. See dropEmptyInFlightToolCalls. + toolStatus: block.status ?? null, meta: block.meta ?? null, }) } @@ -1744,7 +1843,9 @@ export function adaptMessageTurn( groupConsecutiveBackgroundTasks( groupConsecutiveDelegationStatus( groupConsecutiveToolCalls( - dropHiddenFeedbackChecks(adaptedContent) + dropEmptyInFlightToolCalls( + dropHiddenFeedbackChecks(adaptedContent) + ) ) ) ), diff --git a/src/lib/background-task.test.ts b/src/lib/background-task.test.ts index 90978775c..78501d627 100644 --- a/src/lib/background-task.test.ts +++ b/src/lib/background-task.test.ts @@ -292,6 +292,28 @@ describe("buildBackgroundTaskRows", () => { expect(rows[0].taskId).toBe("bfb5xnq1t") }) + it("drops a promoted orphan (output-available but status still unsettled)", () => { + // COMPLETE_TURN promotes the unpruned arg-less TaskOutput orphan: its state + // flips to output-available, but its forwarded status stays pending. It must + // not re-stack as an anonymous "background task running" row post-turn. + const rows = buildBackgroundTaskRows([ + poll({ + toolCallId: "done", + output: COMPLETED, + state: "output-available", + }), + poll({ + toolCallId: "orphan", + input: "{}", + output: null, + state: "output-available", + toolStatus: "pending", + }), + ]) + expect(rows).toHaveLength(1) + expect(rows[0].taskId).toBe("bfb5xnq1t") + }) + it("still shows an id-less poll once it carries an envelope", () => { // A settled/output-available poll that parsed an envelope (even without a // resolvable id) is real content and keeps its row. diff --git a/src/lib/background-task.ts b/src/lib/background-task.ts index 6017db57c..aa81251ff 100644 --- a/src/lib/background-task.ts +++ b/src/lib/background-task.ts @@ -24,6 +24,7 @@ * parsing lives here (same convention as `delegation-status.ts`). */ +import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" import type { AdaptedToolCallPart } from "@/lib/adapters/ai-elements-adapter" export interface BackgroundTaskEnvelope { @@ -275,7 +276,7 @@ export function buildBackgroundTaskRows( poll.output ?? poll.errorText ?? null ) const taskId = envelope?.taskId ?? inputTaskId(poll.input) ?? null - // Drop an in-flight poll that carries no identity AND no output yet — a live + // Drop an unsettled poll that carries no identity AND no output yet — a live // `TaskOutput` whose `task_id` hasn't streamed onto the wire. claude-agent-acp // emits an arg-less initial `tool_call` (rawInput `{}`) and fills the real // args only on a later update, so a still-blocking poll parses to no envelope @@ -283,8 +284,11 @@ export function buildBackgroundTaskRows( // anonymous "background task running" row, stacking identical duplicates of // the same wait; it folds into its task's row once the id resolves (and the // settled transcript, whose polls always carry the id, is unaffected). + // `isUnsettledToolCall` (not a bare live-state check) also covers an orphan + // promoted into `localTurns` at COMPLETE_TURN — output-available yet never + // settled — which would otherwise re-stack after the turn completes. // Mirrors `buildDelegationTaskRows`. - if (taskId == null && envelope == null && isInFlightState(poll)) { + if (taskId == null && envelope == null && isUnsettledToolCall(poll)) { continue } const key = taskId ?? `__bg__:${poll.toolCallId}` diff --git a/src/lib/delegation-status.test.ts b/src/lib/delegation-status.test.ts index b0f8bfcb2..a86b1b7bb 100644 --- a/src/lib/delegation-status.test.ts +++ b/src/lib/delegation-status.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { + buildDelegationTaskRows, deriveBadge, formatDuration, parseStatusReport, @@ -8,6 +9,7 @@ import { parseTaskId, parseTaskIds, } from "./delegation-status" +import type { AdaptedToolCallPart } from "@/lib/adapters/ai-elements-adapter" // Mirrors the MCP CallToolResult envelope the companion emits. function envelope(report: Record, isError = false): string { @@ -412,3 +414,106 @@ describe("formatDuration", () => { expect(formatDuration(119_999)).toBe("2m 0s") }) }) + +describe("buildDelegationTaskRows", () => { + function statusPoll( + over: Partial = {} + ): AdaptedToolCallPart { + return { + type: "tool-call", + toolCallId: "sp", + toolName: "get_delegation_status", + input: null, + state: "output-available", + ...over, + } + } + + const doneReport = envelope({ + status: "completed", + task_id: "t1", + text: "ok", + duration_ms: 1000, + }) + + it("collapses repeated polls of one task into a single row", () => { + const rows = buildDelegationTaskRows([ + statusPoll({ + toolCallId: "p1", + input: JSON.stringify({ task_ids: ["t1"] }), + output: doneReport, + }), + statusPoll({ + toolCallId: "p2", + input: JSON.stringify({ task_ids: ["t1"] }), + output: doneReport, + }), + ]) + expect(rows).toHaveLength(1) + expect(rows[0].taskId).toBe("t1") + }) + + it("drops a live arg-less in-flight orphan poll (no id, nothing to show)", () => { + const rows = buildDelegationTaskRows([ + statusPoll({ input: "{}", output: null, state: "input-available" }), + ]) + expect(rows).toHaveLength(0) + }) + + it("drops a promoted orphan (output-available but status still unsettled)", () => { + // COMPLETE_TURN promotes the unpruned orphan: its state flips to + // output-available, but its forwarded ACP status stays pending. It must not + // re-stack as an anonymous row after the turn completes. + const rows = buildDelegationTaskRows([ + statusPoll({ + input: "{}", + output: null, + state: "output-available", + toolStatus: "pending", + }), + ]) + expect(rows).toHaveLength(0) + }) + + it("keeps a settled id-less interim note (a real message, not an orphan)", () => { + // Has real text → not "nothing to show", so the drop never fires. Guards + // against over-dropping genuine notes that happen to lack an id. + const rows = buildDelegationTaskRows([ + statusPoll({ + input: "{}", + output: "still working on it", + state: "output-available", + toolStatus: "completed", + }), + ]) + expect(rows).toHaveLength(1) + }) + + it("does not stack promoted orphans next to the real task row", () => { + // The reported bug: after the turn completed, orphan polls re-appeared as + // anonymous "等待任务执行结果" rows beside the real #t1 row. + const rows = buildDelegationTaskRows([ + statusPoll({ + toolCallId: "real", + input: JSON.stringify({ task_ids: ["t1"] }), + output: doneReport, + }), + statusPoll({ + toolCallId: "orphan1", + input: "{}", + output: null, + state: "output-available", + toolStatus: "pending", + }), + statusPoll({ + toolCallId: "orphan2", + input: "{}", + output: null, + state: "output-available", + toolStatus: "in_progress", + }), + ]) + expect(rows).toHaveLength(1) + expect(rows[0].taskId).toBe("t1") + }) +}) diff --git a/src/lib/delegation-status.ts b/src/lib/delegation-status.ts index 7a55a9770..c744c7f4f 100644 --- a/src/lib/delegation-status.ts +++ b/src/lib/delegation-status.ts @@ -20,6 +20,7 @@ */ import { extractEmbeddedJsonObject } from "@/lib/embedded-json" +import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" import type { AdaptedToolCallPart, ToolCallState, @@ -535,12 +536,10 @@ export function buildDelegationTaskRows( // gets a (spinner) row immediately instead of all-but-the-first vanishing // until the batch resolves. const count = Math.max(reports.length, inputIds.length) - const inFlight = - poll.state === "input-available" || poll.state === "input-streaming" for (let i = 0; i < count; i++) { const report = reports[i] ?? EMPTY_STATUS_REPORT const taskId = report.taskId ?? inputIds[i] ?? null - // Drop an in-flight poll that carries no identity AND nothing to show yet. + // Drop an unsettled poll that carries no identity AND nothing to show yet. // claude-agent-acp ships an arg-less initial `tool_call` (rawInput `{}`) // and only fills the real `task_ids` on a later update — which, for a long // `wait_ms` status check, lands near completion — so a poll that is still @@ -548,11 +547,15 @@ export function buildDelegationTaskRows( // would otherwise become its own anonymous "waiting for a task's result" // row, stacking identical duplicates of the same wait. It folds into its // task's row the moment an id or report arrives, and the settled transcript - // (whose polls always carry the id) is unaffected. A settled id-less report - // — a real interim note or terminal status — still gets its own row below. + // (whose polls always carry the id) is unaffected. `isUnsettledToolCall` + // (not a bare live-state check) also covers an orphan promoted into + // `localTurns` at COMPLETE_TURN — output-available yet never settled — which + // would otherwise re-stack after the turn completes. A genuinely settled + // id-less report — a real interim note or terminal status — still gets its + // own row below. if ( taskId == null && - inFlight && + isUnsettledToolCall(poll) && report.status == null && (report.text == null || report.text.trim() === "") ) { diff --git a/src/lib/sidebar-view-mode-storage.ts b/src/lib/sidebar-view-mode-storage.ts index ba2afcb42..40195110a 100644 --- a/src/lib/sidebar-view-mode-storage.ts +++ b/src/lib/sidebar-view-mode-storage.ts @@ -2,6 +2,7 @@ const FOLDER_EXPANDED_KEY = "workspace:sidebar-folder-expanded" const SHOW_COMPLETED_KEY = "workspace:sidebar-show-completed" +const SHOW_WORKTREES_KEY = "workspace:sidebar-show-worktrees" const SORT_MODE_KEY = "workspace:sidebar-sort-mode" const SECTION_ORDER_KEY = "workspace:sidebar-section-order" const SECTION_COLLAPSED_KEY = "workspace:sidebar-section-collapsed" @@ -81,15 +82,18 @@ export function saveConversationExpanded(ids: number[]): void { } } +/** Whether completed conversations are shown in the sidebar list. Defaults to + * ON; only an explicitly-stored "false" (the user unchecked it) hides them. */ export function loadShowCompleted(): boolean { - if (typeof window === "undefined") return false + if (typeof window === "undefined") return true try { const raw = localStorage.getItem(SHOW_COMPLETED_KEY) + if (raw === "false") return false if (raw === "true") return true } catch { /* ignore */ } - return false + return true } export function saveShowCompleted(value: boolean): void { @@ -101,6 +105,31 @@ export function saveShowCompleted(value: boolean): void { } } +/** Whether the sidebar splits each repo's worktree child folders into their own + * indented sub-groups (instead of merging them flat into the parent group). + * Defaults to ON; only an explicitly-stored "false" (the user unchecked it) + * falls back to the flattened layout. */ +export function loadShowWorktrees(): boolean { + if (typeof window === "undefined") return true + try { + const raw = localStorage.getItem(SHOW_WORKTREES_KEY) + if (raw === "false") return false + if (raw === "true") return true + } catch { + /* ignore */ + } + return true +} + +export function saveShowWorktrees(value: boolean): void { + if (typeof window === "undefined") return + try { + localStorage.setItem(SHOW_WORKTREES_KEY, String(value)) + } catch { + /* ignore */ + } +} + export function loadSortMode(): SidebarSortMode { if (typeof window === "undefined") return "created" try { diff --git a/src/lib/tool-call-lifecycle.test.ts b/src/lib/tool-call-lifecycle.test.ts new file mode 100644 index 000000000..09d74970a --- /dev/null +++ b/src/lib/tool-call-lifecycle.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest" + +import { isUnsettledToolCall, toolStatusUnsettled } from "./tool-call-lifecycle" +import type { AdaptedToolCallPart } from "@/lib/adapters/ai-elements-adapter" + +function part(over: Partial = {}): AdaptedToolCallPart { + return { + type: "tool-call", + toolCallId: "t", + toolName: "bash", + input: "{}", + state: "output-available", + ...over, + } +} + +describe("toolStatusUnsettled", () => { + it("treats absent status as settled (DB-persisted rows)", () => { + expect(toolStatusUnsettled(undefined)).toBe(false) + expect(toolStatusUnsettled(null)).toBe(false) + }) + + it("treats terminal statuses as settled", () => { + expect(toolStatusUnsettled("completed")).toBe(false) + expect(toolStatusUnsettled("failed")).toBe(false) + expect(toolStatusUnsettled(" COMPLETED ")).toBe(false) + }) + + it("treats any non-terminal status as unsettled", () => { + expect(toolStatusUnsettled("pending")).toBe(true) + expect(toolStatusUnsettled("in_progress")).toBe(true) + }) +}) + +describe("isUnsettledToolCall", () => { + it("is true for a running (live) part regardless of status", () => { + expect(isUnsettledToolCall(part({ state: "input-available" }))).toBe(true) + expect(isUnsettledToolCall(part({ state: "input-streaming" }))).toBe(true) + }) + + it("is true for a promoted orphan: output-available but status unsettled", () => { + expect( + isUnsettledToolCall( + part({ state: "output-available", toolStatus: "pending" }) + ) + ).toBe(true) + }) + + it("is false for a settled part (output-available, no/terminal status)", () => { + expect(isUnsettledToolCall(part({ state: "output-available" }))).toBe(false) + expect( + isUnsettledToolCall( + part({ state: "output-available", toolStatus: "completed" }) + ) + ).toBe(false) + }) +}) diff --git a/src/lib/tool-call-lifecycle.ts b/src/lib/tool-call-lifecycle.ts new file mode 100644 index 000000000..a419c2743 --- /dev/null +++ b/src/lib/tool-call-lifecycle.ts @@ -0,0 +1,44 @@ +/** + * Small, dependency-light lifecycle predicates for an adapted tool-call part, + * shared by the generic tool-group filter (`dropEmptyInFlightToolCalls`) and the + * specialized lane row-builders (`buildDelegationTaskRows`, + * `buildBackgroundTaskRows`). Kept in its own module — with a TYPE-ONLY import + * of `AdaptedToolCallPart` (erased at runtime) — so `background-task.ts` can use + * it without forming a runtime import cycle with the adapter (the adapter + * imports `background-task.ts`). + */ + +import type { AdaptedToolCallPart } from "@/lib/adapters/ai-elements-adapter" + +/** + * Whether a forwarded ACP tool status is present and not yet terminal. Absent + * status (`undefined`/`null`, as on DB-persisted rows) counts as settled, so + * historical tool calls are never treated as still-in-flight. + */ +export function toolStatusUnsettled( + status: string | null | undefined +): boolean { + if (status == null) return false + const s = status.trim().toLowerCase() + return s !== "completed" && s !== "failed" +} + +/** + * A tool-call part that has NOT reached a terminal outcome: either its adapted + * lifecycle state is still running, or its forwarded ACP status is unsettled. + * + * The status arm is what catches an interrupted arg-less orphan promoted into + * `localTurns` at `COMPLETE_TURN`: the promotion re-adapts it with + * `isStreaming=false`, flipping its state to `output-available`, but it never + * actually completed — its forwarded status stays `pending`/`in_progress` until + * an authoritative detail reload. Without this arm, the orphan re-inflates the + * "运行 N 个命令" count and the delegation/background poll rows after a turn + * completes. DB-persisted rows carry no forwarded status, so they are exempt. + */ +export function isUnsettledToolCall(part: AdaptedToolCallPart): boolean { + return ( + part.state === "input-available" || + part.state === "input-streaming" || + toolStatusUnsettled(part.toolStatus) + ) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 9015c2deb..328ed89b5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -141,6 +141,14 @@ export type ContentBlock = tool_use_id: string | null tool_name: string input_preview: string | null + /** + * ACP tool-call status when known. Live and promoted turns forward it + * from `ToolCallInfo.status` in `buildStreamingTurnsFromLiveMessage`; + * DB-persisted rows omit it (`undefined`). Lets the render layer tell a + * still-unsettled orphan (interrupted/retried arg-less call promoted into + * `localTurns`) from a completed no-op. See `dropEmptyInFlightToolCalls`. + */ + status?: string | null /** * ACP extensibility metadata for this tool call. Opaque pass-through * — both the live snapshot (`ToolCallState.meta`) and the persisted diff --git a/src/stores/conversation-runtime-store.ts b/src/stores/conversation-runtime-store.ts index ef78ee2b4..87d3f2bd4 100644 --- a/src/stores/conversation-runtime-store.ts +++ b/src/stores/conversation-runtime-store.ts @@ -941,6 +941,12 @@ export function buildStreamingTurnsFromLiveMessage( tool_use_id: block.info.tool_call_id, tool_name: toolName, input_preview: resolveLiveToolInput(toolName, block.info), + // Forward the ACP status so the render layer can drop an interrupted + // arg-less orphan that survives promotion into `localTurns` at + // COMPLETE_TURN: post-completion its adapted state is no longer + // "running", but this status stays unsettled (pending/in_progress) + // until an authoritative detail reload. See dropEmptyInFlightToolCalls. + status: block.info.status ?? null, // Forward the ACP `meta` field downstream so the renderer can // read delegation state (`meta["codeg.delegation"]`) for // pre-binding / post-refresh fallback rendering of