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