From 1fd9c2254b7da2470b01d79256f7bc842476d183 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 23 Jul 2026 16:11:59 +0800 Subject: [PATCH 1/8] fix(chat): stop empty in-flight tool calls from stacking after a turn completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interrupted or retried turns leave arg-less tool_call orphans in the live message. When COMPLETE_TURN promotes the live message into localTurns, those orphans are re-adapted as output-available and re-inflate the tool-group "运行 N 个命令" count and the delegation-status / background-task poll cards, even though the persisted transcript never contains them. Forward the ACP tool status onto promoted tool_use blocks and drop unsettled, empty, id-less orphans through a shared isUnsettledToolCall predicate, used by the generic tool-group filter and the delegation and background poll-row builders. Rows carrying a real id, report, output, or a settled status are kept, so history and genuine tool calls are unaffected. --- .../conversation-runtime-context.test.tsx | 36 ++++ src/lib/adapters/ai-elements-adapter.test.ts | 178 ++++++++++++++++++ src/lib/adapters/ai-elements-adapter.ts | 103 +++++++++- src/lib/background-task.test.ts | 22 +++ src/lib/background-task.ts | 8 +- src/lib/delegation-status.test.ts | 105 +++++++++++ src/lib/delegation-status.ts | 15 +- src/lib/tool-call-lifecycle.test.ts | 57 ++++++ src/lib/tool-call-lifecycle.ts | 44 +++++ src/lib/types.ts | 8 + src/stores/conversation-runtime-store.ts | 6 + 11 files changed, 573 insertions(+), 9 deletions(-) create mode 100644 src/lib/tool-call-lifecycle.test.ts create mode 100644 src/lib/tool-call-lifecycle.ts 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/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/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 From 4b4d65cfdfd448e67c38f6a6592fba6ecc3a883b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 23 Jul 2026 17:24:00 +0800 Subject: [PATCH 2/8] fix(chat): align connection-status icon with the send button's right edge - Line the composer connection-status icon's right edge up flush with the send button directly above it. - Use the default muted text colour for the connected state instead of a green heart, matching the sibling context-usage indicator. --- src/components/chat/composer-connection-status.tsx | 10 +++++++--- src/components/chat/message-input.tsx | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) 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. */} +
From 9948f31f46a2d69af7a08e1ea92631c60e15415f Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 23 Jul 2026 18:13:46 +0800 Subject: [PATCH 3/8] feat(chat): add view-diff action to reply changed-file cards Each modified file in the reply's changed-files card now has a View Diff button, placed left of the reveal-in-file-manager action, that opens the file's diff in an editor tab. Works in both web and desktop. --- .../message/reply-artifacts.test.tsx | 138 ++++++++++++++++++ src/components/message/reply-artifacts.tsx | 32 +++- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 src/components/message/reply-artifacts.test.tsx 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() && ( From 1634cca0b9c70bb2606eb5858b77d530273604be Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 23 Jul 2026 18:37:13 +0800 Subject: [PATCH 4/8] feat(layout): show session stats on the left of the mobile status bar --- src/components/layout/status-bar.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 ( -
+
+
+ +
From 5cf3f7eb8bc086c427a72f875ede423c59e7a9fd Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 23 Jul 2026 19:32:16 +0800 Subject: [PATCH 5/8] fix(layout): stop the branch selector popup showing a stray scrollbar on short lists Size the popup's scroll window to the row list's real measured height instead of a fixed per-row estimate, so a filtered-down list is exactly as tall as its rows with no stray scrollbar and no dead space. A ResizeObserver on the row list drives the height (rounded up so it is never a sub-pixel short of its content), still capped at the max; the per-row estimate only seeds the first paint before the measurement lands. --- .../layout/branch-selector-list.tsx | 39 +++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) 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({
Date: Thu, 23 Jul 2026 19:46:05 +0800 Subject: [PATCH 6/8] feat(sidebar): add a "Show worktree folders" toggle to the view-options funnel When enabled, a repo with git worktrees renders as a collapsible container: its own sessions group under an indented "root" sub-group and each worktree becomes an indented sub-group (FolderGit2 glyph + branch label), linked by a vertical connector line that stays continuous through empty sub-groups. Collapsing the container hides the whole subtree, while folder reordering still targets only top-level repos. When off, worktree sessions stay merged flat under their parent repo. The preference is persisted and the toggle label is localized across all languages. --- .../sidebar-conversation-grouping.test.ts | 207 +++++++++++ .../sidebar-conversation-grouping.ts | 152 +++++++- .../sidebar-conversation-list.test.tsx | 181 ++++++++- .../sidebar-conversation-list.tsx | 346 +++++++++++++++--- src/components/layout/sidebar.test.tsx | 48 ++- src/components/layout/sidebar.tsx | 16 + src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/sidebar-view-mode-storage.ts | 24 ++ 17 files changed, 912 insertions(+), 72 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index a37bb9043..b6e894dce 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -17,6 +17,7 @@ import { reuseSet, selectChatConversationsWithReuse, selectPinnedWithReuse, + worktreeChildrenByParent, type SidebarRow, } from "./sidebar-conversation-grouping" @@ -185,6 +186,64 @@ describe("groupByFolderWithReuse", () => { }) }) +describe("worktreeChildrenByParent", () => { + const folder = ( + id: number, + parent_id: number | null, + sort_order = 0, + name = `folder-${id}` + ) => ({ id, parent_id, sort_order, name }) + + it("returns an empty map when no repo has worktree children", () => { + const folders = [folder(10, null), folder(20, null)] + expect(worktreeChildrenByParent([10, 20], folders).size).toBe(0) + }) + + it("groups each repo's open worktree children under it", () => { + const folders = [ + folder(10, null), + folder(12, 10, 2, "beta"), + folder(11, 10, 1, "alpha"), + folder(20, null), + folder(21, 20, 0, "wt"), + ] + const map = worktreeChildrenByParent([10, 20], folders) + // 11 (sort_order 1) before 12 (sort_order 2). + expect(map.get(10)).toEqual([11, 12]) + expect(map.get(20)).toEqual([21]) + // Only repos WITH children are keys. + expect([...map.keys()].sort((a, b) => a - b)).toEqual([10, 20]) + }) + + it("orders children by sort_order, then name, then id", () => { + const folders = [ + folder(10, null), + folder(13, 10, 0, "b"), + folder(12, 10, 0, "a"), + folder(11, 10, 0, "a"), + ] + // equal sort_order → by name ("a" < "b"): 11/12 before 13; tie on name → id. + expect(worktreeChildrenByParent([10], folders).get(10)).toEqual([ + 11, 12, 13, + ]) + }) + + it("omits an orphan worktree whose parent is not a top-level entry", () => { + // Parent 10 is closed → absent from the top-level set, so worktree 11 is + // nobody's child here and no container is produced. + const folders = [folder(11, 10), folder(20, null)] + expect(worktreeChildrenByParent([11, 20], folders).size).toBe(0) + }) + + it("does not mutate the inputs", () => { + const folders = [folder(10, null), folder(11, 10)] + const top = [10] + worktreeChildrenByParent(top, folders) + expect(top).toEqual([10]) + expect(folders.map((f) => f.id)).toEqual([10, 11]) + }) +}) + describe("reuseSet", () => { it("returns the previous set when membership is unchanged", () => { const prev = new Set(["a:1", "b:2"]) @@ -783,6 +842,154 @@ describe("buildRows", () => { }) }) +describe("buildRows — Show worktrees container tree", () => { + // Trim the trailing (always-present) Chat section for exact folder assertions. + const trimChats = (rows: SidebarRow[]): SidebarRow[] => { + const i = rows.findIndex( + (r) => r.kind === "section" && r.section === "chats" + ) + return i === -1 ? rows : rows.slice(0, i) + } + + it("nests a container repo's root sub-group + worktrees, both at depth 1", () => { + const rootConv = conv(1, 10) + const wtConv = conv(2, 11) + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([ + [10, [rootConv]], + [11, [wtConv]], + ]), + folderExpanded: {}, + folderTotalCounts: new Map([ + [10, 1], + [11, 1], + ]), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + }) + expect(trimChats(rows)).toEqual([ + { kind: "section", section: "folders", expanded: true, count: 1 }, + { kind: "folder", folderId: 10 }, // container + { kind: "root-group", folderId: 10 }, // repo's own sessions + { kind: "conversation", conversation: rootConv, depth: 1 }, + { kind: "folder", folderId: 11 }, // worktree + { kind: "conversation", conversation: wtConv, depth: 1 }, + ]) + }) + + it("hides the whole subtree when the container is collapsed", () => { + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([ + [10, [conv(1, 10)]], + [11, [conv(2, 11)]], + ]), + folderExpanded: { 10: false }, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + }) + // Container header only — no root-group, no worktree rows. + expect(trimChats(rows)).toEqual([ + { kind: "section", section: "folders", expanded: true, count: 1 }, + { kind: "folder", folderId: 10 }, + ]) + }) + + it("collapses only the root sub-group, leaving worktrees visible", () => { + const wtConv = conv(2, 11) + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([ + [10, [conv(1, 10)]], + [11, [wtConv]], + ]), + folderExpanded: {}, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + rootGroupCollapsed: new Set([10]), + }) + expect(trimChats(rows)).toEqual([ + { kind: "section", section: "folders", expanded: true, count: 1 }, + { kind: "folder", folderId: 10 }, + { kind: "root-group", folderId: 10 }, // header shown, sessions hidden + { kind: "folder", folderId: 11 }, + { kind: "conversation", conversation: wtConv, depth: 1 }, + ]) + }) + + it("emits an empty hint for an empty root sub-group / worktree", () => { + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map(), + folderExpanded: {}, + folderTotalCounts: new Map([ + [10, 4], + [11, 0], + ]), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + }) + expect(trimChats(rows)).toEqual([ + { kind: "section", section: "folders", expanded: true, count: 1 }, + { kind: "folder", folderId: 10 }, + { kind: "root-group", folderId: 10 }, + { kind: "empty", folderId: 10, totalConversationCount: 4 }, + { kind: "folder", folderId: 11 }, + { kind: "empty", folderId: 11, totalConversationCount: 0 }, + ]) + }) + + it("renders a repo with no worktree children flat at depth 0 (no root-group)", () => { + const c = conv(1, 20) + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10, 20], + byFolder: new Map([ + [11, [conv(9, 11)]], + [20, [c]], + ]), + folderExpanded: {}, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + // Only repo 10 is a container; repo 20 stays a plain flat folder. + containerChildren: new Map([[10, [11]]]), + }) + const trimmed = trimChats(rows) + // Repo 20 (plain): header + its own session at depth 0 — no root-group. + expect(trimmed).toContainEqual({ kind: "folder", folderId: 20 }) + expect(trimmed).toContainEqual({ + kind: "conversation", + conversation: c, + depth: 0, + }) + expect( + trimmed.some((r) => r.kind === "root-group" && r.folderId === 20) + ).toBe(false) + }) +}) + describe("mergeChildrenById", () => { it("keeps live events over the snapshot by id and adds new children", () => { const snapA = conv(100, 1, { status: "pending" }) diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index ec66295aa..58b1770de 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -1,4 +1,4 @@ -import type { DbConversationSummary } from "@/lib/types" +import type { DbConversationSummary, FolderDetail } from "@/lib/types" import type { SidebarSortMode, SidebarSectionOrder, @@ -259,6 +259,60 @@ export function selectChatConversationsWithReuse( return arraysShallowEqual(prev, next) ? prev : next } +// ── Folder display ordering (worktree nesting) ────────────────────────────── + +/** The folder fields needed to nest worktree children under their repo root. */ +export type FolderOrderInput = Pick< + FolderDetail, + "id" | "parent_id" | "sort_order" | "name" +> + +/** + * Group each top-level repo's OPEN worktree child folders under it, returning a + * `repoId → [worktreeChildId, ...]` map that only contains repos with at least + * one worktree child. A folder is a worktree child of `p` when its `parent_id` + * is `p` and `p` is a top-level entry in `topLevelFolderIds`; children are + * ordered `sort_order`, then `name`, then `id` for a stable, input-order- + * independent sequence. + * + * Drives the "Show worktrees" container tree: a repo present as a key renders as + * a container (its own sessions move into an indented "root" sub-group, and each + * worktree becomes an indented sub-group after it). An orphan worktree whose + * parent is closed/removed is not a top-level entry's child, so it appears in no + * value list and stands alone as an ordinary top-level folder. Pure — no side + * effects, never mutates inputs. + */ +export function worktreeChildrenByParent( + topLevelFolderIds: readonly number[], + folders: readonly FolderOrderInput[] +): Map { + const topLevel = new Set(topLevelFolderIds) + const childrenByParent = new Map() + for (const f of folders) { + if (f.parent_id == null) continue + if (!topLevel.has(f.parent_id)) continue + const list = childrenByParent.get(f.parent_id) + if (list) list.push(f) + else childrenByParent.set(f.parent_id, [f]) + } + + const out = new Map() + for (const [parentId, list] of childrenByParent) { + // Stable order within a repo: sort_order, then name, then id as a final + // tie-break so the sequence is deterministic regardless of input order. + list.sort((a, b) => { + if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order + const byName = a.name.localeCompare(b.name) + return byName !== 0 ? byName : a.id - b.id + }) + out.set( + parentId, + list.map((f) => f.id) + ) + } + return out +} + // ── Flat row model (Phase 2 virtualization) ───────────────────────────────── // The sidebar tree (folders → their conversation rows) is flattened into a // single linear array so it can be windowed by `virtua`. Each visible folder @@ -270,6 +324,21 @@ export interface FolderHeaderRow { folderId: number } +/** + * The "root" sub-group header shown (only under "Show worktrees") directly below + * a repo CONTAINER header: it groups the repo's OWN (non-worktree) conversations + * under an indented, FolderRoot-glyph "root folder" heading, parallel to each + * worktree sub-group. Distinct from {@link FolderHeaderRow} so it can share the + * container's numeric `folderId` (the repo id, used for its bucket / count / + * theme) without colliding with the container's own folder header row, and so + * the sticky-header machinery — keyed on `kind === "folder"` — deliberately + * skips it (single-level sticky: the container stands in for its root sessions). + */ +export interface RootGroupHeaderRow { + kind: "root-group" + folderId: number +} + export interface ConversationRow { kind: "conversation" /** @@ -350,6 +419,7 @@ export interface SubsessionLoadingRow { export type SidebarRow = | SectionHeaderRow | FolderHeaderRow + | RootGroupHeaderRow | ConversationRow | EmptyHintRow | ChatsEmptyRow @@ -364,6 +434,10 @@ const MAX_RENDER_DEPTH = 32 const EMPTY_EXPANDED: ReadonlySet = new Set() const EMPTY_CHILDREN: ReadonlyMap = new Map() +// No repo is a worktree container by default (Show worktrees off) — every folder +// then takes the flat path, identical to the pre-worktree row model. +const EMPTY_CONTAINER_CHILDREN: ReadonlyMap = + new Map() /** * Merge a freshly-fetched children snapshot with child summaries already applied @@ -503,6 +577,17 @@ export function buildRows(args: { * spinner; an empty array WITHOUT membership is a settled-empty subtree * (renders nothing). Optional. */ childrenLoading?: ReadonlySet + /** "Show worktrees" container map: `repoId → [worktreeChildId, ...]`. A repo + * present here renders as a CONTAINER — its header is followed (when the + * container is expanded via `folderExpanded[repoId]`) by a `root-group` header + * + the repo's own sessions (depth 1), then each worktree's header + sessions + * (depth 1). Absent/empty (the default) = the flat model: every folder renders + * its header + its own bucket at depth 0, exactly as before. Optional. */ + containerChildren?: ReadonlyMap + /** Repo ids whose `root-group` sub-group is collapsed (its own sessions + * hidden). Absent = expanded (the default). Only consulted for container + * repos. Optional. */ + rootGroupCollapsed?: ReadonlySet }): SidebarRow[] { const { pinned, @@ -518,6 +603,8 @@ export function buildRows(args: { conversationExpanded = EMPTY_EXPANDED, childrenByParent = EMPTY_CHILDREN, childrenLoading = EMPTY_EXPANDED, + containerChildren = EMPTY_CONTAINER_CHILDREN, + rootGroupCollapsed = EMPTY_EXPANDED, } = args const rows: SidebarRow[] = [] @@ -547,6 +634,32 @@ export function buildRows(args: { // the order they emit into `rows` is a one-line swap below — the row logic // inside each (both headers always present, each with its own empty hint) // stays intact regardless of position. + // Emit one folder's body (its conversation rows, or a single empty hint) at + // `baseDepth` — 0 for a plain top-level folder, 1 for a container's root + // sub-group or a worktree sub-group. The empty hint carries no depth; the + // renderer derives its indent from the folder id (worktree/container → 1). + const pushFolderBody = (folderId: number, baseDepth: number) => { + const convs = byFolder.get(folderId) + if (!convs || convs.length === 0) { + rows.push({ + kind: "empty", + folderId, + totalConversationCount: folderTotalCounts.get(folderId) ?? 0, + }) + return + } + for (const conv of convs) { + pushConversationRow( + rows, + conv, + baseDepth, + conversationExpanded, + childrenByParent, + childrenLoading + ) + } + } + const pushFolders = () => { // The Folders section header is always present (a permanent entry point), // mirroring the Chat section — so a workspace with chats but no open folders @@ -565,27 +678,26 @@ export function buildRows(args: { return } for (const folderId of orderedFolderIds) { - rows.push({ kind: "folder", folderId }) - const expanded = folderExpanded[folderId] ?? true - if (!expanded) continue - const convs = byFolder.get(folderId) - if (!convs || convs.length === 0) { - rows.push({ - kind: "empty", - folderId, - totalConversationCount: folderTotalCounts.get(folderId) ?? 0, - }) + const worktrees = containerChildren.get(folderId) + if (!worktrees || worktrees.length === 0) { + // Plain top-level folder (or, under Show worktrees, a repo with no open + // worktrees): header + its own bucket at depth 0 — the flat model. + rows.push({ kind: "folder", folderId }) + if (folderExpanded[folderId] ?? true) pushFolderBody(folderId, 0) continue } - for (const conv of convs) { - pushConversationRow( - rows, - conv, - 0, - conversationExpanded, - childrenByParent, - childrenLoading - ) + // Container repo: its header gates the WHOLE subtree (root sub-group + + // worktrees). Collapsing it hides everything below, so the connector spine + // only spans an expanded container. + rows.push({ kind: "folder", folderId }) + if (!(folderExpanded[folderId] ?? true)) continue + // The repo's OWN sessions move into an indented "root" sub-group, first. + rows.push({ kind: "root-group", folderId }) + if (!rootGroupCollapsed.has(folderId)) pushFolderBody(folderId, 1) + // Then each worktree as its own indented sub-group. + for (const worktreeId of worktrees) { + rows.push({ kind: "folder", folderId: worktreeId }) + if (folderExpanded[worktreeId] ?? true) pushFolderBody(worktreeId, 1) } } } diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index d063750e2..602017183 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -26,7 +26,7 @@ import enMessages from "@/i18n/messages/en.json" // FolderOpen lucide icon renders once per FolderHeader body → counts folder // re-renders. Both increment only when the owning memoized component does NOT // bail out, so they measure exactly the production memo path. -const probes = vi.hoisted(() => ({ card: 0, folder: 0 })) +const probes = vi.hoisted(() => ({ card: 0, folder: 0, root: 0 })) // Mutable backing store the mocked tab-context hook reads from (workspace data // now lives in the real zustand store, seeded per test below). `tabs` is @@ -133,10 +133,14 @@ vi.mock("virtua", () => ({ }, })) -// FolderHeader renders exactly one of FolderClosed/FolderOpen in its body → -// folder re-render probe. Every other icon stays real. (The Folders section -// header's Open Folder / Clone Repository actions use FolderOpenDot / FolderGit2, -// which are NOT mocked here, so they never inflate this probe.) +// FolderHeader renders exactly one glyph in its body per variant: FolderClosed/ +// FolderOpen (a repo / plain folder / repo container header) → `probes.folder`, +// or FolderRoot (a container's "root" sub-group header) → `probes.root`. Every +// other icon stays real. FolderRoot is used ONLY by the root sub-group, so it is +// an exact re-render probe. Worktree headers use FolderGit2, which the Folders +// section header's Clone action ALSO renders — so it is deliberately left real +// (not a probe); worktree headers are asserted via `data-folder-id` + the branch +// label instead. vi.mock("lucide-react", async (importOriginal) => { const actual = await importOriginal() return { @@ -149,6 +153,10 @@ vi.mock("lucide-react", async (importOriginal) => { probes.folder++ return null }, + FolderRoot: () => { + probes.root++ + return null + }, } }) @@ -799,3 +807,166 @@ describe("SidebarConversationList — folder ⋯ opens the same menu as right-cl expect(document.body.textContent).toContain("Manage conversations") }) }) + +describe("SidebarConversationList — worktree grouping (Show worktrees)", () => { + // A repo (folder 1) with two conversations, plus a worktree child (folder 2, + // branch "feature-x") holding one conversation. + const wtHarness: { rerender: () => void } = { rerender: () => {} } + function WtHarness({ showWorktrees }: { showWorktrees: boolean }) { + const [, setTick] = useState(0) + useEffect(() => { + wtHarness.rerender = () => setTick((n) => n + 1) + }, []) + return ( + + ) + } + function wtTree(showWorktrees: boolean) { + return ( + + + + ) + } + + function folderHeaderIds(): number[] { + return Array.from(document.querySelectorAll("[data-folder-id]")).map((el) => + Number(el.getAttribute("data-folder-id")) + ) + } + + beforeEach(() => { + probes.card = 0 + probes.folder = 0 + probes.root = 0 + const wt = { + ...folder(2, "wt-feature", 1), + git_branch: "feature-x", + } as unknown as FolderDetail + const folders = [folder(1, "Repo"), wt] + store.activeTabId = null + store.tabSpec = [] + useAppWorkspaceStore.setState({ + folders, + allFolders: folders, + conversations: [conv(11, 1), conv(12, 1), conv(21, 2)], + }) + }) + + it("merges worktree conversations flat under the parent when off", () => { + render(wtTree(false)) + // Only the repo gets a header; the worktree child is hidden and its + // conversation is merged into the repo bucket — no container/root split. + expect(folderHeaderIds()).toEqual([1]) + const text = document.body.textContent ?? "" + expect(text).toContain("conv-11") + expect(text).toContain("conv-12") + expect(text).toContain("conv-21") + // No worktree header → no branch label; no root sub-group (probes.root===0, + // the unambiguous check — the "root" label is a generic word to match on). + expect(text).not.toContain("feature-x") + expect(probes.root).toBe(0) + }) + + it("splits the repo into a container + root sub-group + worktree sub-group when on", () => { + render(wtTree(true)) + // Container header (repo id 1) → root sub-group header (also repo id 1, its + // own sessions) → worktree header (id 2). The repo id appears twice: the + // container and its root sub-group both carry it. + expect(folderHeaderIds()).toEqual([1, 1, 2]) + const text = document.body.textContent ?? "" + // Order: container "Repo" → "root" sub-group + its own convs → worktree + // branch "feature-x" + the worktree's conv. + const iRoot = text.indexOf("root") + const iRepoConv = text.indexOf("conv-11") + const iBranch = text.indexOf("feature-x") + const iWtConv = text.indexOf("conv-21") + expect(iRoot).toBeGreaterThanOrEqual(0) + expect(iRepoConv).toBeGreaterThan(iRoot) + expect(iBranch).toBeGreaterThan(iRepoConv) + expect(iWtConv).toBeGreaterThan(iBranch) + // Exactly one container header (FolderOpen) + one root sub-group (FolderRoot). + expect(probes.folder).toBe(1) + expect(probes.root).toBe(1) + }) + + it("collapsing the root sub-group hides the repo's own sessions but keeps the worktree", () => { + render(wtTree(true)) + expect(document.body.textContent).toContain("conv-11") + + // Toggle the root sub-group header (the SECOND data-folder-id=1 button, after + // the container). Its own sessions collapse; the worktree stays visible. + const repoHeaders = document.querySelectorAll('[data-folder-id="1"]') + expect(repoHeaders).toHaveLength(2) + act(() => { + fireEvent.click(repoHeaders[1] as HTMLElement) + }) + + const text = document.body.textContent ?? "" + expect(text).not.toContain("conv-11") + expect(text).not.toContain("conv-12") + // The worktree sub-group is untouched. + expect(text).toContain("feature-x") + expect(text).toContain("conv-21") + }) + + it("keeps the connector spine continuous through an empty worktree sub-group", () => { + // Add a second worktree (folder 3) with NO conversations → it renders the + // empty-folder hint. That empty row must still draw the container spine + // (ancestor rail) so the vertical connector doesn't break at "no + // conversations". + const wtEmpty = { + ...folder(3, "wt-empty", 1), + git_branch: "feature-y", + } as unknown as FolderDetail + const cur = useAppWorkspaceStore.getState() + useAppWorkspaceStore.setState({ + folders: [...cur.folders, wtEmpty], + allFolders: [...cur.allFolders, wtEmpty], + }) + render(wtTree(true)) + + const hint = enMessages.Folder.sidebar.emptyFolderHint + const hintSpan = Array.from(document.querySelectorAll("span")).find( + (s) => s.textContent === hint + ) + expect(hintSpan).toBeTruthy() + // The empty row (the hint span's row container) carries an ancestor rail. + const row = hintSpan!.closest("div") + expect(row?.querySelector("[data-subsession-rail]")).not.toBeNull() + }) + + it("keeps the single-status-event budget (1 card, 0 headers) with worktrees on", () => { + render(wtTree(true)) + // Initial mount: all three conversations render a card. + expect(probes.card).toBe(3) + + // Replace exactly the worktree's conversation (conv 21) with a new object; + // every other summary keeps its identity (mirrors updateConversationLocal). + const prev = useAppWorkspaceStore.getState().conversations + const next = prev.slice() + const idx = next.findIndex((c) => c.id === 21) + next[idx] = { ...next[idx], status: "completed" } + + probes.card = 0 + probes.folder = 0 + probes.root = 0 + act(() => { + useAppWorkspaceStore.setState({ conversations: next }) + }) + act(() => wtHarness.rerender()) + + // Only the changed card re-renders; the container header AND the root + // sub-group header (both keyed off `folders`, unchanged by a status event) + // bail out, so the container split costs nothing extra per event. (The + // worktree header shares the same memo + folder-derived props, so 0 root/ + // folder re-renders is sufficient evidence it bails too.) + expect(probes.card).toBe(1) + expect(probes.folder).toBe(0) + expect(probes.root).toBe(0) + }) +}) diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 44dd42878..6a2200fb7 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -25,6 +25,7 @@ import { FolderGit2, FolderOpen, FolderOpenDot, + FolderRoot, ListChecks, Loader2, MoreHorizontal, @@ -102,6 +103,7 @@ import { reuseSet, selectChatConversationsWithReuse, selectPinnedWithReuse, + worktreeChildrenByParent, type SidebarRow, } from "./sidebar-conversation-grouping" import { useSubsessionSync } from "@/hooks/use-subsession-sync" @@ -149,6 +151,18 @@ import { toErrorMessage } from "@/lib/app-error" const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect +// Shared empty merge map used when "Show worktrees" is on: worktree children +// then keep their own conversation bucket / count / theme instead of being +// folded into the parent. A module constant so the reference is stable across +// renders (the `byFolder` / `folderTotalCounts` memos depend on it). +const EMPTY_CHILD_TO_PARENT: ReadonlyMap = new Map() + +// Shared empty container map used when "Show worktrees" is off: no repo is a +// worktree container, so every folder renders flat. Stable reference so the +// `containerChildren` memo (and buildRows through it) doesn't churn. +const EMPTY_CONTAINER_CHILDREN: ReadonlyMap = + new Map() + const FolderHeader = memo(function FolderHeader({ folderId, folderName, @@ -174,6 +188,9 @@ const FolderHeader = memo(function FolderHeader({ isDragging, onGripPointerDown, suppressed = false, + depth = 0, + variant = "repo", + worktreeBranch = null, }: { folderId: number folderName: string @@ -219,6 +236,27 @@ const FolderHeader = memo(function FolderHeader({ * virtua still keeps it mounted in the buffer. */ suppressed?: boolean + /** + * Nesting depth of the header row. 0 for a top-level repo / plain folder / repo + * container; 1 for a worktree or "root" sub-group shown under its container + * when "Show worktrees" is on. Drives the left indent and the connector-spine + * ancestor rails (a pure function of this number), mirroring the conversation + * card's per-level rail step. + */ + depth?: number + /** + * Which glyph + label this header renders: + * - `repo` (default): a top-level repo / plain folder / repo container — the + * FolderOpen/FolderClosed glyph and the repo-name alias label. + * - `worktree`: a git worktree sub-group — the FolderGit2 glyph and the branch + * label (`worktreeBranch`). + * - `root`: a repo container's own-sessions sub-group — the FolderRoot glyph + * and a fixed, non-localized "root" label. + */ + variant?: "repo" | "worktree" | "root" + /** The worktree's branch name (its own `git_branch`), shown as the label when + * `variant === "worktree"`. Falls back to the folder name when absent. */ + worktreeBranch?: string | null }) { // Own the translations here rather than receiving `t` as a prop: next-intl // returns a fresh `t` on every parent render, so passing it down would defeat @@ -294,9 +332,15 @@ const FolderHeader = memo(function FolderHeader({ isDragging ? "cursor-grabbing" : "cursor-grab" )} style={{ - paddingLeft: "calc(var(--conv-rail-axis) + 0.875rem)", + paddingLeft: `calc(var(--conv-rail-axis) + 0.875rem + ${depth} * ${CONV_RAIL_DEPTH_STEP})`, }} > + {/* Connector spine (Show worktrees): a depth-1 sub-group header + draws its container's vertical rail at the depth-0 axis, so + stacked across the container's children (root + worktree + headers and their session cards) it forms one continuous line + down from the container. Renders nothing at depth 0. */} + - {expanded ? ( + {variant === "worktree" ? ( + + ) : variant === "root" ? ( + + ) : expanded ? ( ) : ( @@ -322,11 +370,21 @@ const FolderHeader = memo(function FolderHeader({ "min-w-0 flex-shrink truncate text-left text-[0.875rem] font-normal text-sidebar-foreground/75" )} > - + {variant === "worktree" ? ( + (worktreeBranch ?? folderName) + ) : variant === "root" ? ( + // The container repo's own-sessions sub-group is labeled + // with a fixed, non-localized "root" (its glyph is + // FolderRoot); it stands for the repo root regardless of UI + // language. + "root" + ) : ( + + )} }) { @@ -679,6 +741,7 @@ export function SidebarConversationList({ path: string color: string defaultAgentType: AgentType | null + gitBranch: string | null } >() for (const f of allFolders) @@ -688,6 +751,7 @@ export function SidebarConversationList({ path: f.path, color: f.color, defaultAgentType: f.default_agent_type, + gitBranch: f.git_branch, }) return map }, [allFolders]) @@ -727,6 +791,14 @@ export function SidebarConversationList({ const [folderExpanded, setFolderExpanded] = useState>( {} ) + // Repo ids whose "root" sub-group (a container repo's own sessions, shown only + // under "Show worktrees") is collapsed. Session-only, not persisted: the + // container's own collapse — which hides the whole subtree — IS persisted via + // `folderExpanded`, so this indented sub-toggle is kept deliberately + // lightweight. Default (absent) = expanded. + const [rootGroupCollapsed, setRootGroupCollapsed] = useState>( + () => new Set() + ) // Collapsed state of the two top-level sections ("Pinned", "Folders"). Absent // key = expanded (default). Hydrated from localStorage after mount. const [sectionCollapsed, setSectionCollapsed] = @@ -809,7 +881,7 @@ export function SidebarConversationList({ const dragCleanupRef = useRef<(() => void) | null>(null) const autoscrollRef = useRef(null) // Snapshots read by the imperative drag listeners without re-subscribing them. - const orderedFolderIdsRef = useRef([]) + const reorderableFolderIdsRef = useRef([]) const reorderingRef = useRef(false) useEffect(() => { @@ -970,6 +1042,16 @@ export function SidebarConversationList({ return map }, [folders]) + // The merge map used for DISPLAY (grouping, counts, theming). When "Show + // worktrees" is on it is empty, so each worktree child keeps its own bucket / + // count / theme and renders under its own header. The raw `childToParent` + // above is still used to know which folders ARE worktree children (nesting + // order, indent, grip gating). Off → identical to the historical behavior. + const displayChildToParent = useMemo( + () => (showWorktrees ? EMPTY_CHILD_TO_PARENT : childToParent), + [showWorktrees, childToParent] + ) + // Hold the previous grouping so unchanged folders keep their bucket array // reference across renders (lets memoized FolderGroupItems bail out). Updating // the ref inside the memo factory is a deliberate cache, idempotent under @@ -980,11 +1062,11 @@ export function SidebarConversationList({ folderConversations, sortMode, byFolderRef.current, - childToParent + displayChildToParent ) byFolderRef.current = grouped return grouped - }, [folderConversations, sortMode, childToParent]) + }, [folderConversations, sortMode, displayChildToParent]) // Counts the unfiltered-but-non-pinned conversations per display group, so the // empty-hint renderer distinguishes a truly empty folder from one whose rows @@ -994,17 +1076,22 @@ export function SidebarConversationList({ const map = new Map() for (const conv of conversations) { if (conv.pinned_at != null) continue - const groupId = childToParent.get(conv.folder_id) ?? conv.folder_id + const groupId = displayChildToParent.get(conv.folder_id) ?? conv.folder_id map.set(groupId, (map.get(groupId) ?? 0) + 1) } return map - }, [conversations, childToParent]) - - const orderedFolderIds = useMemo(() => { + }, [conversations, displayChildToParent]) + + // The reorderable, top-level folder sequence: worktree child folders are + // excluded (they follow their parent, never reorder on their own), so this is + // the source of truth for the custom drag gesture and its `pointerYToTargetIndex` + // math. When "Show worktrees" is off this is ALSO the display order; when on, + // `orderedFolderIds` below splices each repo's worktree children back in. + const reorderableFolderIds = useMemo(() => { const folderIdSet = new Set(folders.map((f) => f.id)) - // Worktree child folders are merged into their parent group, so they get no - // header row of their own. Hidden chat folders never reach this list — the - // backend already excludes them from the open-folder set + // Worktree child folders are excluded here (`childToParent.has` is always the + // raw map, independent of `showWorktrees`). Hidden chat folders never reach + // this list — the backend already excludes them from the open-folder set // (`folder_service::list_open_folder_details`). const isHidden = (id: number) => childToParent.has(id) // During drag we honour the optimistic order so sibling folders shift live @@ -1039,6 +1126,27 @@ export function SidebarConversationList({ return ids }, [folders, dragOrder, childToParent]) + // "Show worktrees" container map: repo id → its open worktree child folder ids + // (sorted). A repo present here renders as a CONTAINER — buildRows nests its + // own sessions (a "root" sub-group) plus each worktree beneath it, and the + // whole subtree is gated on the container's own `folderExpanded` entry. Empty + // when the toggle is off (every folder then renders flat). Depends only on + // `folders` (+ the toggle), never on `conversations`, so status events don't + // rebuild it — preserving the single-status-event re-render budget. + const containerChildren = useMemo( + () => + showWorktrees + ? worktreeChildrenByParent(reorderableFolderIds, folders) + : EMPTY_CONTAINER_CHILDREN, + [showWorktrees, reorderableFolderIds, folders] + ) + // The repo ids that are containers (have ≥1 open worktree child). Drives the + // header render's container/plain distinction (total count, subtree toggle). + const containerRepoIds = useMemo( + () => new Set(containerChildren.keys()), + [containerChildren] + ) + const darkMode = resolvedTheme === "dark" // Flat row model for windowing — the pinned section, the folders section, and @@ -1051,7 +1159,9 @@ export function SidebarConversationList({ buildRows({ pinned, pinnedExpanded, - orderedFolderIds, + // Top-level (reorderable) folders drive the outer order; buildRows nests + // each container's root sub-group + worktrees via `containerChildren`. + orderedFolderIds: reorderableFolderIds, byFolder, folderExpanded, folderTotalCounts, @@ -1062,11 +1172,13 @@ export function SidebarConversationList({ conversationExpanded, childrenByParent, childrenLoading, + containerChildren, + rootGroupCollapsed, }), [ pinned, pinnedExpanded, - orderedFolderIds, + reorderableFolderIds, byFolder, folderExpanded, folderTotalCounts, @@ -1077,6 +1189,8 @@ export function SidebarConversationList({ conversationExpanded, childrenByParent, childrenLoading, + containerChildren, + rootGroupCollapsed, ] ) @@ -1085,7 +1199,7 @@ export function SidebarConversationList({ // without being torn down and re-subscribed. const rowsRef = useRef(rows) rowsRef.current = rows - orderedFolderIdsRef.current = orderedFolderIds + reorderableFolderIdsRef.current = reorderableFolderIds reorderingRef.current = reordering // Sticky-overlay lookup tables, rebuilt only when the flat rows change @@ -1106,15 +1220,22 @@ export function SidebarConversationList({ expandAll() { setFolderExpanded((prev) => { const next: Record = { ...prev } - for (const id of orderedFolderIds) next[id] = true + for (const id of reorderableFolderIds) next[id] = true + // Worktree children (containers only) are separate folder ids. + for (const kids of containerChildren.values()) + for (const id of kids) next[id] = true saveFolderExpanded(next) return next }) + // Expand every container's root sub-group too (session-only state). + setRootGroupCollapsed((prev) => (prev.size === 0 ? prev : new Set())) }, collapseAll() { setFolderExpanded((prev) => { const next: Record = { ...prev } - for (const id of orderedFolderIds) next[id] = false + for (const id of reorderableFolderIds) next[id] = false + for (const kids of containerChildren.values()) + for (const id of kids) next[id] = false saveFolderExpanded(next) return next }) @@ -1170,11 +1291,52 @@ export function SidebarConversationList({ pendingScrollRef.current = true return } - // A worktree conversation is rendered under its parent group, so the - // row's visibility is gated by the parent's expansion — expand the - // display group, not the (never-rendered) child folder id. + // Under "Show worktrees" the conversation may sit inside a container's + // subtree, which is gated top-down: the container (repo root) must be + // expanded first, then — for the repo's OWN sessions — its root + // sub-group. Each step defers the scroll one render (pendingScrollRef). + if (showWorktrees) { + const containerRepoId = childToParent.get(conv.folder_id) ?? null + if ( + containerRepoId != null && + !(folderExpanded[containerRepoId] ?? true) + ) { + setFolderExpanded((prev) => { + const next = { ...prev, [containerRepoId]: true } + saveFolderExpanded(next) + return next + }) + pendingScrollRef.current = true + return + } + // The repo's own conversation lives in the indented root sub-group. + if (containerRepoIds.has(conv.folder_id)) { + if (!(folderExpanded[conv.folder_id] ?? true)) { + setFolderExpanded((prev) => { + const next = { ...prev, [conv.folder_id]: true } + saveFolderExpanded(next) + return next + }) + pendingScrollRef.current = true + return + } + if (rootGroupCollapsed.has(conv.folder_id)) { + setRootGroupCollapsed((prev) => { + const next = new Set(prev) + next.delete(conv.folder_id) + return next + }) + pendingScrollRef.current = true + return + } + } + } + // A worktree conversation is rendered under its display group's header, + // so the row's visibility is gated by that group's expansion. With + // "Show worktrees" off the display group is the parent repo; on, the + // worktree folder renders its own header, so expand the folder itself. const displayFolderId = - childToParent.get(conv.folder_id) ?? conv.folder_id + displayChildToParent.get(conv.folder_id) ?? conv.folder_id if (!(folderExpanded[displayFolderId] ?? true)) { setFolderExpanded((prev) => { const next = { ...prev, [displayFolderId]: true } @@ -1207,7 +1369,11 @@ export function SidebarConversationList({ selectedConversation, conversations, folderExpanded, + displayChildToParent, + showWorktrees, childToParent, + containerRepoIds, + rootGroupCollapsed, pinnedExpanded, foldersExpanded, chatsExpanded, @@ -1221,6 +1387,17 @@ export function SidebarConversationList({ }) }, []) + // Toggle a container repo's "root" sub-group (its own sessions). Session-only, + // so unlike `toggleFolder` there is no persistence write. + const toggleRootGroup = useCallback((repoId: number) => { + setRootGroupCollapsed((prev) => { + const next = new Set(prev) + if (next.has(repoId)) next.delete(repoId) + else next.add(repoId) + return next + }) + }, []) + // Lazily fetch a conversation's direct delegation children into the cache. // Deduped against both the cache and in-flight requests so a re-toggle or the // restore-time guard below can call it freely (idempotent, StrictMode-safe). @@ -1648,7 +1825,7 @@ export function SidebarConversationList({ const state = dragPointerRef.current const surface = dragSurfaceRef.current if (!state || !surface) return - const order = orderedFolderIdsRef.current + const order = reorderableFolderIdsRef.current // The surface's live rect already reflects scroll, so no scrollTop term. const targetIndex = pointerYToTargetIndex( clientY, @@ -1736,7 +1913,7 @@ export function SidebarConversationList({ if (moved < DRAG_THRESHOLD_PX) return state.started = true setDragging(state.folderId) - setDragOrder(orderedFolderIdsRef.current.slice()) + setDragOrder(reorderableFolderIdsRef.current.slice()) } updateDragTarget(event.clientY) maybeAutoscroll(event.clientY) @@ -1871,6 +2048,16 @@ export function SidebarConversationList({ ) } + // Total sessions across a container repo and all its worktrees — the count + // shown on the container header (its own sessions live in the root sub-group, + // so the bare `byFolder` count would understate the repo family). + const containerTotalCount = (repoId: number): number => { + let total = byFolder.get(repoId)?.length ?? 0 + const kids = containerChildren.get(repoId) + if (kids) for (const kid of kids) total += byFolder.get(kid)?.length ?? 0 + return total + } + const folderHeaderElement = ( folderId: number, opts: { @@ -1879,23 +2066,50 @@ export function SidebarConversationList({ grip: boolean onToggle?: (folderId: number) => void suppressed?: boolean + /** Render as the container repo's own-sessions "root" sub-group (FolderRoot + * glyph, indented, session-only collapse) rather than the repo header. */ + rootGroup?: boolean } ) => { const folderEntry = folderIndex.get(folderId) + const isRootGroup = opts.rootGroup ?? false + // A worktree child header (only under "Show worktrees"): indented, FolderGit2 + // glyph, branch label. Keyed off `childToParent` so it matches exactly which + // folders buildRows nests — an orphan worktree (parent closed) is neither a + // worktree child nor a container here, so it renders as a plain top-level + // folder, consistent with its slot. + const isWorktree = + !isRootGroup && showWorktrees && childToParent.has(folderId) + // A container repo (has ≥1 open worktree): plain repo glyph but a total count + // spanning the whole family. Its own sessions render in the root sub-group. + const isContainer = + !isRootGroup && showWorktrees && containerRepoIds.has(folderId) + const variant = isRootGroup ? "root" : isWorktree ? "worktree" : "repo" + const depth = isRootGroup || isWorktree ? 1 : 0 + const count = isContainer + ? containerTotalCount(folderId) + : (byFolder.get(folderId)?.length ?? 0) + const expanded = isRootGroup + ? !rootGroupCollapsed.has(folderId) + : opts.collapsed + ? false + : (folderExpanded[folderId] ?? true) return ( ) } @@ -1953,7 +2170,10 @@ export function SidebarConversationList({ row.folderId, folderHeaderElement(row.folderId, { dragging: dragging === row.folderId, - grip: true, + // Worktree child headers follow their parent and never reorder on + // their own, so they are not drag initiators (no grip). Only + // reorderable top-level repos keep the grip. + grip: !(showWorktrees && childToParent.has(row.folderId)), // While this folder's sticky overlay is showing, the overlay is the // accessible control; make the (occluded) in-list copy inert so it is // not a duplicate tab stop / announcement. @@ -1961,16 +2181,46 @@ export function SidebarConversationList({ }) ) } + if (row.kind === "root-group") { + // A container repo's own-sessions sub-group. Shares the repo id (for its + // bucket / count / theme) but is its own row kind with its own toggle, and + // is never draggable (it follows the container). + return themeWrap( + row.folderId, + folderHeaderElement(row.folderId, { + dragging: false, + grip: false, + rootGroup: true, + }) + ) + } if (row.kind === "empty") { + // A worktree / root sub-group's empty hint is indented one level so its + // text lines up under the (depth-1) sub-group's sessions, matching the + // header. A plain folder's hint stays at depth 0. + const nested = + showWorktrees && + (childToParent.has(row.folderId) || containerRepoIds.has(row.folderId)) + const depth = nested ? 1 : 0 return themeWrap( row.folderId, + // Full row height (h-[2rem], the fixed virtua item size) so the container + // connector spine stays continuous THROUGH an empty sub-group ("no + // conversations") instead of breaking at a shorter box. The ancestor rail + // spans this row; it renders nothing at depth 0 (a plain folder has no + // spine).
- {row.totalConversationCount === 0 - ? t("emptyFolderHint") - : t("noUnfinishedConversations")} + + + {row.totalConversationCount === 0 + ? t("emptyFolderHint") + : t("noUnfinishedConversations")} +
) } @@ -2015,9 +2265,11 @@ export function SidebarConversationList({ ) } const conv = row.conversation - // Worktree child folders render under their parent group, so theme the row - // by the display group (parent) for a unified look. - const groupId = childToParent.get(conv.folder_id) ?? conv.folder_id + // Theme the row by its display group. With "Show worktrees" off a worktree + // conversation renders under its parent group (themed as the parent); on, it + // renders under its own worktree header (themed as itself). `displayChildToParent` + // captures both — empty when the toggle is on. + const groupId = displayChildToParent.get(conv.folder_id) ?? conv.folder_id return themeWrap( groupId, { if (row.kind === "section") return `section-${row.section}` if (row.kind === "folder") return `folder-${row.folderId}` + if (row.kind === "root-group") return `rootgroup-${row.folderId}` if (row.kind === "empty") return `empty-${row.folderId}` if (row.kind === "chats-empty") return "chats-empty" if (row.kind === "folders-empty") return "folders-empty" @@ -2121,11 +2374,14 @@ export function SidebarConversationList({ )} > {dragging !== null ? ( - // Drag surface: every folder collapsed to its header so any - // folder (even one that was virtualized off-screen) is a valid - // drop target. Non-virtualized — folder counts are small. + // Drag surface: every reorderable (top-level) folder collapsed + // to its header so any folder (even one virtualized off-screen) + // is a valid drop target. Worktree children are excluded — they + // aren't independently reorderable, and their presence would + // break the `pointerYToTargetIndex` fixed-height row math. + // Non-virtualized — folder counts are small.
- {orderedFolderIds.map((folderId) => ( + {reorderableFolderIds.map((folderId) => (
{themeWrap( folderId, diff --git a/src/components/layout/sidebar.test.tsx b/src/components/layout/sidebar.test.tsx index 542c090a3..d3d4eaaf6 100644 --- a/src/components/layout/sidebar.test.tsx +++ b/src/components/layout/sidebar.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render } from "@testing-library/react" +import { fireEvent, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { NextIntlClientProvider } from "next-intl" import { beforeEach, describe, expect, it, vi } from "vitest" @@ -13,6 +14,9 @@ const spies = vi.hoisted(() => ({ 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). + listProps: null as { showWorktrees?: boolean } | null, })) const mockState = vi.hoisted(() => ({ activeFolder: { id: 7, path: "/x" } as { id: number; path: string } | null, @@ -21,7 +25,10 @@ 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 }) => { + spies.listProps = props + return null + }, })) vi.mock("@/contexts/sidebar-context", () => ({ useSidebarContext: () => ({ isOpen: true, toggle: vi.fn() }), @@ -126,3 +133,40 @@ describe("Sidebar — fixed New chat / Search region", () => { expect(spies.openNewConversationTab).not.toHaveBeenCalled() }) }) + +describe("Sidebar — Show worktrees toggle", () => { + beforeEach(() => { + localStorage.clear() + spies.listProps = null + mockState.activeFolder = { id: 7, path: "/x" } + }) + + it("defaults Show worktrees off and threads it to the conversation list", () => { + renderSidebar() + expect(spies.listProps?.showWorktrees).toBe(false) + }) + + it("hydrates Show worktrees from localStorage and threads it down", () => { + localStorage.setItem("workspace:sidebar-show-worktrees", "true") + renderSidebar() + // Hydration runs in a mount effect (flushed by render's act), which flips the + // state and re-renders the (stubbed) list with the persisted value. + expect(spies.listProps?.showWorktrees).toBe(true) + }) + + it("toggling the funnel item persists the choice and threads it down", async () => { + const user = userEvent.setup() + renderSidebar() + expect(spies.listProps?.showWorktrees).toBe(false) + + 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( + "true" + ) + expect(spies.listProps?.showWorktrees).toBe(true) + }) +}) diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index af240c975..ebe12a77d 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, @@ -131,6 +133,7 @@ export function Sidebar() { const leftReserve = leftChromeReserve(platformIsMac && isDesktop(), zoomLevel) const [showCompleted, setShowCompleted] = useState(false) + const [showWorktrees, setShowWorktrees] = useState(false) const [sortMode, setSortMode] = useState("created") const [sectionOrder, setSectionOrder] = useState("folders-first") @@ -155,6 +158,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 +168,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 +333,12 @@ export function Sidebar() { > {t("showCompleted")} + + {t("showWorktrees")} + {t("sortBy")} 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/sidebar-view-mode-storage.ts b/src/lib/sidebar-view-mode-storage.ts index ba2afcb42..75c157337 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" @@ -101,6 +102,29 @@ 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 off — the historical flattened layout. */ +export function loadShowWorktrees(): boolean { + if (typeof window === "undefined") return false + try { + const raw = localStorage.getItem(SHOW_WORKTREES_KEY) + if (raw === "true") return true + } catch { + /* ignore */ + } + return false +} + +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 { From 44f94da38cb062380fa9552c9d7453e8cea04285 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 23 Jul 2026 19:47:35 +0800 Subject: [PATCH 7/8] feat(git-log): remember the branch and author filter per folder Persist the commits tab's selected branch and author to local storage, scoped per folder, and restore them the next time that folder's tab is opened. A restored branch that no longer exists in the repository falls back to the all-branches view. --- .../layout/aux-panel-git-log-tab.tsx | 147 ++++++++++++++---- 1 file changed, 119 insertions(+), 28 deletions(-) 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 } From 71a35e1e09ab21fb80449d681f3812c028808705 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Thu, 23 Jul 2026 20:16:19 +0800 Subject: [PATCH 8/8] feat(sidebar): enable "Show completed" and "Show worktree folders" by default Both view-option toggles now default to on for a fresh workspace; a user who explicitly unchecks either is still respected (a stored "false" keeps it off). --- src/components/layout/sidebar.test.tsx | 56 +++++++++++++++++++------- src/components/layout/sidebar.tsx | 7 +++- src/lib/sidebar-view-mode-storage.ts | 15 ++++--- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/src/components/layout/sidebar.test.tsx b/src/components/layout/sidebar.test.tsx index d3d4eaaf6..1c7be4fa4 100644 --- a/src/components/layout/sidebar.test.tsx +++ b/src/components/layout/sidebar.test.tsx @@ -15,8 +15,11 @@ const spies = vi.hoisted(() => ({ 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). - listProps: null as { showWorktrees?: boolean } | null, + // 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, @@ -25,7 +28,10 @@ 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: (props: { showWorktrees?: boolean }) => { + SidebarConversationList: (props: { + showWorktrees?: boolean + showCompleted?: boolean + }) => { spies.listProps = props return null }, @@ -134,30 +140,31 @@ describe("Sidebar — fixed New chat / Search region", () => { }) }) -describe("Sidebar — Show worktrees toggle", () => { +describe("Sidebar — Show worktree folders toggle", () => { beforeEach(() => { localStorage.clear() spies.listProps = null mockState.activeFolder = { id: 7, path: "/x" } }) - it("defaults Show worktrees off and threads it to the conversation list", () => { + it("defaults Show worktree folders on and threads it to the conversation list", () => { renderSidebar() - expect(spies.listProps?.showWorktrees).toBe(false) + expect(spies.listProps?.showWorktrees).toBe(true) }) - it("hydrates Show worktrees from localStorage and threads it down", () => { - localStorage.setItem("workspace:sidebar-show-worktrees", "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), which flips the - // state and re-renders the (stubbed) list with the persisted value. - expect(spies.listProps?.showWorktrees).toBe(true) + // 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 persists the choice and threads it down", async () => { + it("toggling the funnel item off persists the choice and threads it down", async () => { const user = userEvent.setup() renderSidebar() - expect(spies.listProps?.showWorktrees).toBe(false) + // Default on with a cleared store. + expect(spies.listProps?.showWorktrees).toBe(true) await user.click(screen.getByRole("button", { name: "View options" })) await user.click( @@ -165,8 +172,27 @@ describe("Sidebar — Show worktrees toggle", () => { ) expect(localStorage.getItem("workspace:sidebar-show-worktrees")).toBe( - "true" + "false" ) - expect(spies.listProps?.showWorktrees).toBe(true) + 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 ebe12a77d..52aa8edff 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -132,8 +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) - const [showWorktrees, setShowWorktrees] = 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") diff --git a/src/lib/sidebar-view-mode-storage.ts b/src/lib/sidebar-view-mode-storage.ts index 75c157337..40195110a 100644 --- a/src/lib/sidebar-view-mode-storage.ts +++ b/src/lib/sidebar-view-mode-storage.ts @@ -82,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 { @@ -104,16 +107,18 @@ 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 off — the historical flattened layout. */ + * 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 false + 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 false + return true } export function saveShowWorktrees(value: boolean): void {