fix(editor): keep tab titles in sync with renamed agent tasks (#2062)

Closes #1983

Tab labels read an editor kind's title only when the tab state changed, so renaming an agent task left its open tab with the old title. Agents now signal title changes through the workbench (`notifyTabTitlesChanged`), and tab labels re-render on it; tabs restored before the agent tasks loaded refresh once the tasks arrive.

Co-authored-by: panda-student <[email protected]>
Цей коміт міститься в:
SunZhiQiang authored and GitHub committed 2026-11-08 06:01:40 +08:00
1 джерело ff7473a277
коміт b2d30e0c95
5 files changed
+152 -2

No files matched your search

+5
Переглянути файл
@@ -23,6 +23,7 @@ import {
useContributions,
useFileDecorations,
useEditorDocumentRevision,
useEditorTabTitles,
useTextEditorWrappers,
type EditorInput,
type EditorKindContribution,
@@ -157,6 +158,7 @@ function PaneView({ paneId }: { paneId: string }) {
const isActivePane = useTabs((s) => s.activePane === paneId);
const multiPane = useTabs((s) => s.layout.kind === "split");
const kinds = useContributions((s) => s.editorKinds);
const titleRevision = useEditorTabTitles((s) => s.revision);
const tabs = useMemo(() => pane?.tabs ?? [], [pane]);
const paths = tabs.map((t) => t.input.path ?? "");
const textDirty = useDocMeta(useShallow((s) => paths.map((p) => !!s[p]?.dirty)));
@@ -176,6 +178,8 @@ function PaneView({ paneId }: { paneId: string }) {
showFileIcon ? tabs.flatMap((t) => (t.input.kind === "file" && t.input.path ? [t.input.path] : [])) : [],
);
const items: TabItem[] = useMemo(() => {
// A kind's title may change without the tab state (a renamed agent task).
void titleRevision;
const names = tabs.map((t) => tabTitle(t.input, kinds));
return tabs.map((t, i) => {
const full = names[i]!;
@@ -215,6 +219,7 @@ function PaneView({ paneId }: { paneId: string }) {
fileIcons,
tabs,
kinds,
titleRevision,
dirty,
colors,
tints,
+108
Переглянути файл
@@ -0,0 +1,108 @@
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { notifyTabTitlesChanged, registerEditorKind } 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: () => null }));
vi.mock("./FindBar", () => ({ FindBar: () => null }));
vi.mock("./Banner", () => ({ EditorBanners: () => null, VirtualBanner: () => null }));
vi.mock("./StatusItems", () => ({ CodeBreadcrumbs: () => null }));
/** The title a kind reports; changed behind the tab strip's back, like a renamed task. */
let title = "Alpha";
const disposer = registerEditorKind({
kind: "renamable",
component: () => null,
title: () => title,
document: {
isDirty: () => true,
flush: async () => {},
save: async () => {},
prepareClose: async () => {},
cancelClose: () => {},
discard: async () => {},
close: async () => {},
},
});
const tab: Tab = { id: "t1", input: { kind: "renamable", key: "k" }, preview: false, pinned: false };
const original = useTabs.getState();
function show() {
act(() =>
useTabs.setState({
layout: { kind: "pane", paneId: "p1" },
panes: { p1: { id: "p1", tabs: [tab], active: tab.id, mru: [tab.id] } },
activePane: "p1",
closed: [],
}),
);
}
const label = () => screen.getByRole("tab").textContent ?? "";
afterEach(() => {
cleanup();
useTabs.setState(original, true);
title = "Alpha";
});
afterAll(() => disposer());
describe("editor tab titles", () => {
it("recomputes a kind's title when the tab-title revision is bumped", () => {
show();
render(<EditorAreaView />);
expect(label()).toContain("Alpha");
// The task was renamed: the kind reports the new title, and the store says so.
title = "Beta";
act(() => notifyTabTitlesChanged());
expect(label()).toContain("Beta");
});
it("leaves unrelated tab state alone", () => {
show();
render(<EditorAreaView />);
const modified = screen.getAllByLabelText("Modified");
const state = useTabs.getState();
title = "Beta";
act(() => notifyTabTitlesChanged());
expect(useTabs.getState()).toBe(state);
expect(screen.getAllByRole("tab")).toHaveLength(1);
expect(label()).not.toContain("Alpha");
expect(screen.getAllByLabelText("Modified")).toEqual(modified);
});
it("refreshes the same input in every split pane", () => {
show();
act(() =>
useTabs.setState({
layout: {
kind: "split",
id: "split",
dir: "row",
children: [
{ kind: "pane", paneId: "p1" },
{ kind: "pane", paneId: "p2" },
],
},
panes: {
...useTabs.getState().panes,
p2: { id: "p2", tabs: [tab], active: tab.id, mru: [tab.id] },
},
}),
);
render(<EditorAreaView />);
expect(screen.getAllByRole("tab")).toHaveLength(2);
title = "Beta";
act(() => notifyTabTitlesChanged());
expect(screen.getAllByRole("tab").every((el) => el.textContent?.includes("Beta"))).toBe(true);
});
});
+3 -1
Переглянути файл
@@ -17,7 +17,7 @@ import type {
import { useProject } from "@/platform/project";
import { defaultTaskProject } from "./project";
import { call, commands, isTauri } from "@/platform/ipc";
import { editorService, notifications, toolWindowService } from "@/workbench/contributions";
import { editorService, notifications, notifyTabTitlesChanged, toolWindowService } from "@/workbench/contributions";
import { confirm } from "@/workbench/messages";
import { bypassDefaults, canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel";
import { withQuote } from "./conversation";
@@ -260,6 +260,7 @@ export async function connect(): Promise<void> {
changes: new Map(),
providers,
});
notifyTabTitlesChanged();
// Restored tabs of tasks deleted meanwhile close, like editors of deleted files.
const editors = editorService();
for (const t of editors.openTabs?.() ?? []) {
@@ -291,6 +292,7 @@ function onEvent(e: AgentEvent) {
case "task": {
const prev = s.tasks.get(e.task.id);
useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) });
if (prev && prev.title !== e.task.title) notifyTabTitlesChanged();
if (e.task.status === "archived") setUnread(e.task.id, false);
else if (attentionChange(prev?.status, e.task.status)) markUnread(e.task.id);
notifyChange(prev, e.task);
+28 -1
Переглянути файл
@@ -1,7 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { AgentEntry, AgentEvent, AgentTask } from "@/bindings";
import { dropOnEditor, useContributions, type EditorInput, type OpenEditorTab } from "@/workbench/contributions";
import {
dropOnEditor,
useContributions,
useEditorTabTitles,
type EditorInput,
type OpenEditorTab,
} from "@/workbench/contributions";
import { AgentsView } from "./AgentsView";
import { AgentsStatus } from "./AgentsStatus";
import { activate } from "./index";
@@ -111,6 +117,27 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("agent tab titles", () => {
it("refreshes restored tab titles when tasks finish loading", async () => {
disconnect();
const revision = useEditorTabTitles.getState().revision;
backend.tasks = [{ ...task("one"), title: "Saved rename" }];
await connect();
expect(useAgents.getState().tasks.get("one")?.title).toBe("Saved rename");
expect(useEditorTabTitles.getState().revision).toBe(revision + 1);
});
it("refreshes titles for user and provider renames, but not unchanged task updates", () => {
const revision = useEditorTabTitles.getState().revision;
emit({ type: "task", task: { ...task("one"), title: "User rename" } });
expect(useEditorTabTitles.getState().revision).toBe(revision + 1);
emit({ type: "task", task: { ...task("one"), title: "User rename", updatedMs: 1 } });
expect(useEditorTabTitles.getState().revision).toBe(revision + 1);
emit({ type: "task", task: { ...task("two", "ready"), title: "Provider title" } });
expect(useEditorTabTitles.getState().revision).toBe(revision + 2);
});
});
describe("agent unread activity", () => {
it("does not mark historical tasks unread, but tracks replies even without loaded entries", () => {
expect(useAgents.getState().unread.size).toBe(0);
+8
Переглянути файл
@@ -168,6 +168,14 @@ export interface EditorDocumentLifecycle {
redo?(input: EditorInput): Promise<void>;
}
/** Bumped by `notifyTabTitlesChanged`; tab labels whose `title(input)` changed re-render. */
export const useEditorTabTitles = create<{ revision: number }>(() => ({ revision: 0 }));
/** An editor kind's tab title may have changed outside the tab state (e.g. a renamed agent task). */
export function notifyTabTitlesChanged() {
useEditorTabTitles.setState((s) => ({ revision: s.revision + 1 }));
}
/** Bumped by `notifyEditorDocumentChanged`; selectors reading `isDirty` re-run on it. */
export const useEditorDocuments = create<{ revision: number }>(() => ({ revision: 0 }));
export const useStandaloneEditor = create<{ title: string | null }>(() => ({ title: null }));