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
2 changes: 1 addition & 1 deletion apps/web/app/(landing)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { RedirectIfAuthenticated } from "@/features/landing/components/redirect-

export const metadata: Metadata = {
title: {
absolute: "Multica — Project Management for Human + Agent Teams",
absolute: "Project workspace",
},
description:
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/[workspaceSlug]/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { FloatingChat } from "@multica/views/chat";
import { WebNotificationBridge } from "@/components/web-notification-bridge";
import { DashboardPageTitle } from "@/components/dashboard-page-title";

export default function Layout({ children }: { children: React.ReactNode }) {
return (
Expand All @@ -19,6 +20,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</>
}
>
<DashboardPageTitle />
{children}
</DashboardLayout>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { use } from "react";
import { useSearchParams } from "next/navigation";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { PageTitle } from "@/components/page-title";

// Lives at /:slug/attachments/:id/preview — OUTSIDE the (dashboard) group on
// purpose. The dashboard layout adds a left sidebar + top chrome; this page
Expand All @@ -20,6 +21,7 @@ export default function AttachmentPreviewWebPage({

return (
<ErrorBoundary resetKeys={[id]}>
<PageTitle title="Attachment" />
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ export const viewport: Viewport = {
export const metadata: Metadata = {
metadataBase: new URL("https://www.multica.ai"),
title: {
default: "Multica — Project Management for Human + Agent Teams",
template: "%s | Multica",
default: "Project workspace",
template: "%s",
},
description:
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
Expand Down
163 changes: 163 additions & 0 deletions apps/web/components/dashboard-page-title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"use client";

import { useMemo } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { autopilotListOptions } from "@multica/core/autopilots/queries";
import { runtimeListOptions } from "@multica/core/runtimes";
import {
agentListOptions,
memberListOptions,
skillListOptions,
squadListOptions,
} from "@multica/core/workspace/queries";
import { buildRuntimeMachines } from "@multica/views/runtimes";
import { formatEntityPageTitle, formatIssuePageTitle } from "@/lib/page-title";
import { PageTitle } from "./page-title";

const SETTINGS_DEFAULT_TAB = "profile";

function findRuntimeMachine(
machines: ReturnType<typeof buildRuntimeMachines>,
locator: string,
) {
return machines.find(
(candidate) =>
candidate.id === locator ||
candidate.runtimes.some((runtime) => runtime.id === locator),
);
}

type DetailKind =
| "issue"
| "project"
| "agent"
| "autopilot"
| "runtime"
| "skill"
| "squad"
| "member";

interface RouteTitle {
fallback: string;
detail?: { kind: DetailKind; id: string };
}

const SETTINGS_TITLES: Record<string, string> = {
profile: "Profile",
preferences: "Preferences",
shortcuts: "Keyboard shortcuts",
issue: "Issue settings",
chat: "Chat settings",
notifications: "Notifications",
tokens: "Tokens",
workspace: "Workspace",
repositories: "Repositories",
github: "GitHub",
integrations: "Integrations",
labs: "Labs",
members: "Members",
labels: "Labels",
properties: "Properties",
};

export function dashboardRouteTitle(pathname: string, settingsTab: string | null): RouteTitle {
// The first pathname part is the workspace slug for dashboard routes.
const [section, id, nested] = pathname.split("/").filter(Boolean).slice(1);

switch (section) {
case "issues":
return id ? { fallback: "Issue", detail: { kind: "issue", id } } : { fallback: "Issues" };
case "my-issues": return { fallback: "My issues" };
case "projects":
return id ? { fallback: "Project", detail: { kind: "project", id } } : { fallback: "Projects" };
case "agents":
if (id === "new") return { fallback: "New agent" };
return id ? { fallback: "Agent", detail: { kind: "agent", id } } : { fallback: "Agents" };
case "autopilots":
return id ? { fallback: "Autopilot", detail: { kind: "autopilot", id } } : { fallback: "Autopilots" };
case "runtimes":
if (nested === "runtime") return { fallback: "Runtime settings" };
return id ? { fallback: "Runtime", detail: { kind: "runtime", id } } : { fallback: "Runtimes" };
case "skills":
return id ? { fallback: "Skill", detail: { kind: "skill", id } } : { fallback: "Skills" };
case "squads":
return id ? { fallback: "Squad", detail: { kind: "squad", id } } : { fallback: "Squads" };
case "members":
return id ? { fallback: "Member", detail: { kind: "member", id } } : { fallback: "Members" };
case "settings": {
const tabKey =
settingsTab && SETTINGS_TITLES[settingsTab] ? settingsTab : SETTINGS_DEFAULT_TAB;
const tab = SETTINGS_TITLES[tabKey];
return { fallback: tab ? `Settings · ${tab}` : "Settings" };
}
case "inbox": return { fallback: "Inbox" };
case "chat": return { fallback: "Chat" };
case "billing": return { fallback: "Billing" };
case "usage": return { fallback: "Usage" };
default: return { fallback: "Workspace" };
}
}

function entityName(value: unknown): string | undefined {
if (!value || typeof value !== "object") return undefined;
const entity = value as Record<string, unknown>;
for (const key of ["name", "title", "display_name", "label"]) {
const candidate = entity[key];
if (typeof candidate === "string" && candidate.trim()) return candidate;
}
return undefined;
}

/**
* Owns tab titles for the authenticated web shell. The fallback appears on
* navigation; detail queries then add the most useful entity signal.
*/
export function DashboardPageTitle() {
const pathname = usePathname();
const searchParams = useSearchParams();
const workspaceId = useWorkspaceId();
const route = useMemo(
() => dashboardRouteTitle(pathname, searchParams.get("tab")),
[pathname, searchParams],
);
const detail = route.detail;
const isDetail = (kind: DetailKind) => detail?.kind === kind;

const { data: issue } = useQuery({
...issueDetailOptions(workspaceId, isDetail("issue") ? detail?.id ?? "" : ""),
enabled: isDetail("issue"),
});
const { data: project } = useQuery({
...projectDetailOptions(workspaceId, isDetail("project") ? detail?.id ?? "" : ""),
enabled: isDetail("project"),
});
const { data: agents = [] } = useQuery({ ...agentListOptions(workspaceId), enabled: isDetail("agent") });
const { data: autopilots = [] } = useQuery({ ...autopilotListOptions(workspaceId), enabled: isDetail("autopilot") });
const { data: runtimes = [] } = useQuery({ ...runtimeListOptions(workspaceId), enabled: isDetail("runtime") });
const { data: skills = [] } = useQuery({ ...skillListOptions(workspaceId), enabled: isDetail("skill") });
const { data: squads = [] } = useQuery({ ...squadListOptions(workspaceId), enabled: isDetail("squad") });
const { data: members = [] } = useQuery({ ...memberListOptions(workspaceId), enabled: isDetail("member") });

let title = route.fallback;
if (isDetail("issue")) title = formatIssuePageTitle(issue?.identifier, issue?.title);
if (isDetail("project")) title = formatEntityPageTitle("Project", entityName(project));
if (isDetail("agent")) title = formatEntityPageTitle("Agent", entityName(agents.find((agent) => agent.id === detail?.id)));
if (isDetail("autopilot")) title = formatEntityPageTitle("Autopilot", entityName(autopilots.find((autopilot) => autopilot.id === detail?.id)));
if (isDetail("runtime")) {
const locator = detail?.id ?? "";
const machine = findRuntimeMachine(
buildRuntimeMachines(runtimes, { now: Date.now() }),
locator,
);
title = formatEntityPageTitle("Runtime", entityName(machine));
}
if (isDetail("skill")) title = formatEntityPageTitle("Skill", entityName(skills.find((skill) => skill.id === detail?.id)));
if (isDetail("squad")) title = formatEntityPageTitle("Squad", entityName(squads.find((squad) => squad.id === detail?.id)));
if (isDetail("member")) title = formatEntityPageTitle("Member", entityName(members.find((member) => member.user_id === detail?.id)));

return <PageTitle title={title} />;
}
12 changes: 12 additions & 0 deletions apps/web/components/page-title.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"use client";

import { useEffect } from "react";

/** Sets the client-side title for routes whose data is loaded after hydration. */
export function PageTitle({ title }: { title: string }) {
useEffect(() => {
document.title = title;
}, [title]);

return null;
}
32 changes: 32 additions & 0 deletions apps/web/lib/page-title.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
formatEntityPageTitle,
formatIssuePageTitle,
truncatePageTitle,
} from "./page-title";
import { dashboardRouteTitle } from "@/components/dashboard-page-title";

describe("browser page title formatting", () => {
it("keeps the issue identifier first and truncates only the issue title", () => {
const title = formatIssuePageTitle(
"SCA-240",
"Fix browser tab titles so every open issue remains easy to distinguish at a glance",
);

expect(title).toMatch(/^SCA-240 /);
expect(title).toContain("…");
expect(title).not.toMatch(/^Multica\s[-—]/);
});

it("uses concise non-issue route titles without a brand prefix", () => {
expect(formatEntityPageTitle("Settings", "Repositories")).toBe("Settings · Repositories");
expect(formatEntityPageTitle("Issues")).toBe("Issues");
expect(dashboardRouteTitle("/acme/settings", "repositories").fallback).toBe(
"Settings · Repositories",
);
});

it("normalizes whitespace before truncating", () => {
expect(truncatePageTitle(" One\n title ")).toBe("One title");
});
});
23 changes: 23 additions & 0 deletions apps/web/lib/page-title.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const TITLE_MAX_LENGTH = 60;

/** Keep browser-tab titles scannable when several Multica pages are open. */
export function truncatePageTitle(value: string, maxLength = TITLE_MAX_LENGTH) {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
}

export function formatIssuePageTitle(identifier?: string | null, title?: string | null) {
const issueIdentifier = identifier?.trim();
const issueTitle = title?.trim();

if (issueIdentifier && issueTitle) {
return `${issueIdentifier} ${truncatePageTitle(issueTitle)}`;
}
return issueIdentifier || (issueTitle ? truncatePageTitle(issueTitle) : "Issue");
}

export function formatEntityPageTitle(pageName: string, entityName?: string | null) {
const name = entityName?.trim();
return name ? `${pageName} · ${truncatePageTitle(name)}` : pageName;
}
1 change: 1 addition & 0 deletions packages/views/runtimes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export {
RuntimeDetailPage,
RuntimeSettingsPage,
} from "./components";
export { buildRuntimeMachines, type RuntimeMachine } from "./components/runtime-machines";
Loading