From 5799c89a0399dc425ba62ab6f5af6afac844b435 Mon Sep 17 00:00:00 2001 From: chasemakes <27943491+chasemakes@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:49:28 -0700 Subject: [PATCH 1/2] fix(clients): show the right app name and thread context The window title flashed "T3 Code (Alpha)" on every channel before the real name landed, and never said what you were working on, so window switchers, browser tabs, and time trackers only ever saw the app name. index.html now ships a neutral title. A Vite plugin stamps the web build's channel into it from the same stage-label logic branding.ts uses, and desktop swaps in its preload-injected branding before first paint. The app-start title service reads the open thread from the route and titles the window `project / thread` on desktop, with ` - ` added in browser tabs, and the app name with no thread open. The desktop window now keeps the renderer's title instead of resetting it to the app name, falling back only for blank titles. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.ts | 6 +- apps/web/index.html | 7 ++- apps/web/src/appInit.tsx | 75 +++++++++++++++++++----- apps/web/src/branding.logic.ts | 35 +++++++++++ apps/web/src/branding.ts | 15 +++-- apps/web/vite.config.ts | 24 ++++++++ 6 files changed, 140 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 292a3540d18bf..cb39e95861977 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -692,7 +692,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); }); @@ -775,7 +778,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 ccb7a8b82000e..71bcfe278662b 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"; import * as Schema from "effect/Schema"; @@ -22,7 +27,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, @@ -48,6 +53,7 @@ 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"; @@ -55,7 +61,7 @@ import { startWorkspaceFocus } from "./lib/workspaceFocus"; 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 { @@ -117,19 +123,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); } /** @@ -180,7 +227,7 @@ export function startAppServices(): AppRouter { installDesktopPasteAsText(window.desktopBridge, window); startClientAppearance(); - startDocumentTitle(); + startDocumentTitle(router); startHostEnvironmentLookup(); startEnvironmentThemeRepaint(); startBrowserChromeThemeSync(router); 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 05b4fa6a8f9da..a4d6d8d5e16ac 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 217294de3762c..33942d3c68eb0 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); @@ -246,6 +247,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>${title}`); + }, + }; +} + // Allow Tailnet hosts for sharing; Tailscale-controlled DNS prevents attacker rebinding. const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") .split(",") @@ -266,6 +289,7 @@ export default defineConfig(() => { }, assetsInclude: ["**/*.wasm"], plugins: [ + bootstrapTitlePlugin(), devCompressionPlugin(), chunkCycleGuardPlugin(), builtDev From 66b01b5ac017269864dca8040b042b92dbc47fea Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 8 Nov 2026 23:18:37 +0000 Subject: [PATCH 2/2] fix(clients): keep guest, hidden-thread, and desktop startup titles right The title service no longer overwrites the guest share page's title, follows the open thread's own shell so renames reach threads hidden by a tag filter, and desktop titles the page before the stamped web title parses so Electron never reports the web channel to the window. --- apps/web/index.html | 10 ++++++---- apps/web/src/appInit.tsx | 28 +++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 13769473ff4e0..1a8bc02e19c9d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -668,12 +668,14 @@ white-space: nowrap; } - - T3 Code + + T3 Code
diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 71bcfe278662b..105a9030b10e5 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -134,8 +134,12 @@ function readOpenThreadRef(router: AppRouter): ScopedThreadRef | null { /** Names the open thread and its project in the window title, or the app when none is open. */ function startDocumentTitle(router: AppRouter): void { + const readOnGuestPage = () => router.state.matches.some((match) => match.routeId === "/guest"); let openThreadRef = readOpenThreadRef(router); + let onGuestPage = readOnGuestPage(); const write = () => { + // GuestPage titles the tab after the shared thread. + if (onGuestPage) return; const thread = openThreadRef === null ? null @@ -160,20 +164,38 @@ function startDocumentTitle(router: AppRouter): void { threadTitle: thread?.title ?? null, desktop: isElectron, }); - // Thread shells change whenever any thread does; most changes leave the title alone. + // Projects change whenever any project does; most changes leave the title alone. if (document.title !== title) document.title = title; }; + // The open thread's own shell, since the shell lists skip threads hidden by a tag filter. + let unsubscribeOpenThread = () => {}; + const watchOpenThread = () => { + unsubscribeOpenThread(); + unsubscribeOpenThread = + openThreadRef === null + ? () => {} + : appAtomRegistry.subscribe( + environmentThreadShells.quietThreadShellAtom(openThreadRef), + write, + ); + }; // 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))) { + const nextOnGuestPage = readOnGuestPage(); + if ( + nextOnGuestPage === onGuestPage && + (next && scopedThreadKey(next)) === (openThreadRef && scopedThreadKey(openThreadRef)) + ) { return; } openThreadRef = next; + onGuestPage = nextOnGuestPage; + watchOpenThread(); write(); }; + watchOpenThread(); appAtomRegistry.subscribe(primaryServerConfigAtom, write, { immediate: true }); - appAtomRegistry.subscribe(environmentThreadShells.quietThreadShellsAtom, write); appAtomRegistry.subscribe(environmentProjects.projectsAtom, write); router.subscribe("onResolved", updateOpenThread); useComposerDraftStore.subscribe(updateOpenThread);