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 }));