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).
)
}
@@ -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.
{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