mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 07:59:30 +02:00
Compare commits
2 Commits
agent/lamb
...
refactor/u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea8f787cb | ||
|
|
da003e713a |
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
67
packages/views/layout/breadcrumb-header.tsx
Normal file
67
packages/views/layout/breadcrumb-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user