From 4a90f124c69f3308970f3cb0d5667eed74b9a478 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:50:20 +0800 Subject: [PATCH] fix: reopen long agent conversations quickly and pack the Git log graph (#1453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent conversations: an editor tab mounts only while it is active, so switching back to a task rebuilt the whole conversation, parsed and sanitized every message again, and fetched all entries again, which replaced them and rendered everything a second time. Loaded entries are already kept current by the task's events, so they are no longer fetched again; rendered Markdown is cached by text; and a conversation opened at its end renders its last 40 entries first and the earlier ones in batches behind it (all at once when a scroll position or Find needs them). Git log: with branch-aware layout (#1076) a commit's lane was its branch's global layout index, so a repository with many branches drew lanes dozens of columns apart, joined by diagonals across the row. Each row boundary and node level is now packed side by side in layout order; adjacent rows share their boundary, so lines stay joined. The column header row is hidden by default and can be shown from View Options › Columns › Column Headers, where columns are resized and reordered. --- src/features/agents/Markdown.tsx | 14 +++++ src/features/agents/SessionEditor.tsx | 50 ++++++++++++---- src/features/agents/sessionState.test.tsx | 33 ++++++++-- src/features/vcs/GitLog.tsx | 22 ++++--- src/features/vcs/__tests__/graph.test.ts | 60 ++++++++++++++++++- .../vcs/__tests__/logUiFeatures.test.tsx | 7 +++ src/features/vcs/graph.ts | 24 ++++++++ src/features/vcs/logOptions.ts | 3 + src/features/vcs/logPresentation.ts | 11 +++- src/platform/locales/text/zh.json | 1 + 10 files changed, 196 insertions(+), 29 deletions(-) diff --git a/src/features/agents/Markdown.tsx b/src/features/agents/Markdown.tsx index a0da3f508..6ba05f6e1 100644 --- a/src/features/agents/Markdown.tsx +++ b/src/features/agents/Markdown.tsx @@ -17,7 +17,21 @@ import styles from "./Session.module.css"; const marked = new Marked({ gfm: true, breaks: true }); +/** Rendered messages by text, least recently used first: reopening a conversation renders it again. */ +const rendered = new Map(); +const RENDERED_MAX = 500; + export function markdownHtml(text: string): string { + let html = rendered.get(text); + if (html === undefined) { + html = renderHtml(text); + if (rendered.size >= RENDERED_MAX) rendered.delete(rendered.keys().next().value!); + } else rendered.delete(text); + rendered.set(text, html); + return html; +} + +function renderHtml(text: string): string { const html = marked.parse(text, { async: false }) as string; // Replies can quote repository content. Styles would reach the whole IDE, and the click // handlers below trust `data-copy` / `data-path`, which only `enhance` may set. diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index b74a2b4cc..02f91fa1d 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -1,5 +1,14 @@ /** An agent task as an editor tab: the conversation, a composer and the task's changed files. */ -import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { + Fragment, + startTransition, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, AgentTask, AgentUsage } from "@/bindings"; @@ -92,14 +101,17 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) { useShallow((s) => path.map((sub, i) => findSubagent(s.entries.get(entriesKey(id, path[i - 1] ?? null)), sub))), ); useEffect(() => { - void loadEntries(id).catch(() => undefined); + // Loaded entries are kept current by the task's events: reopening the tab needn't fetch them again. + if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined); void refreshChanges(id); }, [id]); useEffect(() => { if (!docked || !changesOpen) return markVisible(id); }, [id, docked, changesOpen]); useEffect(() => { - if (view) void loadEntries(id, view).catch(() => undefined); + if (view && !useAgents.getState().entries.has(entriesKey(id, view))) { + void loadEntries(id, view).catch(() => undefined); + } }, [id, view]); const openSubagent = (sub: string) => setPath((p) => [...p, sub]); const [finding, setFinding] = useState(false); @@ -412,6 +424,9 @@ interface StreamProps { onCloseFind: () => void; } +/** Entries rendered at a time when a conversation opens at its end. */ +const ENTRY_BATCH = 40; + function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, onCloseFind }: StreamProps) { const ref = useRef(null); useSelectionBoundary(ref); @@ -419,6 +434,13 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o const positions = useAgents.getState().scrollPositions; const position = useRef(positions.get(key) ?? { top: 0, stick: true }); const restored = useRef(false); + // Opened at the end, the last entries come first and the earlier ones follow in batches: + // reopening a long conversation paints at once. A kept offset needs them all. + const [shown, setShown] = useState(() => (positions.get(key)?.stick === false ? Infinity : ENTRY_BATCH)); + const start = finding || !entries ? 0 : Math.max(0, entries.length - shown); + useEffect(() => { + if (start > 0) startTransition(() => setShown((n) => n + ENTRY_BATCH)); + }, [start]); useLayoutEffect(() => { const el = ref.current; if (!el || entries === null) return; @@ -470,16 +492,18 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o {entries === null ? ( ) : ( - entries.map((entry, i) => ( - - )) + entries + .slice(start) + .map((entry, i) => ( + + )) )} {subagent && subagentWorking(subagent.status) && (
diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx index 9d0a361f4..4a8261400 100644 --- a/src/features/agents/sessionState.test.tsx +++ b/src/features/agents/sessionState.test.tsx @@ -14,14 +14,16 @@ const backend = vi.hoisted(() => ({ entries: new Map(), changes: [] as AgentChange[], answers: [] as Array<[string, number, string | null]>, + fetched: [] as string[], })); vi.mock("@/platform/ipc", async (original) => ({ ...(await original()), commands: { - agentEntries: async (id: string, sub: string | null) => ({ - status: "ok", - data: backend.entries.get(sub ? `${id}/${sub}` : id) ?? [], - }), + agentEntries: async (id: string, sub: string | null) => { + const key = sub ? `${id}/${sub}` : id; + backend.fetched.push(key); + return { status: "ok", data: backend.entries.get(key) ?? [] }; + }, agentChanges: async () => ({ status: "ok", data: backend.changes }), vcsBranchInfo: async () => ({ status: "ok", data: { head: "main" } }), agentRespond: async (task: string, permission: number, option: string | null) => { @@ -177,6 +179,29 @@ describe("a pending permission request in the conversation", () => { }); describe("session state across tasks and hosts", () => { + it("fetches a conversation once and shows it again from the store when its tab reopens", async () => { + const entries = new Map(useAgents.getState().entries); + entries.delete("A"); + useAgents.setState({ entries }); + backend.fetched = []; + const first = render(); + expect(await screen.findByText("Answer A")).toBeInTheDocument(); + first.unmount(); + render(); + expect(screen.getByText("Answer A")).toBeInTheDocument(); + await act(async () => {}); + expect(backend.fetched).toEqual(["A"]); + }); + + it("opens a long conversation at its end and renders the earlier entries after it", async () => { + const entries = Array.from({ length: 150 }, (_, i): AgentEntry => ({ type: "agent", text: `Reply ${i}` })); + useAgents.setState({ entries: new Map(useAgents.getState().entries).set("A", entries) }); + render(); + expect(screen.getByText("Reply 149")).toBeInTheDocument(); + expect(await screen.findByText("Reply 0")).toBeInTheDocument(); + expect(stream().children).toHaveLength(150); + }); + it("returns to the task conversation when the docked task changes after opening a subagent", async () => { const dispose = registerActions(taskActions); try { diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index 89edb7bb5..51bcb64ac 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -1091,6 +1091,8 @@ export function viewOptionsMenu(o: LogOptions): MenuItem[] { toggle("Date", o, "showDate"), toggle("Hash", o, "showHash"), toggle("Signature", o, "showSignature"), + { kind: "separator" }, + toggle("Column Headers", o, "showColumnHeaders"), ], }, { kind: "separator" }, @@ -1604,15 +1606,17 @@ function CommitList() { ); }} > -
- {opts.showRepository && useVcs.getState().roots.length > 1 && ( - {tx("Repository")} - )} - {tx("Commit")} - {columns.map((column) => ( - - ))} -
+ {opts.showColumnHeaders && ( +
+ {opts.showRepository && useVcs.getState().roots.length > 1 && ( + {tx("Repository")} + )} + {tx("Commit")} + {columns.map((column) => ( + + ))} +
+ )} {!commits.length && !loading && finished && (
{headCommit ? tx("No commits match the filters") : tx("No commits yet")}
)} diff --git a/src/features/vcs/__tests__/graph.test.ts b/src/features/vcs/__tests__/graph.test.ts index 97464ef7e..5b575aa50 100644 --- a/src/features/vcs/__tests__/graph.test.ts +++ b/src/features/vcs/__tests__/graph.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computeGraph, straightenLaneChanges } from "../graph"; +import { compactLanes, computeGraph, straightenLaneChanges, type GraphRow } from "../graph"; const c = (id: string, ...parents: string[]) => ({ id, parents }); @@ -106,3 +106,61 @@ describe("straightenLaneChanges", () => { expect(rows[2]!.up[0]!.fromX).toBeUndefined(); }); }); + +describe("compactLanes", () => { + const row = (lane: number, up: [number, number][], down: [number, number][], width = 9): GraphRow => ({ + lane, + color: 0, + up: up.map(([from, to]) => ({ from, to, color: 0 })), + down: down.map(([from, to]) => ({ from, to, color: 0 })), + width, + }); + + it("packs the lanes of each row side by side, in their order", () => { + // Lanes 2 and 7 (branch layout leaves the slots between them empty); 7 merges into 2. + const rows = compactLanes([ + row( + 7, + [], + [ + [2, 2], + [7, 7], + ], + ), + row( + 2, + [ + [2, 2], + [7, 2], + ], + [[2, 2]], + ), + row(2, [[2, 2]], []), + ]); + expect(rows.map((r) => r.lane)).toEqual([1, 0, 0]); + expect(rows.map((r) => r.width)).toEqual([2, 2, 1]); + expect(rows[0]!.down.map((s) => [s.from, s.to])).toEqual([ + [0, 0], + [1, 1], + ]); + expect(rows[1]!.up.map((s) => [s.from, s.to])).toEqual([ + [0, 0], + [1, 0], + ]); + }); + + it("keeps lines joined across rows when a lane is freed in between", () => { + const laid = computeGraph([ + { id: "a", parents: ["c"], refs: ["a"] }, + { id: "b", parents: ["c"], refs: ["b"] }, + { id: "x", parents: ["c"], refs: ["x"] }, + { id: "c", parents: [], refs: ["main"] }, + ]).rows; + const rows = compactLanes(laid); + for (let r = 0; r + 1 < rows.length; r++) { + const below = new Set(rows[r]!.down.map((s) => s.to)); + expect(new Set(rows[r + 1]!.up.map((s) => s.from))).toEqual(below); + expect(Math.max(...below)).toBe(below.size - 1); + } + }); +}); diff --git a/src/features/vcs/__tests__/logUiFeatures.test.tsx b/src/features/vcs/__tests__/logUiFeatures.test.tsx index 2159cf57b..378055ff1 100644 --- a/src/features/vcs/__tests__/logUiFeatures.test.tsx +++ b/src/features/vcs/__tests__/logUiFeatures.test.tsx @@ -149,7 +149,14 @@ describe("log controls", () => { expect(screen.getByRole("status")).toHaveTextContent("b"); }); + it("hides the column headers unless View Options shows them", async () => { + render(); + await screen.findByRole("listbox", { name: "Commits" }); + expect(screen.queryByRole("separator", { name: "Resize Author column" })).toBeNull(); + }); + it("resizes and reorders columns using the keyboard", async () => { + useLogOptions.setState({ showColumnHeaders: true }); render(); const author = await screen.findByRole("separator", { name: "Resize Author column" }); fireEvent.keyDown(author, { key: "ArrowRight" }); diff --git a/src/features/vcs/graph.ts b/src/features/vcs/graph.ts index 1f160952f..b6e84dd53 100644 --- a/src/features/vcs/graph.ts +++ b/src/features/vcs/graph.ts @@ -506,6 +506,30 @@ export function hideLongEdges(rows: readonly GraphRow[], minLength = LONG_EDGE): return out; } +/** + * Packs each row's lanes side by side, in their order: lanes laid out by branch leave + * gaps (one per branch) and hidden long edges free theirs. Each row boundary and each + * node level is packed on its own; adjacent rows share the boundary, so lines stay joined. + */ +export function compactLanes(rows: readonly GraphRow[]): GraphRow[] { + const ranks = (lanes: Iterable) => new Map([...new Set(lanes)].sort((a, b) => a - b).map((l, i) => [l, i])); + const boundaries = Array.from({ length: rows.length + 1 }, (_, r) => + ranks([...(rows[r]?.up.map((s) => s.from) ?? []), ...(rows[r - 1]?.down.map((s) => s.to) ?? [])]), + ); + return rows.map((row, r) => { + const node = ranks([row.lane, ...row.up.map((s) => s.to), ...row.down.map((s) => s.from)]); + const top = boundaries[r]!; + const bottom = boundaries[r + 1]!; + return { + ...row, + lane: node.get(row.lane)!, + up: row.up.map((s) => ({ ...s, from: top.get(s.from)!, to: node.get(s.to)! })), + down: row.down.map((s) => ({ ...s, from: node.get(s.from)!, to: bottom.get(s.to)! })), + width: Math.max(node.size, top.size, bottom.size), + }; + }); +} + /** * Draws a lane change as one straight diagonal across the row boundary, like IDEA: a half-row * segment that changes lane and the vertical half of the neighbouring row that continues it meet diff --git a/src/features/vcs/logOptions.ts b/src/features/vcs/logOptions.ts index 4e594a831..835b4a096 100644 --- a/src/features/vcs/logOptions.ts +++ b/src/features/vcs/logOptions.ts @@ -22,6 +22,8 @@ export interface LogOptions { showDate: boolean; showHash: boolean; showSignature: boolean; + /** The row naming the columns, where they are resized and reordered. */ + showColumnHeaders: boolean; highlightMine: boolean; highlightMerges: boolean; highlightCurrentBranch: boolean; @@ -56,6 +58,7 @@ export const DEFAULT_LOG_OPTIONS: LogOptions = { showDate: true, showHash: false, showSignature: false, + showColumnHeaders: false, highlightMine: true, highlightMerges: true, highlightCurrentBranch: true, diff --git a/src/features/vcs/logPresentation.ts b/src/features/vcs/logPresentation.ts index c4471c990..77500a369 100644 --- a/src/features/vcs/logPresentation.ts +++ b/src/features/vcs/logPresentation.ts @@ -1,4 +1,11 @@ -import { collapseLinear, computeGraph, hideLongEdges, linearizeMerges, straightenLaneChanges } from "./graph"; +import { + collapseLinear, + compactLanes, + computeGraph, + hideLongEdges, + linearizeMerges, + straightenLaneChanges, +} from "./graph"; import { commitKey, graphCommit, type RepoCommit } from "./multiRoot"; import type { LogOptions } from "./logOptions"; @@ -26,6 +33,6 @@ export function presentLogGraph( const byId = new Map(all.map((c) => [commitKey(c), c])); const commits = graph.map((c) => byId.get(c.id)!); const laid = computeGraph(graph).rows; - const rows = straightenLaneChanges(options.longEdges ? laid : hideLongEdges(laid)); + const rows = straightenLaneChanges(compactLanes(options.longEdges ? laid : hideLongEdges(laid))); return { commits, graph, rows, linear, collapsed }; } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 32cf99260..1de8973f8 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -473,6 +473,7 @@ "Color scheme preview": "配色方案预览", "Color theme": "颜色主题", "Column": "列", + "Column Headers": "列标题", "Column Selection Mode": "列选择模式", "Columns": "列", "Comma-separated column numbers, e.g. 80, 100.": "逗号分隔的列号,例如 80, 100。",