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: 4 additions & 2 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
chasemakes marked this conversation as resolved.
event.preventDefault();
window.setTitle(environment.displayName);
});
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 6 additions & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,12 @@
white-space: nowrap;
}
</style>
<title>T3 Code (Beta)</title>
<!-- vite.config.ts stamps the web build's channel into this title. -->
<title>T3 Code</title>
<script>
// Desktop's preload injects branding before page scripts, so the window starts on its channel.
document.title = window.desktopBridge?.getAppBranding?.()?.displayName ?? document.title;
</script>
</head>
<body>
<div id="root">
Expand Down
75 changes: 61 additions & 14 deletions apps/web/src/appInit.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -165,7 +212,7 @@ export function startAppServices(): AppRouter {
installDesktopPasteAsText(window.desktopBridge, window);

startClientAppearance();
startDocumentTitle();
startDocumentTitle(router);
startEnvironmentThemeRepaint();
startBrowserChromeThemeSync(router);
startDefaultThemeAdoption();
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/branding.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`;
}
15 changes: 10 additions & 5 deletions apps/web/src/branding.ts
Original file line number Diff line number Diff line change
@@ -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") {
Expand All @@ -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 });
Expand Down
24 changes: 24 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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>${title}</title>`);
},
};
}

// Allow Tailnet hosts for sharing; Tailscale-controlled DNS prevents attacker rebinding.
const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "")
.split(",")
Expand All @@ -265,6 +288,7 @@ export default defineConfig(() => {
},
assetsInclude: ["**/*.wasm"],
plugins: [
bootstrapTitlePlugin(),
devCompressionPlugin(),
chunkCycleGuardPlugin(),
builtDev
Expand Down
Loading