mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
Information Architecture + project/agent visual refresh (experimental) (#7543)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The board UI is the control surface for issues, projects, agents, goals, workspaces, and operator settings. > - The existing navigation and list surfaces make several high-frequency workflows feel harder to scan than they should, especially around projects and agents. > - The product direction is to improve those surfaces without breaking the existing route model or forcing a new IA on every operator at once. > - This pull request now keeps the dependent IA, project identity, and agent-list visual refresh work together while the Issue-to-Task copy migration is split into #7651. > - The benefit is a clearer left nav, better project identity, denser agent/project list rows, and brand-aligned status treatment while preserving the classic default experience behind a flag. ## Linked Issues or Issue Description Refs #7645 Refs #7651 Internal planning/work references: PAP-53, PAP-56, PAP-58, PAP-59, PAP-60, PAP-61, PAP-68, PAP-69, PAP-70, PAP-71, PAP-72, PAP-75, PAP-76, PAP-80, PAP-85, PAP-86, PAP-87, PAP-88, PAP-89. ## What Changed - Adds `enableStreamlinedLeftNavigation`, defaulting off, and gates sidebar presentation so classic navigation remains the default. - Adds project icon persistence, validation, portability, picker UI, and `ProjectTile` rendering while defaulting new projects to neutral gray. - Adds projects-list task-count and budget summary data with focused server/shared/UI coverage. - Refreshes agent list rows, row actions, active/recent sidebar behavior, and status capsule/chip styling for the approved brand state system. - Removes the placeholder Conference room and Artifacts nav/routes from the finalized experimental nav direction. - Removes `pnpm-lock.yaml` and the Issue-to-Task copy migration from this PR diff; the copy migration now lives in #7651. ## Verification - Existing branch verification from the authored commits: UI typecheck, targeted unit tests, and light/dark visual checks for `/agents`, agent detail, and design-guide status states. - Maintainer cleanup verification on `75e34e5`: `git diff --check origin/master...HEAD` passed, the `design/` diff is empty, and the PR diff is 61 files, below Greptile's 100-file review limit. - `pnpm --filter @paperclipai/ui build` passed. - `NODE_ENV=test pnpm exec vitest run ui/src/components/Sidebar.test.tsx` passed: 1 file, 8 tests. - CI and Greptile should rerun on the latest push. ## Risks - Broad UI surface area: the experimental flag keeps the classic nav default, but changed shared components such as `EntityRow`, `ProjectTile`, and agent status badges could affect multiple pages. - Database migration: `projects.icon` is additive and nullable, but migration ordering and portability import/export must stay aligned. - The Issue-to-Task copy migration is now separated into #7651, so reviewers should evaluate this PR as IA/project/agent presentation work only. - Visual regressions are possible across smaller widths because the PR intentionally changes dense list-row layouts. ## Model Used Claude Opus 4.8 assisted the original feature commits. Paperclip-Paperclip agents assisted some planning/design commits. Codex / GPT-5-class coding agent with shell, GitHub CLI, and repository access performed this PR-readiness cleanup and split. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Dotta <bippadotta@protonmail.com>
This commit is contained in:
60 files changed
+21179
-390
No files matched your search
@@ -1,5 +1,40 @@
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { ApiError } from "../api/client";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { agentRouteRef } from "../lib/utils";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import {
|
||||
buildDuplicateAgentPayload,
|
||||
duplicateAgentName,
|
||||
type DuplicateInstructionsBundle,
|
||||
} from "../lib/duplicate-agent-payload";
|
||||
import type {
|
||||
Agent,
|
||||
AgentInstructionsBundle,
|
||||
AgentInstructionsFileSummary,
|
||||
HeartbeatRun,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export function RunButton({
|
||||
onClick,
|
||||
@@ -49,3 +84,262 @@ export function PauseResumeButton({
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function duplicateInstructionFilePath(
|
||||
_bundle: AgentInstructionsBundle,
|
||||
summary: AgentInstructionsFileSummary,
|
||||
): string | null {
|
||||
if (summary.deprecated || summary.virtual) return null;
|
||||
return summary.path;
|
||||
}
|
||||
|
||||
export async function loadDuplicateInstructionsBundle(
|
||||
agentId: string,
|
||||
companyId?: string,
|
||||
): Promise<DuplicateInstructionsBundle | null> {
|
||||
const bundle = await agentsApi.instructionsBundle(agentId, companyId);
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
for (const summary of bundle.files) {
|
||||
const path = duplicateInstructionFilePath(bundle, summary);
|
||||
if (!path) continue;
|
||||
const file = await agentsApi.instructionsFile(agentId, summary.path, companyId);
|
||||
files[path] = file.content;
|
||||
}
|
||||
|
||||
const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile)
|
||||
? bundle.entryFile
|
||||
: Object.keys(files)[0] ?? "AGENTS.md";
|
||||
return Object.keys(files).length > 0 ? { entryFile, files } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared agent action cluster used by both the agent detail header and the
|
||||
* agents list rows. Encapsulates the invoke / pause / resume / terminate /
|
||||
* duplicate / reset-session mutations so callers do not diverge in behavior.
|
||||
*/
|
||||
export function AgentActionButtons({
|
||||
agent,
|
||||
companyId,
|
||||
size = "sm",
|
||||
assignLabel = "Assign Task",
|
||||
runLabel = "Run now",
|
||||
showStatus = true,
|
||||
actionsDisabled = false,
|
||||
navigateToRunOnInvoke = true,
|
||||
onActionError,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
agent: Agent;
|
||||
companyId?: string | null;
|
||||
size?: "sm" | "default";
|
||||
assignLabel?: string;
|
||||
runLabel?: string;
|
||||
showStatus?: boolean;
|
||||
actionsDisabled?: boolean;
|
||||
navigateToRunOnInvoke?: boolean;
|
||||
/**
|
||||
* Optional inline error reporter. When provided it is used instead of a toast
|
||||
* for action failures (preserves the detail page's inline error banner). When
|
||||
* omitted, failures surface as toasts (used by the list view).
|
||||
*/
|
||||
onActionError?: (message: string | null) => void;
|
||||
/** Extra content rendered just before the overflow menu (e.g. live-run link). */
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { pushToast } = useToastActions();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
const resolvedCompanyId = companyId ?? agent.companyId;
|
||||
const canonicalAgentRef = agentRouteRef(agent);
|
||||
const isPaused = agent.status === "paused";
|
||||
|
||||
const reportError = useCallback(
|
||||
(message: string) => {
|
||||
if (onActionError) {
|
||||
onActionError(message);
|
||||
} else {
|
||||
pushToast({ title: "Action failed", body: message, tone: "error" });
|
||||
}
|
||||
},
|
||||
[onActionError, pushToast],
|
||||
);
|
||||
|
||||
const invalidateAgent = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(canonicalAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
|
||||
}
|
||||
}, [agent.id, canonicalAgentRef, queryClient, resolvedCompanyId]);
|
||||
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
|
||||
switch (action) {
|
||||
case "invoke": return agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "pause": return agentsApi.pause(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "resume": return agentsApi.resume(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "approve": return agentsApi.approve(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "terminate": return agentsApi.terminate(agent.id, resolvedCompanyId ?? undefined);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, action) => {
|
||||
onActionError?.(null);
|
||||
invalidateAgent();
|
||||
if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
reportError(err instanceof Error ? err.message : "Action failed");
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateAgent = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!resolvedCompanyId) {
|
||||
throw new Error("Agent is not ready to duplicate");
|
||||
}
|
||||
const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId);
|
||||
const payload = buildDuplicateAgentPayload(agent, instructionsBundle);
|
||||
try {
|
||||
return await agentsApi.create(resolvedCompanyId, payload);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) {
|
||||
const hire = await agentsApi.hire(resolvedCompanyId, payload);
|
||||
return hire.agent;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: async (createdAgent) => {
|
||||
onActionError?.(null);
|
||||
if (resolvedCompanyId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
}
|
||||
pushToast({ title: "Agent duplicated", body: createdAgent.name, tone: "success" });
|
||||
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to duplicate agent";
|
||||
onActionError?.(message);
|
||||
pushToast({ title: "Could not duplicate agent", body: message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDuplicateAgent = useCallback(() => {
|
||||
if (duplicateAgent.isPending) return;
|
||||
const nextName = duplicateAgentName(agent.name);
|
||||
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
|
||||
setMoreOpen(false);
|
||||
if (!confirmed) return;
|
||||
duplicateAgent.mutate();
|
||||
}, [agent.name, duplicateAgent]);
|
||||
|
||||
const resetTaskSession = useMutation({
|
||||
mutationFn: () => agentsApi.resetSession(agent.id, null, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
onActionError?.(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
|
||||
},
|
||||
onError: (err) => {
|
||||
reportError(err instanceof Error ? err.message : "Failed to reset session");
|
||||
},
|
||||
});
|
||||
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const disabled = actionsDisabled || agentAction.isPending;
|
||||
|
||||
return (
|
||||
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{assignLabel}</span>
|
||||
</Button>
|
||||
<RunButton
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
disabled={disabled || isPendingApproval}
|
||||
label={runLabel}
|
||||
size={size}
|
||||
/>
|
||||
<PauseResumeButton
|
||||
isPaused={isPaused}
|
||||
onPause={() => agentAction.mutate("pause")}
|
||||
onResume={() => agentAction.mutate("resume")}
|
||||
disabled={disabled || isPendingApproval}
|
||||
size={size}
|
||||
/>
|
||||
{showStatus && (
|
||||
<span className="hidden sm:inline">
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Open actions for ${agent.name}`}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
disabled={duplicateAgent.isPending}
|
||||
onClick={handleDuplicateAgent}
|
||||
>
|
||||
{duplicateAgent.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
Duplicate Agent
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(agent.id);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy Agent ID
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
resetTaskSession.mutate();
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset Sessions
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
agentAction.mutate("terminate");
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Terminate
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatDate, agentUrl } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -41,7 +41,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
<StatusBadge status={agent.status} />
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Role">
|
||||
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
|
||||
|
||||
@@ -23,4 +23,27 @@ describe("EntityRow", () => {
|
||||
expect(markup).toContain("text-foreground/55");
|
||||
expect(markup).not.toContain("text-inherit");
|
||||
});
|
||||
|
||||
it("renders an optional meta slot and stops the title from flex-growing", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EntityRow
|
||||
title="Alpha"
|
||||
meta={<span data-testid="meta-cell">gpt-5.4</span>}
|
||||
trailing={<span data-testid="trailing-cell">badge</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// meta content renders alongside trailing
|
||||
expect(markup).toContain("meta-cell");
|
||||
expect(markup).toContain("trailing-cell");
|
||||
// a flex-1 spacer is inserted (between meta and trailing); the title block
|
||||
// itself no longer flex-grows
|
||||
expect(markup).toContain('class="flex-1"');
|
||||
expect(markup).not.toContain("min-w-0 flex-1");
|
||||
});
|
||||
|
||||
it("keeps the title flex-growing when no meta is provided", () => {
|
||||
const markup = renderToStaticMarkup(<EntityRow title="Alpha" />);
|
||||
expect(markup).toContain("min-w-0 flex-1");
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,18 @@ interface EntityRowProps {
|
||||
identifier?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Optional metadata columns rendered immediately after the title. When set,
|
||||
* the title stops flex-growing and a spacer is inserted between `meta` and
|
||||
* `trailing`, so meta sits next to the name while trailing stays pinned right.
|
||||
*/
|
||||
meta?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
selected?: boolean;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
reserveSubtitleSpace?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,11 +27,13 @@ export function EntityRow({
|
||||
identifier,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
trailing,
|
||||
selected,
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
titleClassName,
|
||||
reserveSubtitleSpace,
|
||||
}: EntityRowProps) {
|
||||
const isClickable = !!(to || onClick);
|
||||
@@ -38,14 +47,14 @@ export function EntityRow({
|
||||
const content = (
|
||||
<>
|
||||
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn("min-w-0", !meta && "flex-1", titleClassName)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{identifier && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-[1px]">
|
||||
{identifier}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{title}</span>
|
||||
<span className="truncate" title={title}>{title}</span>
|
||||
</div>
|
||||
{(subtitle || reserveSubtitleSpace) && (
|
||||
<p
|
||||
@@ -56,6 +65,8 @@ export function EntityRow({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="flex items-center gap-2 shrink-0">{meta}</div>}
|
||||
{meta && <div className="flex-1" />}
|
||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -294,6 +294,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#6366f1",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PROJECT_COLORS } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
@@ -165,7 +164,7 @@ export function NewProjectDialog() {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
|
||||
// No color is sent — new projects persist color = null (neutral gray). See PAP-68.
|
||||
...(goalIds.length > 0 ? { goalIds } : {}),
|
||||
...(targetDate ? { targetDate } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
|
||||
describe("ProjectTile", () => {
|
||||
it("renders a neutral gray folder tile by default", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile />);
|
||||
// Neutral tile uses the muted token, never an inline background color.
|
||||
expect(markup).toContain("bg-muted");
|
||||
expect(markup).toContain("text-muted-foreground");
|
||||
expect(markup).not.toContain("background-color");
|
||||
// Folder icon is the default when no icon prop is supplied.
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("renders the chosen Lucide icon when an icon prop is provided", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile icon="rocket" />);
|
||||
expect(markup).toContain("lucide-rocket");
|
||||
expect(markup).not.toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("falls back to the folder icon for an unknown icon name", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile icon="not-a-real-icon" />);
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("tints the background when a color is provided", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile color="#22c55e" />);
|
||||
expect(markup).toContain("background-color:#22c55e");
|
||||
expect(markup).toContain("text-white");
|
||||
// Tinted tile drops the muted neutral background.
|
||||
expect(markup).not.toContain("bg-muted");
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("treats null color as neutral (not tinted)", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile color={null} />);
|
||||
expect(markup).toContain("bg-muted");
|
||||
expect(markup).not.toContain("background-color");
|
||||
});
|
||||
|
||||
it("applies size styles", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile size="lg" />);
|
||||
expect(markup).toContain("h-9 w-9");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from "../lib/utils";
|
||||
import { getProjectIcon } from "../lib/project-icons";
|
||||
|
||||
/**
|
||||
* Reusable project tile (IA Phase 3 — PAP-58; icon picker added PAP-68 part 3).
|
||||
*
|
||||
* Default render is a neutral gray rounded rectangle with a folder icon.
|
||||
* An optional `color` tints the background; an optional `icon` selects which
|
||||
* Lucide glyph to render (defaults to `folder`).
|
||||
*
|
||||
* Used by the Projects list rows and the project detail header. Both `color`
|
||||
* and `icon` live on the project itself (`project.color` / `project.icon`).
|
||||
*/
|
||||
|
||||
export type ProjectTileSize = "xs" | "sm" | "md" | "lg";
|
||||
|
||||
const SIZE_STYLES: Record<ProjectTileSize, { box: string; icon: string }> = {
|
||||
xs: { box: "h-4 w-4 rounded-sm", icon: "h-2.5 w-2.5" },
|
||||
sm: { box: "h-6 w-6 rounded-md", icon: "h-3.5 w-3.5" },
|
||||
md: { box: "h-7 w-7 rounded-lg", icon: "h-4 w-4" },
|
||||
lg: { box: "h-9 w-9 rounded-lg", icon: "h-5 w-5" },
|
||||
};
|
||||
|
||||
export interface ProjectTileProps {
|
||||
/** Optional project color. When unset, the tile stays neutral gray. */
|
||||
color?: string | null;
|
||||
/** Optional Lucide icon name. When unset, defaults to `folder`. */
|
||||
icon?: string | null;
|
||||
size?: ProjectTileSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectTile({ color, icon, size = "md", className }: ProjectTileProps) {
|
||||
const dims = SIZE_STYLES[size];
|
||||
const tinted = Boolean(color);
|
||||
const Icon = getProjectIcon(icon);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center",
|
||||
dims.box,
|
||||
tinted ? "text-white" : "bg-muted text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
style={tinted ? { backgroundColor: color ?? undefined } : undefined}
|
||||
>
|
||||
<Icon className={dims.icon} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ function createProject(): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#22c55e",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -82,12 +82,14 @@ vi.mock("./SidebarCompanyMenu", () => ({
|
||||
SidebarCompanyMenu: () => <div>Company menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => null,
|
||||
vi.mock("./SidebarAgents", () => ({
|
||||
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
|
||||
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarAgents", () => ({
|
||||
SidebarAgents: () => null,
|
||||
vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
|
||||
}));
|
||||
|
||||
async function flushReact() {
|
||||
@@ -146,7 +148,10 @@ describe("Sidebar", () => {
|
||||
});
|
||||
|
||||
it("renders plugin sidebar launchers inside the Work section", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
|
||||
@@ -162,6 +167,60 @@ describe("Sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("streamlined (flag ON): keeps Issue wording, top-level Projects link, no per-project collapsible", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Issue");
|
||||
expect(container.textContent).not.toContain("New Task");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Issues");
|
||||
expect(navLabels).not.toContain("Tasks");
|
||||
|
||||
const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects");
|
||||
expect(projectsLink?.getAttribute("href")).toBe("/projects");
|
||||
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("classic (flag OFF): New Issue button, Issues label, per-project collapsible, no top-level Projects link", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Issue");
|
||||
expect(container.textContent).not.toContain("New Task");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Issues");
|
||||
expect(navLabels).not.toContain("Tasks");
|
||||
// No top-level Projects nav link in classic mode (D5 option A).
|
||||
expect(navLabels).not.toContain("Projects");
|
||||
|
||||
// Per-project collapsible restored below Work.
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders plugin sidebar slots in Work below Workspaces", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = await renderSidebar();
|
||||
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
GitBranch,
|
||||
Package,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -47,6 +48,11 @@ export function Sidebar() {
|
||||
});
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
// IA flag (PAP-89): branch the sidebar nav presentation. Default OFF = classic
|
||||
// (per-project collapsible, no Projects nav link). ON = streamlined
|
||||
// (top-level Projects link). Issue/Task wording is split to PR #7651.
|
||||
// Gating is navigation-only; all routes stay registered in both modes.
|
||||
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true;
|
||||
|
||||
const pluginContext = {
|
||||
companyId: selectedCompanyId,
|
||||
@@ -78,7 +84,7 @@ export function Sidebar() {
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
data-slot="icon-button"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
@@ -102,6 +108,9 @@ export function Sidebar() {
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
{streamlined ? (
|
||||
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["sidebar"]}
|
||||
context={pluginContext}
|
||||
@@ -117,9 +126,10 @@ export function Sidebar() {
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
{/* Classic mode restores the per-project collapsible below Work. */}
|
||||
{streamlined ? null : <SidebarProjects />}
|
||||
|
||||
<SidebarAgents />
|
||||
<SidebarAgents streamlined={streamlined} />
|
||||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
|
||||
@@ -186,10 +186,19 @@ async function chooseSortMode(label: string) {
|
||||
|
||||
function agentLinkLabels(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll('a[href^="/agents/"]'))
|
||||
.filter((anchor) => anchor.getAttribute("href") !== "/agents/all")
|
||||
.map((anchor) => anchor.textContent?.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function seeAllAgentsLink(container: HTMLElement) {
|
||||
return (
|
||||
Array.from(container.querySelectorAll('a[href="/agents/all"]')).find((anchor) =>
|
||||
anchor.textContent?.includes("See all agents"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
describe("SidebarAgents", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
@@ -249,7 +258,21 @@ describe("SidebarAgents", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderSidebarAgents() {
|
||||
async function renderSidebarAgents(streamlined = true) {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents streamlined={streamlined} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function renderSidebarAgentsWithDefaultProps() {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
@@ -467,6 +490,103 @@ describe("SidebarAgents", () => {
|
||||
expect(document.body.textContent).not.toContain("Updating...");
|
||||
});
|
||||
|
||||
it("shows only active agents when any agent has a live run", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
{ id: "run-1", agentId: "agent-b", status: "running" },
|
||||
]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
|
||||
const labels = agentLinkLabels(container);
|
||||
expect(labels).toHaveLength(1);
|
||||
expect(labels[0]).toContain("Bravo");
|
||||
// PAP-76: the full-list entry point stays visible even when only active
|
||||
// agents are shown.
|
||||
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("shows up to 5 recently-active agents plus a See all link when none are running", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(5);
|
||||
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("classic mode (flag OFF) shows all agents and no See all link even when one is running", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
{ id: "run-1", agentId: "agent-b", status: "running" },
|
||||
]);
|
||||
|
||||
await renderSidebarAgents(false);
|
||||
|
||||
// Show-all: every agent is listed regardless of live-run state. (Bravo's
|
||||
// label includes its live-run badge text, so match by prefix.)
|
||||
const labels = agentLinkLabels(container);
|
||||
expect(labels).toHaveLength(3);
|
||||
expect(labels[0]).toBe("Alpha");
|
||||
expect(labels[1]).toContain("Bravo");
|
||||
expect(labels[2]).toBe("Charlie");
|
||||
// No recent-5 truncation, so no "See all agents" link in classic mode.
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("classic mode (flag OFF) shows more than 5 agents without truncation", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgents(false);
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(7);
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to classic mode when rendered outside the Sidebar flag path", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgentsWithDefaultProps();
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(7);
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not offer sidebar resume for budget-paused agents", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({
|
||||
|
||||
@@ -44,6 +44,12 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* When no agent is running, the sidebar falls back to showing at most this many
|
||||
* recently-active agents plus a "See all agents" link (IA Phase 5).
|
||||
*/
|
||||
const RECENT_AGENT_LIMIT = 5;
|
||||
|
||||
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "alphabetical", label: "Alphabetical" },
|
||||
@@ -210,7 +216,7 @@ function SidebarAgentItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarAgents() {
|
||||
export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const queryClient = useQueryClient();
|
||||
@@ -277,6 +283,25 @@ export function SidebarAgents() {
|
||||
[orderedAgents, sortMode],
|
||||
);
|
||||
|
||||
// IA Phase 5 (streamlined): if any agent has a live run, show only those
|
||||
// active agents. Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either
|
||||
// way a "See all agents" link is shown so the full list is always reachable.
|
||||
// Classic mode (PAP-89, flag OFF) restores the show-all behavior.
|
||||
const runningAgents = useMemo(
|
||||
() => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0),
|
||||
[sortedAgents, liveCountByAgent],
|
||||
);
|
||||
const hasActiveAgents = runningAgents.length > 0;
|
||||
const displayedAgents = !streamlined
|
||||
? sortedAgents
|
||||
: hasActiveAgents
|
||||
? runningAgents
|
||||
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
|
||||
// Always expose "See all agents" whenever the displayed list is a subset of all
|
||||
// agents, so users never lose the entry point to the full list. In classic mode
|
||||
// every agent is already shown, so the link is unnecessary.
|
||||
const showSeeAllLink = streamlined && sortedAgents.length > 0;
|
||||
|
||||
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
||||
const activeAgentId = agentMatch?.[1] ?? null;
|
||||
const activeTab = agentMatch?.[2] ?? null;
|
||||
@@ -406,7 +431,7 @@ export function SidebarAgents() {
|
||||
onRadioValueChange: persistSortMode,
|
||||
}}
|
||||
>
|
||||
{sortedAgents.map((agent: Agent) => {
|
||||
{displayedAgents.map((agent: Agent) => {
|
||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||
return (
|
||||
<SidebarAgentItem
|
||||
@@ -424,6 +449,19 @@ export function SidebarAgents() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showSeeAllLink && (
|
||||
<Link
|
||||
to="/agents/all"
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<Users className="shrink-0 h-3.5 w-3.5" />
|
||||
<span>See all agents</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
@@ -145,6 +145,7 @@ function makeProject(overrides: Partial<Project>): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#ef4444",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -139,10 +140,7 @@ function ProjectItem({
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
||||
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
||||
/>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { cn } from "../lib/utils";
|
||||
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
||||
import {
|
||||
statusBadge,
|
||||
statusBadgeDefault,
|
||||
agentStatusColor,
|
||||
agentStatusColorDefault,
|
||||
agentStatusBadge,
|
||||
agentStatusCapsule,
|
||||
agentStatusMotion,
|
||||
} from "../lib/status-colors";
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
@@ -13,3 +21,39 @@ export function StatusBadge({ status }: { status: string }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent status chip — brand `.task-chip` (1px border, light/dark variants).
|
||||
* Distinct from the shared {@link StatusBadge} so the agents section can carry
|
||||
* the brand state colours without affecting run/issue/goal badges. `active`
|
||||
* renders as "idle" (alias for dead code).
|
||||
*/
|
||||
export function AgentStatusBadge({ status }: { status: string }) {
|
||||
const color = agentStatusColor[status] ?? agentStatusColorDefault;
|
||||
const label = status === "active" ? "idle" : status;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium leading-none whitespace-nowrap shrink-0",
|
||||
agentStatusBadge[color]
|
||||
)}
|
||||
>
|
||||
{label.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent status indicator — brand heartbeat capsule (vertical 8×16, r4). Running
|
||||
* agents pulse, broken (error) agents blink; both honor `prefers-reduced-motion`.
|
||||
*/
|
||||
export function AgentStatusCapsule({ status }: { status: string }) {
|
||||
const color = agentStatusColor[status] ?? agentStatusColorDefault;
|
||||
const motion = agentStatusMotion[status] ?? "";
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("inline-block h-4 w-2 rounded-[4px] shrink-0", agentStatusCapsule[color], motion)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user