fix: reopen long agent conversations quickly and pack the Git log graph (#1453)

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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-05 12:50:20 +08:00
1 джерело bb4e2db47f
коміт 4a90f124c6
10 files changed
+196 -29

No files matched your search

+14
Переглянути файл
@@ -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<string, string>();
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.
+37 -13
Переглянути файл
@@ -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<HTMLDivElement>(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 ? (
<Spinner />
) : (
entries.map((entry, i) => (
<Entry
key={i}
task={task}
entry={entry}
turn={turns?.get(i) ?? null}
turnCount={turns?.size ?? 0}
onOpenSubagent={onOpenSubagent}
/>
))
entries
.slice(start)
.map((entry, i) => (
<Entry
key={start + i}
task={task}
entry={entry}
turn={turns?.get(start + i) ?? null}
turnCount={turns?.size ?? 0}
onOpenSubagent={onOpenSubagent}
/>
))
)}
{subagent && subagentWorking(subagent.status) && (
<div className={styles.working}>
+29 -4
Переглянути файл
@@ -14,14 +14,16 @@ const backend = vi.hoisted(() => ({
entries: new Map<string, AgentEntry[]>(),
changes: [] as AgentChange[],
answers: [] as Array<[string, number, string | null]>,
fetched: [] as string[],
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
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(<SessionEditor input={input("A")} />);
expect(await screen.findByText("Answer A")).toBeInTheDocument();
first.unmount();
render(<SessionEditor input={input("A")} />);
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(<SessionEditor input={input("A")} />);
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 {
+13 -9
Переглянути файл
@@ -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() {
);
}}
>
<div className={clsx(styles.row, styles.columnHeader)} style={{ minWidth }}>
{opts.showRepository && useVcs.getState().roots.length > 1 && (
<span className={styles.colRepository}>{tx("Repository")}</span>
)}
<span className={styles.colSubject}>{tx("Commit")}</span>
{columns.map((column) => (
<LogColumnHeader key={column.id} column={column} columns={columns} options={opts} />
))}
</div>
{opts.showColumnHeaders && (
<div className={clsx(styles.row, styles.columnHeader)} style={{ minWidth }}>
{opts.showRepository && useVcs.getState().roots.length > 1 && (
<span className={styles.colRepository}>{tx("Repository")}</span>
)}
<span className={styles.colSubject}>{tx("Commit")}</span>
{columns.map((column) => (
<LogColumnHeader key={column.id} column={column} columns={columns} options={opts} />
))}
</div>
)}
{!commits.length && !loading && finished && (
<div className={styles.message}>{headCommit ? tx("No commits match the filters") : tx("No commits yet")}</div>
)}
+59 -1
Переглянути файл
@@ -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);
}
});
});
+7
Переглянути файл
@@ -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(<LogView />);
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(<LogView />);
const author = await screen.findByRole("separator", { name: "Resize Author column" });
fireEvent.keyDown(author, { key: "ArrowRight" });
+24
Переглянути файл
@@ -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<number>) => 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
+3
Переглянути файл
@@ -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,
+9 -2
Переглянути файл
@@ -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 };
}
+1
Переглянути файл
@@ -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。",