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