дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
perf(agents): switch back to a long conversation without rebuilding it (#1710)
Agent task tabs now stay mounted while another tab is active (the five most recently used per pane). A hidden one skips rendering with content-visibility: hidden, which keeps its layout and scroll offset, so showing it again costs a few milliseconds instead of building and laying out the whole conversation. Hidden tabs don't mark their task read and close Find. Thinking blocks and tool output now sit in a wrapper that skips off-screen layout like other entries; on <details> itself WebKit wouldn't expand them, so they were laid out in full on every resize and reopen.
Цей коміт міститься в:
1 джерело
dadda6cf59
коміт
09c0af504e
8 files changed
+215
-23
No files matched your search
@@ -30,6 +30,19 @@
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
/*
|
||||
* Kept-alive tabs share the content box; hiding one only skips its rendering, not its layout.
|
||||
* Nothing inherited changes (pointer-events, inert): that would restyle the whole conversation.
|
||||
*/
|
||||
.kept {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.keptHidden {
|
||||
content-visibility: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
.host {
|
||||
flex: 1;
|
||||
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
getFileDecoration,
|
||||
prepareMenuGroups,
|
||||
textEditorWrapperFor,
|
||||
EditorShownProvider,
|
||||
useContributions,
|
||||
useFileDecorations,
|
||||
useEditorDocumentRevision,
|
||||
useTextEditorWrappers,
|
||||
type EditorInput,
|
||||
type EditorKindContribution,
|
||||
} from "@/workbench/contributions";
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { EmptyEditor } from "@/workbench/EditorArea";
|
||||
@@ -276,6 +278,7 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
|
||||
if (!pane) return null;
|
||||
const active = pane.tabs.find((t) => t.id === pane.active) ?? null;
|
||||
const kept = keptTabs(pane.tabs, pane.mru, kinds);
|
||||
|
||||
const onFocus = () => {
|
||||
setContext("editorFocus", true);
|
||||
@@ -355,7 +358,14 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
aria-labelledby={active ? `editor-${paneId}-tab-${active.id}` : undefined}
|
||||
ref={contentRef}
|
||||
>
|
||||
{active ? <PaneContent paneId={paneId} tab={active} tabsVisible={!hidden} /> : <EmptyEditor />}
|
||||
{kept.map((tab) => (
|
||||
<KindEditor key={tab.id} tab={tab} shown={tab === active} kept />
|
||||
))}
|
||||
{!active ? (
|
||||
<EmptyEditor />
|
||||
) : (
|
||||
!kept.includes(active) && <PaneContent paneId={paneId} tab={active} tabsVisible={!hidden} />
|
||||
)}
|
||||
{dropZone && <div className={clsx(styles.dropZone, styles[`drop-${dropZone}`])} aria-hidden="true" />}
|
||||
</div>
|
||||
</section>
|
||||
@@ -370,7 +380,6 @@ function virtualTooltip(input: EditorInput): string | null {
|
||||
}
|
||||
|
||||
function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; tabsVisible: boolean }) {
|
||||
const kinds = useContributions((s) => s.editorKinds);
|
||||
const crumbs = !useDistractionFree((s) => s.active);
|
||||
const wrappers = useTextEditorWrappers((s) => s.wrappers);
|
||||
if (isTextInput(tab.input)) {
|
||||
@@ -402,12 +411,41 @@ function PaneContent({ paneId, tab, tabsVisible }: { paneId: string; tab: Tab; t
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <KindEditor key={tab.id} tab={tab} shown />;
|
||||
}
|
||||
|
||||
/** Tabs of a pane kept mounted while hidden: the most recently used ones of `keepAlive` kinds. */
|
||||
const KEPT_TABS = 5;
|
||||
|
||||
function keptTabs(tabs: Tab[], mru: string[], kinds: Map<string, EditorKindContribution>): Tab[] {
|
||||
const recent = mru
|
||||
.map((id) => tabs.find((t) => t.id === id))
|
||||
.filter((t): t is Tab => !!t && !!kinds.get(t.input.kind)?.keepAlive)
|
||||
.slice(0, KEPT_TABS);
|
||||
// In tab order, so switching tabs doesn't move their DOM (which would lose scroll offsets).
|
||||
return tabs.filter((t) => recent.includes(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* `shown`: the pane's visible tab, which focusing the editor area reaches. A hidden `kept` tab
|
||||
* keeps its layout (content-visibility), so showing it again needn't lay it out from scratch;
|
||||
* its content can't be focused or hit while skipped.
|
||||
*/
|
||||
function KindEditor({ tab, shown, kept = false }: { tab: Tab; shown: boolean; kept?: boolean }) {
|
||||
const kinds = useContributions((s) => s.editorKinds);
|
||||
const kind = kinds.get(tab.input.kind);
|
||||
if (!kind) return <EmptyState icon="file" title={tx`No editor for “${tab.input.kind}”`} />;
|
||||
const Component = kind.component;
|
||||
return (
|
||||
<div className={styles.kind} data-editor-focus="" tabIndex={-1} key={tab.id}>
|
||||
<Component input={tab.input} />
|
||||
<div
|
||||
className={clsx(styles.kind, kept && styles.kept, !shown && styles.keptHidden)}
|
||||
data-editor-focus={shown ? "" : undefined}
|
||||
tabIndex={-1}
|
||||
aria-hidden={shown ? undefined : true}
|
||||
>
|
||||
<EditorShownProvider value={shown}>
|
||||
<Component input={tab.input} />
|
||||
</EditorShownProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { registerEditorKind, type EditorInput } from "@/workbench/contributions";
|
||||
import { useTabs } from "../controller";
|
||||
import type { Tab } from "../model/tabs";
|
||||
import { EditorAreaView } from "./EditorArea";
|
||||
|
||||
vi.mock("../controller", async (original) => ({
|
||||
...(await original<object>()),
|
||||
editorController: () => ({ publishContext: vi.fn(), tabMenu: () => [] }),
|
||||
}));
|
||||
vi.mock("./TextEditorHost", () => ({
|
||||
TextEditorHost: () => <input aria-label="Source" />,
|
||||
}));
|
||||
vi.mock("./FindBar", () => ({ FindBar: () => null }));
|
||||
vi.mock("./Banner", () => ({
|
||||
EditorBanners: () => null,
|
||||
VirtualBanner: () => null,
|
||||
}));
|
||||
vi.mock("./StatusItems", () => ({ CodeBreadcrumbs: () => null }));
|
||||
|
||||
const mounts: string[] = [];
|
||||
function Kept({ input }: { input: EditorInput }) {
|
||||
const name = input.key;
|
||||
useState(() => mounts.push(name));
|
||||
return <div data-testid={name}>{name}</div>;
|
||||
}
|
||||
const disposers = [
|
||||
registerEditorKind({
|
||||
kind: "kept",
|
||||
component: Kept,
|
||||
title: (i) => i.key,
|
||||
keepAlive: true,
|
||||
}),
|
||||
registerEditorKind({ kind: "plain", component: Kept, title: (i) => i.key }),
|
||||
];
|
||||
|
||||
/** Shown, or kept mounted behind the shown tab (its rendering skipped). */
|
||||
const shown = (id: string) => !screen.getByTestId(id).closest("[aria-hidden]");
|
||||
|
||||
const tab = (id: string, kind: string): Tab => ({
|
||||
id,
|
||||
input: { kind, key: id },
|
||||
preview: false,
|
||||
pinned: false,
|
||||
});
|
||||
const original = useTabs.getState();
|
||||
|
||||
function show(tabs: Tab[], active: string) {
|
||||
act(() =>
|
||||
useTabs.setState((s) => {
|
||||
const mru = [active, ...(s.panes.p1?.mru ?? []).filter((id) => id !== active && tabs.some((t) => t.id === id))];
|
||||
return {
|
||||
layout: { kind: "pane", paneId: "p1" },
|
||||
panes: { p1: { id: "p1", tabs, active, mru } },
|
||||
activePane: "p1",
|
||||
closed: [],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mounts.length = 0;
|
||||
useTabs.setState(original, true);
|
||||
});
|
||||
afterAll(() => disposers.forEach((d) => d()));
|
||||
|
||||
describe("keep-alive editor kinds", () => {
|
||||
it("keep their recent tabs mounted and hidden while another tab is active", () => {
|
||||
const tabs = [tab("a", "kept"), tab("b", "kept"), tab("c", "plain")];
|
||||
show(tabs, "a");
|
||||
render(<EditorAreaView />);
|
||||
show(tabs, "b");
|
||||
show(tabs, "c");
|
||||
expect(shown("a")).toBe(false);
|
||||
expect(shown("b")).toBe(false);
|
||||
expect(shown("c")).toBe(true);
|
||||
show(tabs, "a");
|
||||
show(tabs, "c");
|
||||
// Only the plain tab is built again; the kept ones are shown as they were.
|
||||
expect(mounts).toEqual(["a", "b", "c", "c"]);
|
||||
expect(screen.queryAllByTestId("c")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("let the least recently used kept tabs go beyond the limit", () => {
|
||||
const tabs = Array.from({ length: 7 }, (_, i) => tab(`k${i}`, "kept"));
|
||||
show(tabs, "k0");
|
||||
render(<EditorAreaView />);
|
||||
for (const t of tabs) show(tabs, t.id);
|
||||
expect(screen.queryByTestId("k0")).toBeNull();
|
||||
expect(screen.queryByTestId("k1")).toBeNull();
|
||||
expect(shown("k2")).toBe(false);
|
||||
expect(shown("k6")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -157,7 +157,10 @@
|
||||
.stream:focus {
|
||||
outline: none;
|
||||
}
|
||||
/* Skip off-screen output layout; editable messages must repaint immediately when focus/content changes. */
|
||||
/*
|
||||
* Skip off-screen output layout; editable messages must repaint immediately when focus/content changes.
|
||||
* Thinking and tool output sit in a <div>: on <details> itself WebKit wouldn't expand them.
|
||||
*/
|
||||
.stream > :not(.message):not(details) {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-block-size: auto 80px;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/** An agent task as an editor tab: the conversation, a composer and the task's changed files. */
|
||||
import {
|
||||
Fragment,
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
@@ -15,7 +17,7 @@ import type { AgentChange, AgentEntry, AgentImage, AgentLimit, AgentRetry, Agent
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { call, commands, onIdeEvent } from "@/platform/ipc";
|
||||
import { basename, relativeDir, useProject } from "@/platform/project";
|
||||
import { editorService, notifications, type EditorInput } from "@/workbench/contributions";
|
||||
import { editorService, notifications, useEditorShown, type EditorInput } from "@/workbench/contributions";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
@@ -105,15 +107,17 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
if (!useAgents.getState().entries.has(id)) void loadEntries(id).catch(() => undefined);
|
||||
void refreshChanges(id);
|
||||
}, [id]);
|
||||
// A kept-alive tab stays mounted behind the shown one: it isn't read there, nor searched.
|
||||
const shown = useEditorShown();
|
||||
useEffect(() => {
|
||||
if (!docked || !changesOpen) return markVisible(id);
|
||||
}, [id, docked, changesOpen]);
|
||||
if (shown && (!docked || !changesOpen)) return markVisible(id);
|
||||
}, [id, docked, changesOpen, shown]);
|
||||
useEffect(() => {
|
||||
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 openSubagent = useCallback((sub: string) => setPath((p) => [...p, sub]), []);
|
||||
const [finding, setFinding] = useState(false);
|
||||
const [findFocus, setFindFocus] = useState(0);
|
||||
const [session] = useState<FocusedSession>(() => ({
|
||||
@@ -157,7 +161,7 @@ function TaskSession({ id, docked }: { id: string; docked: boolean }) {
|
||||
entries={viewEntries ?? null}
|
||||
subagent={view ? (cards.at(-1) ?? null) : null}
|
||||
onOpenSubagent={openSubagent}
|
||||
finding={finding}
|
||||
finding={finding && shown}
|
||||
findFocus={findFocus}
|
||||
onCloseFind={() => setFinding(false)}
|
||||
/>
|
||||
@@ -441,9 +445,11 @@ function Stream({ task, entries, subagent, onOpenSubagent, finding, findFocus, o
|
||||
useEffect(() => {
|
||||
if (start > 0) startTransition(() => setShown((n) => n + ENTRY_BATCH));
|
||||
}, [start]);
|
||||
// Hidden behind another tab, it has no layout to scroll; shown again, it follows the end again.
|
||||
const visible = useEditorShown();
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || entries === null) return;
|
||||
if (!el || entries === null || !visible) return;
|
||||
if (position.current.stick) el.scrollTop = el.scrollHeight;
|
||||
else if (!restored.current) el.scrollTop = position.current.top;
|
||||
restored.current = true;
|
||||
@@ -726,7 +732,8 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
think: "todo",
|
||||
};
|
||||
|
||||
function Entry({
|
||||
/** Memoized: the earlier entries rendered behind a reopened conversation leave the rest alone. */
|
||||
const Entry = memo(function Entry({
|
||||
task,
|
||||
entry,
|
||||
turn,
|
||||
@@ -747,12 +754,14 @@ function Entry({
|
||||
return <Markdown text={entry.text} cwd={task.cwd} />;
|
||||
case "thought":
|
||||
return (
|
||||
<details className={styles.thought}>
|
||||
<summary>
|
||||
<T>Thinking</T>
|
||||
</summary>
|
||||
<div className={styles.thoughtText}>{entry.text}</div>
|
||||
</details>
|
||||
<div>
|
||||
<details className={styles.thought}>
|
||||
<summary>
|
||||
<T>Thinking</T>
|
||||
</summary>
|
||||
<div className={styles.thoughtText}>{entry.text}</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
case "tool":
|
||||
return <ToolEntry task={task} entry={entry} />;
|
||||
@@ -801,7 +810,7 @@ function Entry({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function UserMessage({
|
||||
task,
|
||||
@@ -1049,10 +1058,12 @@ function ToolEntry({ task, entry }: { task: AgentTask; entry: Extract<AgentEntry
|
||||
);
|
||||
if (!entry.output) return <div className={styles.tool}>{head}</div>;
|
||||
return (
|
||||
<details className={styles.toolDetails}>
|
||||
<summary className={styles.tool}>{head}</summary>
|
||||
<pre className={styles.output}>{entry.output}</pre>
|
||||
</details>
|
||||
<div>
|
||||
<details className={styles.toolDetails}>
|
||||
<summary className={styles.tool}>{head}</summary>
|
||||
<pre className={styles.output}>{entry.output}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,8 @@ export function activate(): () => void {
|
||||
icon: () => "toolwindow-agents",
|
||||
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
|
||||
restorable: true,
|
||||
// Rebuilding a long conversation on every switch back is slow.
|
||||
keepAlive: true,
|
||||
}),
|
||||
registerEditorKind({
|
||||
kind: HOME_KIND,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { EditorShownProvider } from "@/workbench/contributions";
|
||||
import type { AgentChange, AgentEntry, AgentTask } from "@/bindings";
|
||||
import { AgentsView } from "./AgentsView";
|
||||
import { SessionEditor } from "./SessionEditor";
|
||||
@@ -179,6 +180,20 @@ describe("a pending permission request in the conversation", () => {
|
||||
});
|
||||
|
||||
describe("session state across tasks and hosts", () => {
|
||||
it("leaves a task unread while its kept-alive tab is hidden, and reads it once shown", () => {
|
||||
vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
useAgents.setState({ unread: new Set(["A"]) });
|
||||
const session = (shown: boolean) => (
|
||||
<EditorShownProvider value={shown}>
|
||||
<SessionEditor input={input("A")} />
|
||||
</EditorShownProvider>
|
||||
);
|
||||
const { rerender } = render(session(false));
|
||||
expect(useAgents.getState().unread.has("A")).toBe(true);
|
||||
rerender(session(true));
|
||||
expect(useAgents.getState().unread.has("A")).toBe(false);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -122,6 +122,12 @@ export interface EditorKindContribution {
|
||||
restorable?: boolean;
|
||||
/** Can remain visible in a window without a project (for example, an external Git log). */
|
||||
standalone?: boolean;
|
||||
/**
|
||||
* Its recently used tabs stay mounted, hidden, while another tab is active: switching back
|
||||
* shows them as they were instead of building them again (long agent conversations). A hidden
|
||||
* tab keeps running; `useEditorShown` tells it whether it is the one shown.
|
||||
*/
|
||||
keepAlive?: boolean;
|
||||
/** Last tab showing this input closed (including split tabs and tab-limit eviction). */
|
||||
onClose?(input: EditorInput): void;
|
||||
/**
|
||||
@@ -318,6 +324,12 @@ export const registerWorkbenchBanner = (c: WorkbenchBannerContribution) => regis
|
||||
export const registerToolWindow = (c: ToolWindowContribution) => register("toolWindows", c.id, c);
|
||||
export const registerStatusItem = (c: StatusItemContribution) => register("statusItems", c.id, c);
|
||||
export const registerOverlay = (c: OverlayContribution) => register("overlays", c.id, c);
|
||||
|
||||
const EditorShown = createContext(true);
|
||||
export const EditorShownProvider = EditorShown.Provider;
|
||||
/** Whether this editor tab is the one its pane shows (a `keepAlive` tab stays mounted when it isn't). */
|
||||
export const useEditorShown = () => useContext(EditorShown);
|
||||
|
||||
export const registerEditorKind = (c: EditorKindContribution) => register("editorKinds", c.kind, c);
|
||||
export const registerTitleItem = (c: TitleItemContribution) => register("titleItems", c.id, c);
|
||||
export const registerServiceView = (c: ServiceViewContribution) => register("serviceViews", c.id, c);
|
||||
|
||||
Reference in new issue
Заблокувати користувача