From c310393e14a98aee4ccfbde536d9bad5a7cb7546 Mon Sep 17 00:00:00 2001 From: chasemakes <27943491+chasemakes@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:29:41 -0700 Subject: [PATCH] feat(clients): show project and thread in the window title The desktop window previously cancelled every renderer title change and pinned the static app name, so the window title (and the second line in OS-level window previews) never reflected what the user was working on. The active project and thread titles now flow from ChatView into a small window-title context store, and the root DocumentTitleSync composes document.title as "project / thread" (with the app display name kept as a suffix on the web, where the tab has no separate app-name line). The desktop shell accepts renderer titles and falls back to the app display name when none is set. Titles update live on rename and as generated thread titles stream in. Built with Claude Code (Fable 5). Co-Authored-By: Claude Fable 5 --- apps/desktop/src/window/DesktopWindow.ts | 6 +-- apps/web/index.html | 12 ++++- apps/web/src/branding.logic.ts | 16 +++++++ apps/web/src/branding.test.ts | 58 ++++++++++++++++++++++++ apps/web/src/components/ChatView.tsx | 7 +++ apps/web/src/routes/__root.tsx | 21 ++++++--- apps/web/src/windowTitleStore.ts | 27 +++++++++++ 7 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/windowTitleStore.ts diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 56411711eb6c..9a7a4a841e91 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -584,9 +584,10 @@ export const make = Effect.gen(function* () { } }); - window.on("page-title-updated", (event) => { + window.on("page-title-updated", (event, title) => { event.preventDefault(); - window.setTitle(environment.displayName); + const trimmed = title.trim(); + window.setTitle(trimmed === "" ? environment.displayName : trimmed); }); window.on("resize", scheduleBoundsPersist); window.on("move", scheduleBoundsPersist); @@ -660,7 +661,6 @@ export const make = Effect.gen(function* () { } clearDevelopmentLoadRetry(); developmentLoadRetryIndex = 0; - window.setTitle(environment.displayName); }); window.webContents.on( "did-fail-load", diff --git a/apps/web/index.html b/apps/web/index.html index 8aef3a4286f2..228c3cf756ec 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -453,7 +453,17 @@ object-fit: contain; } - T3 Code (Alpha) + T3 Code +
diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6ab..049f15f7f661 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -36,3 +36,19 @@ export function resolveServerBackedAppDisplayName(input: { ? input.fallbackDisplayName : formatAppDisplayName({ baseName: input.baseName, stageLabel }); } + +export function resolveWindowTitle(input: { + readonly appDisplayName: string; + readonly projectTitle: string | null; + readonly threadTitle: string | null; + readonly desktop: boolean; +}): string { + const threadTitle = input.threadTitle?.trim() ?? ""; + if (threadTitle === "") { + return input.appDisplayName; + } + + const projectTitle = input.projectTitle?.trim() ?? ""; + const context = projectTitle === "" ? threadTitle : `${projectTitle} / ${threadTitle}`; + return input.desktop ? context : `${context} — ${input.appDisplayName}`; +} diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index e1c87bcf0595..d4799617cf39 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, + resolveWindowTitle, } from "./branding.logic"; const originalWindow = globalThis.window; @@ -114,3 +115,60 @@ describe("branding logic", () => { ).toBe("T3 Code (Alpha)"); }); }); + +describe("resolveWindowTitle", () => { + it("falls back to the app display name without an active thread", () => { + expect( + resolveWindowTitle({ + appDisplayName: "T3 Code (Nightly)", + projectTitle: null, + threadTitle: null, + desktop: true, + }), + ).toBe("T3 Code (Nightly)"); + }); + + it("joins project and thread titles on desktop without the app name", () => { + expect( + resolveWindowTitle({ + appDisplayName: "T3 Code (Nightly)", + projectTitle: "acme-web", + threadTitle: "New thread", + desktop: true, + }), + ).toBe("acme-web / New thread"); + }); + + it("keeps the app display name as a suffix on the web", () => { + expect( + resolveWindowTitle({ + appDisplayName: "T3 Code (Alpha)", + projectTitle: "acme-web", + threadTitle: "New thread", + desktop: false, + }), + ).toBe("acme-web / New thread — T3 Code (Alpha)"); + }); + + it("omits the separator without a project title", () => { + expect( + resolveWindowTitle({ + appDisplayName: "T3 Code (Nightly)", + projectTitle: " ", + threadTitle: "Fix login bug", + desktop: true, + }), + ).toBe("Fix login bug"); + }); + + it("ignores whitespace-only thread titles", () => { + expect( + resolveWindowTitle({ + appDisplayName: "T3 Code (Nightly)", + projectTitle: "acme-web", + threadTitle: " ", + desktop: true, + }), + ).toBe("T3 Code (Nightly)"); + }); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a8a71e781fb..84790f155442 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,6 +78,7 @@ import { import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isElectron } from "../env"; +import { clearWindowTitleContext, setWindowTitleContext } from "../windowTitleStore"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; import { @@ -1764,6 +1765,12 @@ function ChatViewContent(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + const activeProjectTitle = activeProject?.title ?? null; + const activeThreadTitle = activeThread?.title ?? null; + useEffect(() => { + setWindowTitleContext({ projectTitle: activeProjectTitle, threadTitle: activeThreadTitle }); + }, [activeProjectTitle, activeThreadTitle]); + useEffect(() => clearWindowTitleContext, []); const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 91381301418e..85a6c4a0eeec 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -11,7 +11,9 @@ import { import { useEffect, useEffectEvent, useRef, useState } from "react"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; -import { resolveServerBackedAppDisplayName } from "../branding.logic"; +import { resolveServerBackedAppDisplayName, resolveWindowTitle } from "../branding.logic"; +import { isElectron } from "../env"; +import { useWindowTitleContextStore } from "../windowTitleStore"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; @@ -197,11 +199,17 @@ function FontAppearanceSync() { function DocumentTitleSync() { const primaryServerVersion = useAtomValue(primaryServerConfigAtom)?.environment.serverVersion ?? null; - const title = resolveServerBackedAppDisplayName({ - baseName: APP_BASE_NAME, - fallbackDisplayName: APP_DISPLAY_NAME, - fallbackStageLabel: APP_STAGE_LABEL, - primaryServerVersion, + const { projectTitle, threadTitle } = useWindowTitleContextStore(); + const title = resolveWindowTitle({ + appDisplayName: resolveServerBackedAppDisplayName({ + baseName: APP_BASE_NAME, + fallbackDisplayName: APP_DISPLAY_NAME, + fallbackStageLabel: APP_STAGE_LABEL, + primaryServerVersion, + }), + projectTitle, + threadTitle, + desktop: isElectron, }); useEffect(() => { @@ -245,6 +253,7 @@ function RootRouteErrorView({ error, reset }: ErrorComponentProps) { return (
+
diff --git a/apps/web/src/windowTitleStore.ts b/apps/web/src/windowTitleStore.ts new file mode 100644 index 000000000000..d179bbcc7e8e --- /dev/null +++ b/apps/web/src/windowTitleStore.ts @@ -0,0 +1,27 @@ +import { create } from "zustand"; + +export interface WindowTitleContext { + projectTitle: string | null; + threadTitle: string | null; +} + +/** Active project/thread context mirrored into document.title by the root layout. */ +export const useWindowTitleContextStore = create()(() => ({ + projectTitle: null, + threadTitle: null, +})); + +export function setWindowTitleContext(context: WindowTitleContext): void { + const current = useWindowTitleContextStore.getState(); + if ( + current.projectTitle === context.projectTitle && + current.threadTitle === context.threadTitle + ) { + return; + } + useWindowTitleContextStore.setState(context); +} + +export function clearWindowTitleContext(): void { + setWindowTitleContext({ projectTitle: null, threadTitle: null }); +}