Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
});
window.on("resize", scheduleBoundsPersist);
window.on("move", scheduleBoundsPersist);
Expand Down Expand Up @@ -660,7 +661,6 @@ export const make = Effect.gen(function* () {
}
clearDevelopmentLoadRetry();
developmentLoadRetryIndex = 0;
window.setTitle(environment.displayName);
});
window.webContents.on(
"did-fail-load",
Expand Down
12 changes: 11 additions & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,17 @@
object-fit: contain;
}
</style>
<title>T3 Code (Alpha)</title>
<title>T3 Code</title>
Comment thread
cursor[bot] marked this conversation as resolved.
<script>
(() => {
// The desktop preload injects branding before page scripts run, so the
// native window title carries the stage label from first paint.
const branding = window.desktopBridge?.getAppBranding?.();
if (branding?.displayName) {
document.title = branding.displayName;
}
})();
</script>
</head>
<body>
<div id="root">
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/branding.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
58 changes: 58 additions & 0 deletions apps/web/src/branding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)");
});
});
7 changes: 7 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]);
Expand Down
21 changes: 15 additions & 6 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({
Comment thread
chasemakes marked this conversation as resolved.
appDisplayName: resolveServerBackedAppDisplayName({
baseName: APP_BASE_NAME,
fallbackDisplayName: APP_DISPLAY_NAME,
fallbackStageLabel: APP_STAGE_LABEL,
primaryServerVersion,
}),
projectTitle,
threadTitle,
desktop: isElectron,
});

useEffect(() => {
Expand Down Expand Up @@ -245,6 +253,7 @@ function RootRouteErrorView({ error, reset }: ErrorComponentProps) {

return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4 py-10 text-foreground sm:px-6">
<DocumentTitleSync />
<div className="pointer-events-none absolute inset-0 opacity-80">
<div className="absolute inset-x-0 top-0 h-44 bg-[radial-gradient(44rem_16rem_at_top,color-mix(in_srgb,var(--color-red-500)_16%,transparent),transparent)]" />
<div className="absolute inset-0 bg-[linear-gradient(145deg,color-mix(in_srgb,var(--background)_90%,var(--color-black))_0%,var(--background)_55%)]" />
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/windowTitleStore.ts
Original file line number Diff line number Diff line change
@@ -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<WindowTitleContext>()(() => ({
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 });
}
Loading