Files
multica/apps/web/platform/document-title.ts
d77d676c10 feat(web): name browser tabs after the open workspace page (MUL-6222) (#7012)
Every dashboard tab rendered the root metadata title, so several issues
open side by side were indistinguishable without clicking into each one
(GH #7010).

Web now names the tab from the URL, e.g. `MUL-123: Fix login | Multica`.
The resolution is not new: `WorkspaceDocumentTitle` mounts once in the
dashboard layout and reuses the pure subject parser plus the cache-backed
presentation hook that already name desktop's tabs, so a browser tab and a
desktop tab for one URL read identically, in the user's locale, and a
renamed project or a changed issue title re-titles the tab live. Every
query behind it is cache-only, so it costs no extra request.

The site title and the `%s | Multica` template move next to it and are
imported by the root layout's metadata, so the statically rendered title
and the client-set one cannot drift into two separate separators.

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-15 21:17:30 +08:00

61 lines
2.4 KiB
TypeScript

/**
* The browser tab title contract for the web app.
*
* Two writers produce titles and they must agree: Next.js metadata renders the
* `<title>` for statically known routes (landing, auth), and
* `WorkspaceDocumentTitle` sets `document.title` on workspace routes, whose
* names only exist in the query cache. Both go through the constants here so
* `Issues | Multica` and `Changelog | Multica` can never drift into two
* different separators.
*
* Pure and React-free on purpose: the root layout is a server component and
* imports `SITE_TITLE` / `TITLE_TEMPLATE` for its metadata export.
*/
/** Root fallback — the title of a page that has nothing more specific to say. */
export const SITE_TITLE = "Multica — Project Management for Human + Agent Teams";
/** Appended to every page-specific title. */
export const TITLE_SUFFIX = " | Multica";
/** Next.js `metadata.title.template`; see apps/web/app/layout.tsx. */
export const TITLE_TEMPLATE = `%s${TITLE_SUFFIX}`;
/**
* Longest page name we put in front of the suffix, counted in code points.
*
* Issue titles are unbounded server-side, and the whole string ends up in the
* window title, history entries and bookmarks, not just the tab — a 600-char
* title makes all three unreadable. Browsers truncate the tab itself far
* earlier than this, so the cap only exists to keep those secondary surfaces
* sane; the discriminating part of a title (the issue key) leads, so it always
* survives both truncations.
*/
export const MAX_PAGE_TITLE_LENGTH = 120;
/**
* Clip to {@link MAX_PAGE_TITLE_LENGTH} code points.
*
* Iterating code points rather than slicing UTF-16 units keeps an emoji or any
* other astral character from being cut in half into a replacement glyph — a
* real case here, since issue titles routinely open with one.
*/
function clipTitle(title: string): string {
const codePoints = Array.from(title);
if (codePoints.length <= MAX_PAGE_TITLE_LENGTH) return title;
return `${codePoints.slice(0, MAX_PAGE_TITLE_LENGTH).join("").trimEnd()}…`;
}
/**
* Build the full document title for a page name, e.g.
* `MUL-123: Fix login` → `MUL-123: Fix login | Multica`.
*
* An empty or whitespace-only name falls back to {@link SITE_TITLE} rather than
* rendering a bare ` | Multica`.
*/
export function formatDocumentTitle(pageTitle: string | null | undefined): string {
const trimmed = pageTitle?.trim();
if (!trimmed) return SITE_TITLE;
return `${clipTitle(trimmed)}${TITLE_SUFFIX}`;
}