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>${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