diff --git a/src/editor/core/view/EditorArea.module.css b/src/editor/core/view/EditorArea.module.css
index 593cd1ac4..722677b2a 100644
--- a/src/editor/core/view/EditorArea.module.css
+++ b/src/editor/core/view/EditorArea.module.css
@@ -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;
diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx
index 2dccfd9e4..ff0906f6b 100644
--- a/src/editor/core/view/EditorArea.tsx
+++ b/src/editor/core/view/EditorArea.tsx
@@ -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 ? : }
+ {kept.map((tab) => (
+
+ ))}
+ {!active ? (
+
+ ) : (
+ !kept.includes(active) &&
+ )}
{dropZone &&
}
@@ -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 ;
+}
+
+/** 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): 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 ;
const Component = kind.component;
return (
-
-
+
+
+
+
);
}
diff --git a/src/editor/core/view/keptTabs.test.tsx b/src/editor/core/view/keptTabs.test.tsx
new file mode 100644
index 000000000..cff0d8a1b
--- /dev/null
+++ b/src/editor/core/view/keptTabs.test.tsx
@@ -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
;
return (
-
- {head}
- {entry.output}
-
+
+
+ {head}
+ {entry.output}
+
+
);
}
diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts
index 7b29e6cfd..83b03b812 100644
--- a/src/features/agents/index.ts
+++ b/src/features/agents/index.ts
@@ -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,
diff --git a/src/features/agents/sessionState.test.tsx b/src/features/agents/sessionState.test.tsx
index 4a8261400..3b060267a 100644
--- a/src/features/agents/sessionState.test.tsx
+++ b/src/features/agents/sessionState.test.tsx
@@ -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) => (
+
+
+
+ );
+ 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");
diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts
index 8f09062c8..8ee878f1e 100644
--- a/src/workbench/contributions.ts
+++ b/src/workbench/contributions.ts
@@ -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);