From b2d30e0c9539d60a490ba456090fa8b3168211a2 Mon Sep 17 00:00:00 2001 From: SunZhiQiang <71381346+panda-student@users.noreply.github.com> Date: Sat, 7 Nov 2026 14:01:40 -0800 Subject: [PATCH] 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 <71381346+panda-student@users.noreply.github.com> --- src/editor/core/view/EditorArea.tsx | 5 + .../core/view/tabTitleRevision.test.tsx | 108 ++++++++++++++++++ src/features/agents/store.ts | 4 +- src/features/agents/unread.test.tsx | 29 ++++- src/workbench/contributions.ts | 8 ++ 5 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 src/editor/core/view/tabTitleRevision.test.tsx diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index ac8a6286c..13e22533d 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -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, diff --git a/src/editor/core/view/tabTitleRevision.test.tsx b/src/editor/core/view/tabTitleRevision.test.tsx new file mode 100644 index 000000000..2efc4dbf6 --- /dev/null +++ b/src/editor/core/view/tabTitleRevision.test.tsx @@ -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()), + 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(); + 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(); + 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(); + expect(screen.getAllByRole("tab")).toHaveLength(2); + title = "Beta"; + act(() => notifyTabTitlesChanged()); + expect(screen.getAllByRole("tab").every((el) => el.textContent?.includes("Beta"))).toBe(true); + }); +}); diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index da0522e12..1538e384d 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -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 { 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); diff --git a/src/features/agents/unread.test.tsx b/src/features/agents/unread.test.tsx index 4afd1ab91..57cd25a9b 100644 --- a/src/features/agents/unread.test.tsx +++ b/src/features/agents/unread.test.tsx @@ -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); diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 280427d4c..8e77311da 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -168,6 +168,14 @@ export interface EditorDocumentLifecycle { redo?(input: EditorInput): Promise; } +/** 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 }));