Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
9ea8f787cb test(views): update breadcrumb tests for unified header behavior
The header unification changed three observable behaviors the tests
asserted against:
- issue detail no longer renders the workspace name as a breadcrumb root
- bare issue shows only its (now clickable) title leaf, no ancestor crumbs
- the project "Unknown project" error placeholder was removed

Rewrite the two affected issue-detail tests to assert the new leaf-link
and no-project-crumb behavior, drop the obsolete Unknown-project test, and
update the issues-page header test to assert the workspace prefix is gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:21:38 +08:00
Naiyuan Qing
da003e713a refactor(views): unify detail/list headers into shared BreadcrumbHeader
Replace four hand-rolled, divergent header styles (workspace-name root,
"/" separator, back-arrow, raw div) with one shared BreadcrumbHeader
component. The mental model is now identical everywhere: leading crumbs
are the thing's real containers and clicking one navigates up.

- New packages/views/layout/breadcrumb-header.tsx (segments/leaf/actions)
- Detail pages (issue, project, runtime, skill, autopilot, agent, squad)
  now render `{Section} › name`; org name removed as a breadcrumb root
- Issue breadcrumb shows the single most-direct container only (parent
  wins over project; they are orthogonal columns), never a fabricated
  chain; bare issue shows just its title
- Issue leaf (identifier + title) is now a clickable link to the issue
  detail page with a subtle hover:opacity-80
- Issues / My Issues list headers drop the workspace prefix, matching the
  icon + title style of the other list pages

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:06:37 +08:00
12 changed files with 290 additions and 289 deletions

View File

