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。",