дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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.
Цей коміт міститься в:
1 джерело
bb4e2db47f
коміт
4a90f124c6
10 files changed
+196
-29
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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。",
|
||||
|
||||
Reference in new issue
Заблокувати користувача