@@ -44,6 +44,7 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { AppLink, useNavigation } from "../../navigation";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import { PageHeader } from "../../layout/page-header";
import { availabilityConfig } from "../presence";
import { AgentDetailInspector } from "./agent-detail-inspector";
@@ -368,46 +369,42 @@ function DetailHeader({
// up here was redundant chrome.
return (
<PageHeader className="justify-between gap-3 px-5">
<div className="flex min-w-0 items-center gap-2">
<AppLink
href={backHref}
className="inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ArrowLeft className="h-3.5 w-3.5" />
{t(($) => $.detail.back_to_agents)}
</AppLink>
<span className="text-muted-foreground/40">/</span>
<h1 className="truncate text-sm font-medium">{agent.name}</h1>
{!isArchived && av && presence && (
<span
className={`inline-flex items-center gap-1.5 rounded-md border px-1.5 py-0.5 text-xs ${av.textClass}`}
>
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
{av.label}
</span>
)}
</div>
{!isArchived && canArchive && (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" />}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem
className="text-destructive"
onClick={onArchive}
<BreadcrumbHeader
segments={[{ href: backHref, label: t(($) => $.page.title) }]}
leaf={
<>
<h1 className="min-w-0 truncate text-sm font-medium text-foreground">{agent.name}</h1>
{!isArchived && av && presence && (
<span
className={`inline-flex shrink-0 items-center gap-1.5 rounded-md border px-1.5 py-0.5 text-xs ${av.textClass}`}
>
<Trash2 className="h-3.5 w-3.5" />
{t(($) => $.detail.more_archive)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</PageHeader>
<span className={`h-1.5 w-1.5 rounded-full ${av.dotClass}`} />
{av.label}
</span>
)}
</>
}
actions={
!isArchived && canArchive ? (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" />}
>
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem
className="text-destructive"
onClick={onArchive}
>
<Trash2 className="h-3.5 w-3.5" />
{t(($) => $.detail.more_archive)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null
}
/>
);
}

View File

@@ -23,7 +23,7 @@ import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useActorName } from "@multica/core/workspace/hooks";
import { useNavigation, AppLink } from "../../navigation";
import { PageHeader } from "../../layout/page-header";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import { ActorAvatar } from "../../common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
@@ -677,48 +677,49 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
return (
<div className="flex h-full flex-col">
{/* Header */}
<PageHeader className="justify-between px-5">
<div className="flex items-center gap-2">
<AppLink href={wsPaths.autopilots()} className="text-muted-foreground hover:text-foreground transition-colors">
<Zap className="h-4 w-4" />
</AppLink>
<span className="text-muted-foreground">/</span>
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
<div className="ml-1 flex items-center gap-1.5">
<Switch
size="sm"
checked={autopilot.status === "active"}
onCheckedChange={handleToggleStatus}
disabled={autopilot.status === "archived"}
aria-label={
autopilot.status === "active"
? t(($) => $.detail.pause_aria)
: t(($) => $.detail.activate_aria)
}
/>
<span className={cn(
"text-xs font-medium",
autopilot.status === "active" ? "text-emerald-500" :
autopilot.status === "paused" ? "text-amber-500" :
"text-muted-foreground",
)}>
{t(($) => $.status[autopilot.status])}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
<Pencil className="h-3.5 w-3.5 mr-1" />
{t(($) => $.detail.edit)}
</Button>
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
<Play className="h-3.5 w-3.5 mr-1" />
{triggerAutopilot.isPending
? t(($) => $.detail.running)
: t(($) => $.detail.run_now)}
</Button>
</div>
</PageHeader>
<BreadcrumbHeader
segments={[{ href: wsPaths.autopilots(), label: t(($) => $.page.title) }]}
leaf={
<>
<h1 className="min-w-0 truncate text-sm font-medium text-foreground">{autopilot.title}</h1>
<div className="ml-1 flex items-center gap-1.5 shrink-0">
<Switch
size="sm"
checked={autopilot.status === "active"}
onCheckedChange={handleToggleStatus}
disabled={autopilot.status === "archived"}
aria-label={
autopilot.status === "active"
? t(($) => $.detail.pause_aria)
: t(($) => $.detail.activate_aria)
}
/>
<span className={cn(
"text-xs font-medium",
autopilot.status === "active" ? "text-emerald-500" :
autopilot.status === "paused" ? "text-amber-500" :
"text-muted-foreground",
)}>
{t(($) => $.status[autopilot.status])}
</span>
</div>
</>
}
actions={
<>
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
<Pencil className="h-3.5 w-3.5 mr-1" />
{t(($) => $.detail.edit)}
</Button>
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
<Play className="h-3.5 w-3.5 mr-1" />
{triggerAutopilot.isPending
? t(($) => $.detail.running)
: t(($) => $.detail.run_now)}
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto p-6 space-y-8">

View File

@@ -531,30 +531,26 @@ describe("IssueDetail (shared)", () => {
expect(screen.getByDisplayValue("Add JWT auth to the backend")).toBeInTheDocument();
});
it("renders workspace name as breadcrumb link", async () => {
it("renders the issue title leaf as a link to the issue detail page", async () => {
renderIssueDetail();
await waitFor(() => {
expect(screen.getByText("Test WS")).toBeInTheDocument();
});
const wsLink = screen.getByText("Test WS");
// After the URL-driven workspace refactor, issue paths are scoped under
// /<workspaceSlug>/issues.
expect(wsLink.closest("a")).toHaveAttribute("href", "/test/issues");
// The breadcrumb leaf is the whole "identifier + title" string wrapped in a
// single link to the issue's own detail route (used to open the full page
// from the inline Inbox pane). A bare issue has no ancestor crumbs.
const leaf = await screen.findByText("TES-1 Implement authentication");
expect(leaf.closest("a")).toHaveAttribute("href", "/test/issues/issue-1");
});
it("omits the project breadcrumb segment when the issue has no project_id", async () => {
// Default fixture has project_id: null.
renderIssueDetail();
await waitFor(() => {
expect(screen.getByText("Test WS")).toBeInTheDocument();
});
// Leaf renders once loaded; a bare issue has no ancestor crumbs at all.
await screen.findByText("TES-1 Implement authentication");
// Project should not have been fetched.
// Project is never fetched and no project crumb appears.
expect(mockApiObj.getProject).not.toHaveBeenCalled();
expect(screen.queryByText("Unknown project")).not.toBeInTheDocument();
expect(screen.queryByText("Marketing site refresh")).not.toBeInTheDocument();
});
it("renders the project breadcrumb segment when the issue belongs to a project", async () => {
@@ -584,20 +580,6 @@ describe("IssueDetail (shared)", () => {
expect(projectLink.closest("a")).toHaveAttribute("href", "/test/projects/p-1");
});
it("shows an Unknown project placeholder when the project query fails", async () => {
mockApiObj.getIssue.mockResolvedValue({ ...mockIssue, project_id: "p-missing" });
mockApiObj.getProject.mockRejectedValue(new Error("not found"));
renderIssueDetail();
await waitFor(() => {
expect(screen.getByText("Unknown project")).toBeInTheDocument();
});
// Placeholder is non-interactive — no link wraps the text.
const placeholder = screen.getByText("Unknown project");
expect(placeholder.closest("a")).toBeNull();
});
it("renders properties sidebar with all core rows plus set optional rows", async () => {
renderIssueDetail();

View File

@@ -22,7 +22,7 @@ import {
Tag,
Users,
} from "lucide-react";
import { PageHeader } from "../../layout/page-header";
import { BreadcrumbHeader, type BreadcrumbSegment } from "../../layout/breadcrumb-header";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@multica/ui/components/ui/resizable";
@@ -60,7 +60,7 @@ import { PullRequestList } from "./pull-request-list";
import { useGitHubSettings } from "@multica/core/github";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { useWorkspacePaths } from "@multica/core/paths";
import { useActorName } from "@multica/core/workspace/hooks";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions, issueAttachmentsOptions } from "@multica/core/issues/queries";
@@ -661,7 +661,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const id = issueId;
const router = useNavigation();
const user = useAuthStore((s) => s.user);
const workspace = useCurrentWorkspace();
const paths = useWorkspacePaths();
// Issue navigation — read from TQ list cache
@@ -1019,7 +1018,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// Project segment in the breadcrumb. The issue's project_id is the source of
// truth — same URL renders the same breadcrumb regardless of entry path.
const issueProjectId = issue?.project_id;
const { data: breadcrumbProject = null, isError: breadcrumbProjectError } = useQuery({
const { data: breadcrumbProject = null } = useQuery({
...projectDetailOptions(wsId, issueProjectId ?? ""),
enabled: !!issueProjectId,
});
@@ -1604,60 +1603,45 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
);
};
// Breadcrumb shows the single most-direct container, never a fabricated chain.
// project_id and parent_issue_id are orthogonal (a sub-issue can live in a
// different project than its parent), so we never render both: parent wins,
// else project, else nothing. The project is still shown in the properties
// panel. The workspace name is intentionally absent — "all issues" is a view,
// not a container.
const breadcrumbSegments: BreadcrumbSegment[] = parentIssue
? [{ href: paths.issueDetail(parentIssue.id), label: parentIssue.identifier }]
: breadcrumbProject
? [
{
href: paths.projectDetail(breadcrumbProject.id),
className: "flex items-center gap-1 min-w-0 max-w-72",
label: (
<>
<ProjectIcon project={breadcrumbProject} size="sm" />
<span className="min-w-0 truncate">{breadcrumbProject.title}</span>
</>
),
},
]
: [];
const detailContent = (
<div className="flex h-full min-w-0 flex-1 flex-col">
<PageHeader className="gap-2 bg-background text-sm">
<div className="flex flex-1 items-center gap-1.5 min-w-0">
{workspace && (
<>
<AppLink
href={paths.issues()}
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
{workspace.name}
</AppLink>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
{issueProjectId && (
<>
{breadcrumbProject ? (
<AppLink
href={paths.projectDetail(breadcrumbProject.id)}
className="flex items-center gap-1 min-w-0 max-w-72 text-muted-foreground hover:text-foreground transition-colors"
>
<ProjectIcon project={breadcrumbProject} size="sm" />
<span className="min-w-0 truncate">{breadcrumbProject.title}</span>
</AppLink>
) : breadcrumbProjectError ? (
<span className="italic text-muted-foreground/70 shrink-0">
{t(($) => $.detail.breadcrumb_project_unknown)}
</span>
) : (
<Skeleton className="h-3.5 w-20 shrink-0" />
)}
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
{parentIssue && (
<>
<AppLink
href={paths.issueDetail(parentIssue.id)}
className="text-muted-foreground hover:text-foreground transition-colors truncate shrink-0"
>
{parentIssue.identifier}
</AppLink>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</>
)}
<span className="text-muted-foreground tabular-nums shrink-0">
{issue.identifier}
</span>
<span className="truncate font-medium text-foreground">
{issue.title}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<BreadcrumbHeader
segments={breadcrumbSegments}
leaf={
<AppLink
href={paths.issueDetail(issue.id)}
className="flex min-w-0 transition-opacity hover:opacity-80"
>
<span className="truncate font-medium text-foreground">
{issue.identifier} {issue.title}
</span>
</AppLink>
}
actions={
<>
{onDone && issue.status !== "done" && issue.status !== "cancelled" && (
<Tooltip>
<TooltipTrigger
@@ -1734,8 +1718,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
/>
<TooltipContent side="bottom">{t(($) => $.detail.sidebar_tooltip)}</TooltipContent>
</Tooltip>
</div>
</PageHeader>
</>
}
/>
<div
ref={setScrollContainerEl}

View File

@@ -559,7 +559,7 @@ describe("IssuesPage (shared)", () => {
expect(mockListIssues).not.toHaveBeenCalled();
});
it("shows workspace breadcrumb with 'Issues' label", async () => {
it("shows the 'Issues' section header without a workspace prefix", async () => {
mockListIssues.mockImplementation((params: any) =>
Promise.resolve({
issues: mockIssues.filter((i) => i.status === params?.status),
@@ -570,7 +570,9 @@ describe("IssuesPage (shared)", () => {
renderWithQuery(<IssuesPage />);
await screen.findByText("Issues");
expect(screen.getByText("Test WS")).toBeInTheDocument();
// The list header is now `icon + title`, matching the other list pages.
// The workspace/org name is no longer rendered as a breadcrumb prefix.
expect(screen.queryByText("Test WS")).not.toBeInTheDocument();
});
it("shows empty state when there are no issues", async () => {

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo } from "react";
import { toast } from "sonner";
import { ChevronRight, ListTodo } from "lucide-react";
import { ListTodo } from "lucide-react";
import type { UpdateIssueRequest } from "@multica/core/types";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
@@ -11,8 +11,6 @@ import { useIssuesScopeStore } from "@multica/core/issues/stores/issues-scope-st
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
import { filterIssues } from "../utils/filter";
import { BOARD_STATUSES } from "@multica/core/issues/config";
import { useCurrentWorkspace } from "@multica/core/paths";
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueAssigneeGroupsOptions, issueListOptions, childIssueProgressOptions, type AssigneeGroupedIssuesFilter } from "@multica/core/issues/queries";
import { agentTaskSnapshotOptions } from "@multica/core/agents";
@@ -33,7 +31,6 @@ export function IssuesPage() {
const { t } = useT("issues");
const wsId = useWorkspaceId();
const workspace = useCurrentWorkspace();
const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode);
const grouping = useIssueViewStore((s) => s.grouping);
@@ -193,13 +190,9 @@ export function IssuesPage() {
return (
<div className="flex flex-1 min-h-0 flex-col">
<PageHeader className="gap-1.5">
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
<span className="text-sm text-muted-foreground">
{workspace?.name ?? t(($) => $.page.breadcrumb_workspace_fallback)}
</span>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium">{t(($) => $.page.breadcrumb_title)}</span>
<PageHeader className="gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">{t(($) => $.page.breadcrumb_title)}</h1>
</PageHeader>
<ViewStoreProvider store={useIssueViewStore}>

View File

@@ -0,0 +1,67 @@
"use client";
import { Fragment, type ReactNode } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { PageHeader } from "./page-header";
import { AppLink } from "../navigation";
/**
* One ancestor crumb. Always a clickable link to the segment's container — the
* breadcrumb expresses a containment chain, so every segment must navigate
* somewhere. Non-navigable chrome (skeletons, "unknown" states) does NOT belong
* here; omit the segment instead.
*/
export interface BreadcrumbSegment {
href: string;
/** Plain text, or a composed node (e.g. icon + label). */
label: ReactNode;
/**
* Overrides the default `shrink-0`. Pass `flex items-center gap-1 min-w-0
* max-w-72` for a truncating segment (e.g. a long project title).
*/
className?: string;
}
interface BreadcrumbHeaderProps {
/** Ancestor links, rendered left-to-right with chevron separators. */
segments: BreadcrumbSegment[];
/** The current page — non-clickable leaf. Caller controls styling/adornments. */
leaf: ReactNode;
/** Right-side actions. Wrapped in a `shrink-0` flex row; omit for none. */
actions?: ReactNode;
className?: string;
}
/**
* Unified detail-page header: `{ancestor ancestor …} leaf [actions]`.
*
* Replaces the per-page hand-rolled breadcrumbs that had drifted into four
* different styles (workspace-name root, `/` separator, back-arrow, raw div).
* The mental model is identical everywhere: the leading crumbs are the thing's
* real containers and clicking one navigates up to it.
*/
export function BreadcrumbHeader({ segments, leaf, actions, className }: BreadcrumbHeaderProps) {
return (
<PageHeader className={cn("gap-2 bg-background text-sm", className)}>
<div className="flex flex-1 items-center gap-1.5 min-w-0">
{segments.map((segment) => (
<Fragment key={segment.href}>
<AppLink
href={segment.href}
className={cn(
"text-muted-foreground hover:text-foreground transition-colors",
segment.className ?? "shrink-0",
)}
>
{segment.label}
</AppLink>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
</Fragment>
))}
{leaf}
</div>
{actions ? <div className="flex items-center gap-1 shrink-0">{actions}</div> : null}
</PageHeader>
);
}

View File

@@ -3,12 +3,10 @@
import { useCallback, useEffect, useMemo } from "react";
import { useStore } from "zustand";
import { toast } from "sonner";
import { ChevronRight, ListTodo } from "lucide-react";
import { ListTodo } from "lucide-react";
import type { UpdateIssueRequest } from "@multica/core/types";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { useAuthStore } from "@multica/core/auth";
import { useCurrentWorkspace } from "@multica/core/paths";
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
import { useQuery } from "@tanstack/react-query";
import { filterIssues } from "../../issues/utils/filter";
import { BOARD_STATUSES } from "@multica/core/issues/config";
@@ -31,7 +29,6 @@ import { MyIssuesHeader } from "./my-issues-header";
export function MyIssuesPage() {
const { t } = useT("my-issues");
const user = useAuthStore((s) => s.user);
const workspace = useCurrentWorkspace();
const wsId = useWorkspaceId();
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
@@ -238,14 +235,9 @@ export function MyIssuesPage() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header 1: Workspace breadcrumb */}
<PageHeader className="gap-1.5">
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
<span className="text-sm text-muted-foreground">
{workspace?.name ?? t(($) => $.page.workspace_fallback)}
</span>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium">{t(($) => $.page.breadcrumb)}</span>
<PageHeader className="gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">{t(($) => $.page.breadcrumb)}</h1>
</PageHeader>
<ViewStoreProvider store={myIssuesViewStore}>

View File

@@ -25,7 +25,7 @@ import { useUpdateIssue } from "@multica/core/issues/mutations";
import { useModalStore } from "@multica/core/modals";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
import { useWorkspacePaths } from "@multica/core/paths";
import { useActorName } from "@multica/core/workspace/hooks";
import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config";
import { BOARD_STATUSES } from "@multica/core/issues/config";
@@ -34,7 +34,7 @@ import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/vie
import { filterIssues } from "../../issues/utils/filter";
import { getProjectIssueMetrics } from "./project-issue-metrics";
import { ActorAvatar } from "../../common/actor-avatar";
import { AppLink, useNavigation } from "../../navigation";
import { useNavigation } from "../../navigation";
import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor";
import { PriorityIcon } from "../../issues/components/priority-icon";
import { ProjectResourcesSection } from "./project-resources-section";
@@ -67,7 +67,7 @@ import {
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
import { PageHeader } from "../../layout/page-header";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import {
AlertDialog,
AlertDialogAction,
@@ -378,8 +378,6 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
const wsPaths = useWorkspacePaths();
const router = useNavigation();
const userId = useAuthStore((s) => s.user?.id);
const workspace = useCurrentWorkspace();
const workspaceName = workspace?.name;
const { data: project, isLoading } = useQuery(projectDetailOptions(wsId, projectId));
const projectScope = `project:${projectId}`;
const projectFilter = useMemo<MyIssuesFilter>(
@@ -695,15 +693,11 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="content" minSize="50%">
<div className="flex h-full flex-col">
<PageHeader className="gap-2 bg-background text-sm">
<div className="flex flex-1 items-center gap-1.5 min-w-0">
<AppLink href={wsPaths.projects()} className="text-muted-foreground hover:text-foreground transition-colors shrink-0">
{workspaceName ?? t(($) => $.detail.breadcrumb_fallback)}
</AppLink>
<ChevronRight className="h-3 w-3 text-muted-foreground/50 shrink-0" />
<span className="truncate">{project.title}</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<BreadcrumbHeader
segments={[{ href: wsPaths.projects(), label: t(($) => $.detail.breadcrumb_fallback) }]}
leaf={<span className="truncate font-medium text-foreground">{project.title}</span>}
actions={
<>
<Button
variant="ghost"
size="icon-sm"
@@ -769,8 +763,9 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
/>
<TooltipContent side="bottom">{t(($) => $.detail.sidebar_tooltip)}</TooltipContent>
</Tooltip>
</div>
</PageHeader>
</>
}
/>
<ViewStoreProvider store={projectViewStore}>
<ProjectIssuesSurface

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import {
ArrowLeft,
Trash2,
ChevronRight,
Cpu,
@@ -29,6 +28,7 @@ import {
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { ActorAvatar } from "../../common/actor-avatar";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import { AppLink, useNavigation } from "../../navigation";
import { availabilityConfig, workloadConfig } from "../../agents/presence";
import { formatLastSeen, isSelfHealingRuntime } from "../utils";
@@ -131,31 +131,22 @@ export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) {
return (
<div className="flex h-full flex-col">
{/* Topbar — back link + breadcrumb + right-side actions. Mirrors the
skill-detail-page topbar so users build one mental model for
"go back to the index" across the dashboard. */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<Button
variant="ghost"
size="xs"
render={<AppLink href={paths.runtimes()} />}
>
<ArrowLeft className="h-3 w-3" />
{t(($) => $.detail.all_runtimes)}
</Button>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="truncate font-mono text-xs text-foreground">
{runtime.name}
</span>
<div className="ml-auto flex items-center gap-2">
{!canDelete && (
<BreadcrumbHeader
segments={[{ href: paths.runtimes(), label: t(($) => $.page.title) }]}
leaf={
<span className="truncate font-mono text-xs text-foreground">
{runtime.name}
</span>
}
actions={
!canDelete ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
{t(($) => $.detail.read_only)}
</span>
)}
</div>
</div>
) : null
}
/>
{/* Body — single scroll container that owns the Hero card AND the
analytic blocks below. Putting Hero inside the scroll (instead of

View File

@@ -5,7 +5,6 @@ import {
AlertCircle,
AlertTriangle,
ArrowLeft,
ChevronRight,
HardDrive,
Loader2,
Lock,
@@ -58,6 +57,7 @@ import {
TooltipTrigger,
} from "@multica/ui/components/ui/tooltip";
import { AppLink, useNavigation } from "../../navigation";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import { useCanEditSkill } from "../hooks/use-can-edit-skill";
import { useSkillPermissions } from "@multica/core/permissions";
import { CapabilityBanner } from "@multica/ui/components/common/capability-banner";
@@ -553,49 +553,42 @@ export function SkillDetailPage({ skillId }: { skillId: string }) {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Topbar */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<Button
variant="ghost"
size="xs"
render={<AppLink href={paths.skills()} />}
nativeButton={false}
className="shrink-0"
>
<ArrowLeft className="h-3 w-3" />
{t(($) => $.detail.all_skills)}
</Button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate font-mono text-xs text-foreground">
{skill.name}
</span>
<div className="ml-auto flex items-center gap-2">
{!canEdit && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
{t(($) => $.detail.read_only)}
</span>
)}
{canEdit && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirmDelete(true)}
className="text-muted-foreground hover:text-destructive"
aria-label={t(($) => $.detail.delete_aria)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
<TooltipContent>{t(($) => $.detail.delete_tooltip)}</TooltipContent>
</Tooltip>
)}
</div>
</div>
<BreadcrumbHeader
segments={[{ href: paths.skills(), label: t(($) => $.page.title) }]}
leaf={
<span className="truncate font-mono text-xs text-foreground">
{skill.name}
</span>
}
actions={
<>
{!canEdit && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Lock className="h-3 w-3" />
{t(($) => $.detail.read_only)}
</span>
)}
{canEdit && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirmDelete(true)}
className="text-muted-foreground hover:text-destructive"
aria-label={t(($) => $.detail.delete_aria)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
}
/>
<TooltipContent>{t(($) => $.detail.delete_tooltip)}</TooltipContent>
</Tooltip>
)}
</>
}
/>
{!canEdit && (
<div className="px-4 pt-3">

View File

@@ -15,8 +15,9 @@ import { runtimeListOptions } from "@multica/core/runtimes";
import { CreateAgentDialog } from "../../agents/components/create-agent-dialog";
import { useNavigation } from "../../navigation";
import { AppLink } from "../../navigation";
import { BreadcrumbHeader } from "../../layout/breadcrumb-header";
import { PageHeader } from "../../layout/page-header";
import { Users, Plus, Trash2, ArrowLeft, ArrowUpRight, Crown, Camera, Loader2, Pencil, FileText, Save } from "lucide-react";
import { Users, Plus, Trash2, ArrowUpRight, Crown, Camera, Loader2, Pencil, FileText, Save } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
@@ -221,19 +222,21 @@ export function SquadDetailPage() {
return (
<div className="flex flex-1 min-h-0 flex-col">
<PageHeader className="justify-between px-5">
<div className="flex items-center gap-2">
<AppLink href={p.squads()} className="text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
</AppLink>
<SquadHeaderAvatar squad={squad} initials={initials} />
<h1 className="text-sm font-medium">{squad.name}</h1>
</div>
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => setConfirmArchive(true)}>
<Trash2 className="size-3.5 mr-1" />
{t(($) => $.inspector.archive_button)}
</Button>
</PageHeader>
<BreadcrumbHeader
segments={[{ href: p.squads(), label: t(($) => $.page.title) }]}
leaf={
<>
<SquadHeaderAvatar squad={squad} initials={initials} />
<h1 className="truncate text-sm font-medium text-foreground">{squad.name}</h1>
</>
}
actions={
<Button size="sm" variant="ghost" className="text-destructive hover:text-destructive" onClick={() => setConfirmArchive(true)}>
<Trash2 className="size-3.5 mr-1" />
{t(($) => $.inspector.archive_button)}
</Button>
}
/>
{/* Two-column grid mirrors agent-detail-page: left inspector (identity +
properties + leader), right pane with tabs (Members | Instructions).