diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index ab5fc8be26a92..995484d0bfde3 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -660,7 +660,10 @@ export const make = Effect.gen(function* () {
runFork(zoomWindow(window, direction));
});
- window.on("page-title-updated", (event) => {
+ // The renderer titles the window after the open thread; only a blank title
+ // falls back to the app name.
+ window.on("page-title-updated", (event, title) => {
+ if (title.trim() !== "") return;
event.preventDefault();
window.setTitle(environment.displayName);
});
@@ -737,7 +740,6 @@ export const make = Effect.gen(function* () {
}
clearDevelopmentLoadRetry();
developmentLoadRetryIndex = 0;
- window.setTitle(environment.displayName);
if (environment.platform === "darwin") syncMacosWindowButtons(window);
});
window.webContents.on(
diff --git a/apps/web/index.html b/apps/web/index.html
index a7a5e7450b3de..13769473ff4e0 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -668,7 +668,12 @@
white-space: nowrap;
}
-
T3 Code (Beta)
+
+ T3 Code
+
diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx
index ae7ad7c7df0f6..c342091c003ea 100644
--- a/apps/web/src/appInit.tsx
+++ b/apps/web/src/appInit.tsx
@@ -1,4 +1,9 @@
-import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import {
+ scopedThreadKey,
+ scopeProjectRef,
+ scopeThreadRef,
+} from "@t3tools/client-runtime/environment";
+import type { ScopedThreadRef } from "@t3tools/contracts";
import type { ClientSettings } from "@t3tools/contracts/settings";
import { createBrowserHistory, createHashHistory } from "@tanstack/react-router";
@@ -21,7 +26,7 @@ import { startSlowRpcRequestToasts } from "./components/SlowRpcRequestToastCoord
import { startThreadNotifications } from "./components/ThreadNotificationCoordinator";
import { startGuideRunNotifications } from "./components/pullRequest/guide/PullRequestGuideRunCoordinator";
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 {
subscribeComposerDraftModelReconciliation,
@@ -45,13 +50,14 @@ import {
import { startProviderAuthDelivery } from "./providerAuthDelivery";
import { getRouter, type AppRouter } from "./router";
import { appAtomRegistry } from "./rpc/atomRegistry";
+import { environmentProjects } from "./state/projects";
import { primaryServerConfigAtom } from "./state/server";
import { environmentShell } from "./state/shell";
import { installChatRouteCommands } from "./shortcuts/chatRouteCommands";
import { installCommandPaletteCommands } from "./shortcuts/commandPaletteCommands";
import { registerOpenFavoriteEditorCommand } from "./components/chat/OpenInPickerShortcut";
import { environmentThreadShells } from "./state/threads";
-import { resolveThreadRouteTarget } from "./threadRoutes";
+import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "./threadRoutes";
/** Writes appearance settings onto the document, touching only the groups that changed. */
function startClientAppearance(): void {
@@ -102,19 +108,60 @@ function startClientAppearance(): void {
apply();
}
-function startDocumentTitle(): void {
- appAtomRegistry.subscribe(
- primaryServerConfigAtom,
- (config) => {
- document.title = resolveServerBackedAppDisplayName({
+function readOpenThreadRef(router: AppRouter): ScopedThreadRef | null {
+ const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {});
+ const draft =
+ target?.kind === "draft"
+ ? useComposerDraftStore.getState().getDraftSession(target.draftId)
+ : null;
+ return resolveActiveThreadRouteRef(target, draft);
+}
+
+/** Names the open thread and its project in the window title, or the app when none is open. */
+function startDocumentTitle(router: AppRouter): void {
+ let openThreadRef = readOpenThreadRef(router);
+ const write = () => {
+ const thread =
+ openThreadRef === null
+ ? null
+ : appAtomRegistry.get(environmentThreadShells.quietThreadShellAtom(openThreadRef));
+ const project =
+ thread === null
+ ? null
+ : appAtomRegistry.get(
+ environmentProjects.projectAtom(
+ scopeProjectRef(thread.environmentId, thread.projectId),
+ ),
+ );
+ const title = resolveWindowTitle({
+ appDisplayName: resolveServerBackedAppDisplayName({
baseName: APP_BASE_NAME,
fallbackDisplayName: APP_DISPLAY_NAME,
fallbackStageLabel: APP_STAGE_LABEL,
- primaryServerVersion: config?.environment.serverVersion ?? null,
- });
- },
- { immediate: true },
- );
+ primaryServerVersion:
+ appAtomRegistry.get(primaryServerConfigAtom)?.environment.serverVersion ?? null,
+ }),
+ projectTitle: project?.title ?? null,
+ threadTitle: thread?.title ?? null,
+ desktop: isElectron,
+ });
+ // Thread shells change whenever any thread does; most changes leave the title alone.
+ if (document.title !== title) document.title = title;
+ };
+ // The draft store changes on every keystroke; only a new open thread matters here.
+ const updateOpenThread = () => {
+ const next = readOpenThreadRef(router);
+ if ((next && scopedThreadKey(next)) === (openThreadRef && scopedThreadKey(openThreadRef))) {
+ return;
+ }
+ openThreadRef = next;
+ write();
+ };
+ appAtomRegistry.subscribe(primaryServerConfigAtom, write, { immediate: true });
+ appAtomRegistry.subscribe(environmentThreadShells.quietThreadShellsAtom, write);
+ appAtomRegistry.subscribe(environmentProjects.projectsAtom, write);
+ router.subscribe("onResolved", updateOpenThread);
+ useComposerDraftStore.subscribe(updateOpenThread);
}
/**
@@ -165,7 +212,7 @@ export function startAppServices(): AppRouter {
installDesktopPasteAsText(window.desktopBridge, window);
startClientAppearance();
- startDocumentTitle();
+ startDocumentTitle(router);
startEnvironmentThemeRepaint();
startBrowserChromeThemeSync(router);
startDefaultThemeAdoption();
diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts
index 126fb77062481..6490edbb4a05c 100644
--- a/apps/web/src/branding.logic.ts
+++ b/apps/web/src/branding.logic.ts
@@ -11,6 +11,21 @@ export function formatAppDisplayName(input: {
return `${input.baseName} (${input.stageLabel})`;
}
+export function resolveHostedAppChannelLabel(channel: string | null | undefined): string | null {
+ const normalized = channel?.trim().toLowerCase();
+ return normalized === "nightly" ? "Nightly" : normalized === "latest" ? "Latest" : null;
+}
+
+/** Stage label of a web build. vite.config.ts stamps it into index.html's title too. */
+export function resolveWebAppStageLabel(input: {
+ readonly hostedAppChannel: string | null | undefined;
+ readonly development: boolean;
+}): string {
+ return (
+ resolveHostedAppChannelLabel(input.hostedAppChannel) ?? (input.development ? "Dev" : "Beta")
+ );
+}
+
export function resolveServerBackedAppStageLabel(input: {
readonly primaryServerVersion: string | null | undefined;
readonly fallbackStageLabel: string;
@@ -36,3 +51,23 @@ export function resolveServerBackedAppDisplayName(input: {
? input.fallbackDisplayName
: formatAppDisplayName({ baseName: input.baseName, stageLabel });
}
+
+/**
+ * Names the open thread: `project / thread` on desktop, where the OS already
+ * shows the app, with the app name appended in browser tabs.
+ */
+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.ts b/apps/web/src/branding.ts
index 7d14e2cea858b..3a40028fc10a2 100644
--- a/apps/web/src/branding.ts
+++ b/apps/web/src/branding.ts
@@ -1,5 +1,9 @@
import type { DesktopAppBranding } from "@t3tools/contracts";
-import { formatAppDisplayName } from "./branding.logic";
+import {
+ formatAppDisplayName,
+ resolveHostedAppChannelLabel,
+ resolveWebAppStageLabel,
+} from "./branding.logic";
function readInjectedDesktopAppBranding(): DesktopAppBranding | null {
if (typeof window === "undefined") {
@@ -14,13 +18,14 @@ const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLower
export const HOSTED_APP_CHANNEL =
hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null;
-export const HOSTED_APP_CHANNEL_LABEL =
- HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null;
+export const HOSTED_APP_CHANNEL_LABEL = resolveHostedAppChannelLabel(HOSTED_APP_CHANNEL);
export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code";
export const APP_STAGE_LABEL =
injectedDesktopAppBranding?.stageLabel ??
- HOSTED_APP_CHANNEL_LABEL ??
- (import.meta.env.DEV ? "Dev" : "Beta");
+ resolveWebAppStageLabel({
+ hostedAppChannel: HOSTED_APP_CHANNEL,
+ development: import.meta.env.DEV,
+ });
export const APP_DISPLAY_NAME =
injectedDesktopAppBranding?.displayName ??
formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL });
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 501bac5dba020..5486d9948f318 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -22,6 +22,7 @@ import { loadRepoEnv } from "../../scripts/lib/public-config";
import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses";
import { tailwindPlugins } from "./vite/tailwind";
import { warmBundledDevPlugin } from "./vite/warmBundledDev";
+import { formatAppDisplayName, resolveWebAppStageLabel } from "./src/branding.logic";
const repoEnv = loadRepoEnv();
Object.assign(process.env, repoEnv);
@@ -245,6 +246,28 @@ function chunkCycleGuardPlugin(): Plugin {
};
}
+// Stamps index.html's title with the name branding.ts resolves once the app
+// loads, so a tab opens on the right channel instead of flashing another's.
+// Desktop swaps in its injected branding from an inline script in index.html.
+function bootstrapTitlePlugin(): Plugin {
+ let development = false;
+ return {
+ name: "t3code:bootstrap-title",
+ configResolved(config) {
+ // The same flag Vite exposes to the app as import.meta.env.DEV.
+ development = !config.isProduction;
+ },
+ transformIndexHtml(html) {
+ const stageLabel = resolveWebAppStageLabel({
+ hostedAppChannel: configuredHostedAppChannel,
+ development,
+ });
+ const title = formatAppDisplayName({ baseName: "T3 Code", stageLabel });
+ return html.replace(/
[^<]*<\/title>/, `${title}`);
+ },
+ };
+}
+
// Allow Tailnet hosts for sharing; Tailscale-controlled DNS prevents attacker rebinding.
const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "")
.split(",")
@@ -265,6 +288,7 @@ export default defineConfig(() => {
},
assetsInclude: ["**/*.wasm"],
plugins: [
+ bootstrapTitlePlugin(),
devCompressionPlugin(),
chunkCycleGuardPlugin(),
builtDev