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:
@@ -407,6 +407,7 @@ describe("renderCompanyImportPreview", () => {
|
|||||||
leadAgentSlug: null,
|
leadAgentSlug: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: null,
|
color: null,
|
||||||
|
icon: null,
|
||||||
status: null,
|
status: null,
|
||||||
executionWorkspacePolicy: null,
|
executionWorkspacePolicy: null,
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
@@ -626,6 +627,7 @@ describe("import selection catalog", () => {
|
|||||||
leadAgentSlug: null,
|
leadAgentSlug: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: null,
|
color: null,
|
||||||
|
icon: null,
|
||||||
status: null,
|
status: null,
|
||||||
executionWorkspacePolicy: null,
|
executionWorkspacePolicy: null,
|
||||||
workspaces: [],
|
workspaces: [],
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "projects" ADD COLUMN "icon" text;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -687,6 +687,13 @@
|
|||||||
"when": 1780534000000,
|
"when": 1780534000000,
|
||||||
"tag": "0097_low_trust_source_trust",
|
"tag": "0097_low_trust_source_trust",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 98,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780534200000,
|
||||||
|
"tag": "0098_project_icon",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const projects = pgTable(
|
|||||||
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
leadAgentId: uuid("lead_agent_id").references(() => agents.id),
|
||||||
targetDate: date("target_date"),
|
targetDate: date("target_date"),
|
||||||
color: text("color"),
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
env: jsonb("env").$type<AgentEnvConfig>(),
|
env: jsonb("env").$type<AgentEnvConfig>(),
|
||||||
pauseReason: text("pause_reason"),
|
pauseReason: text("pause_reason"),
|
||||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||||
|
|||||||
@@ -532,6 +532,7 @@ function existingProject(): Project {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#0f766e",
|
color: "#0f766e",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -1008,6 +1008,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: declaration.color ?? null,
|
color: declaration.color ?? null,
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -124,6 +124,56 @@ export const AGENT_ICON_NAMES = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type AgentIconName = (typeof AGENT_ICON_NAMES)[number];
|
export type AgentIconName = (typeof AGENT_ICON_NAMES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Curated Lucide icon set for projects (PAP-68 part 3).
|
||||||
|
*
|
||||||
|
* The first entry, `"folder"`, is the default for any project without an
|
||||||
|
* explicit icon. The remaining entries reuse much of the agent icon set plus a
|
||||||
|
* handful of folder/structure icons that read well at small tile sizes.
|
||||||
|
*/
|
||||||
|
export const PROJECT_ICON_NAMES = [
|
||||||
|
"folder",
|
||||||
|
"rocket",
|
||||||
|
"code",
|
||||||
|
"terminal",
|
||||||
|
"database",
|
||||||
|
"globe",
|
||||||
|
"package",
|
||||||
|
"boxes",
|
||||||
|
"box",
|
||||||
|
"layers",
|
||||||
|
"briefcase",
|
||||||
|
"compass",
|
||||||
|
"target",
|
||||||
|
"flame",
|
||||||
|
"zap",
|
||||||
|
"star",
|
||||||
|
"bug",
|
||||||
|
"wrench",
|
||||||
|
"hammer",
|
||||||
|
"lightbulb",
|
||||||
|
"sparkles",
|
||||||
|
"shield",
|
||||||
|
"lock",
|
||||||
|
"search",
|
||||||
|
"cog",
|
||||||
|
"brain",
|
||||||
|
"cpu",
|
||||||
|
"git-branch",
|
||||||
|
"file-code",
|
||||||
|
"puzzle",
|
||||||
|
"gem",
|
||||||
|
"atom",
|
||||||
|
"heart",
|
||||||
|
"mail",
|
||||||
|
"message-square",
|
||||||
|
"crown",
|
||||||
|
"radar",
|
||||||
|
"telescope",
|
||||||
|
"hexagon",
|
||||||
|
] as const;
|
||||||
|
export type ProjectIconName = (typeof PROJECT_ICON_NAMES)[number];
|
||||||
|
|
||||||
export const ISSUE_STATUSES = [
|
export const ISSUE_STATUSES = [
|
||||||
"backlog",
|
"backlog",
|
||||||
"todo",
|
"todo",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export {
|
|||||||
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||||
MODEL_PROFILE_KEYS,
|
MODEL_PROFILE_KEYS,
|
||||||
AGENT_ICON_NAMES,
|
AGENT_ICON_NAMES,
|
||||||
|
PROJECT_ICON_NAMES,
|
||||||
ISSUE_STATUSES,
|
ISSUE_STATUSES,
|
||||||
INBOX_MINE_ISSUE_STATUSES,
|
INBOX_MINE_ISSUE_STATUSES,
|
||||||
INBOX_MINE_ISSUE_STATUS_FILTER,
|
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||||
@@ -169,6 +170,7 @@ export {
|
|||||||
type AgentRole,
|
type AgentRole,
|
||||||
type ModelProfileKey,
|
type ModelProfileKey,
|
||||||
type AgentIconName,
|
type AgentIconName,
|
||||||
|
type ProjectIconName,
|
||||||
type IssueStatus,
|
type IssueStatus,
|
||||||
type IssuePriority,
|
type IssuePriority,
|
||||||
type IssueWorkMode,
|
type IssueWorkMode,
|
||||||
@@ -399,6 +401,7 @@ export type {
|
|||||||
AdapterEnvironmentTestResult,
|
AdapterEnvironmentTestResult,
|
||||||
AssetImage,
|
AssetImage,
|
||||||
Project,
|
Project,
|
||||||
|
ProjectBudgetSummary,
|
||||||
ProjectCodebase,
|
ProjectCodebase,
|
||||||
ProjectCodebaseOrigin,
|
ProjectCodebaseOrigin,
|
||||||
ProjectGoalRef,
|
ProjectGoalRef,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export interface CompanyPortabilityProjectManifestEntry {
|
|||||||
leadAgentSlug: string | null;
|
leadAgentSlug: string | null;
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
icon: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
env: AgentEnvConfig | null;
|
env: AgentEnvConfig | null;
|
||||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export type {
|
|||||||
DocumentTextRange,
|
DocumentTextRange,
|
||||||
UpdateDocumentAnnotationThreadRequest,
|
UpdateDocumentAnnotationThreadRequest,
|
||||||
} from "./document-annotation.js";
|
} from "./document-annotation.js";
|
||||||
export type { Project, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
|
||||||
export type {
|
export type {
|
||||||
CompanySearchHighlight,
|
CompanySearchHighlight,
|
||||||
CompanySearchArtifactSummary,
|
CompanySearchArtifactSummary,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
|
|||||||
export interface InstanceExperimentalSettings {
|
export interface InstanceExperimentalSettings {
|
||||||
enableEnvironments: boolean;
|
enableEnvironments: boolean;
|
||||||
enableIsolatedWorkspaces: boolean;
|
enableIsolatedWorkspaces: boolean;
|
||||||
|
enableStreamlinedLeftNavigation: boolean;
|
||||||
enableIssuePlanDecompositions: boolean;
|
enableIssuePlanDecompositions: boolean;
|
||||||
enableCloudSync: boolean;
|
enableCloudSync: boolean;
|
||||||
autoRestartDevServerWhenIdle: boolean;
|
autoRestartDevServerWhenIdle: boolean;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PauseReason, ProjectStatus } from "../constants.js";
|
import type { BudgetWindowKind, PauseReason, ProjectStatus } from "../constants.js";
|
||||||
import type {
|
import type {
|
||||||
ProjectExecutionWorkspacePolicy,
|
ProjectExecutionWorkspacePolicy,
|
||||||
ProjectWorkspaceRuntimeConfig,
|
ProjectWorkspaceRuntimeConfig,
|
||||||
@@ -14,6 +14,17 @@ export interface ProjectGoalRef {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight per-project budget summary surfaced on the projects list payload
|
||||||
|
* (IA Phase 4 — PAP-60). Reflects the active `billed_cents` budget policy scoped
|
||||||
|
* to the project, when one is set.
|
||||||
|
*/
|
||||||
|
export interface ProjectBudgetSummary {
|
||||||
|
/** Budget limit in cents. */
|
||||||
|
amountCents: number;
|
||||||
|
windowKind: BudgetWindowKind;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectWorkspace {
|
export interface ProjectWorkspace {
|
||||||
id: string;
|
id: string;
|
||||||
companyId: string;
|
companyId: string;
|
||||||
@@ -78,6 +89,7 @@ export interface Project {
|
|||||||
leadAgentId: string | null;
|
leadAgentId: string | null;
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
icon: string | null;
|
||||||
env: AgentEnvConfig | null;
|
env: AgentEnvConfig | null;
|
||||||
pauseReason: PauseReason | null;
|
pauseReason: PauseReason | null;
|
||||||
pausedAt: Date | null;
|
pausedAt: Date | null;
|
||||||
@@ -86,6 +98,16 @@ export interface Project {
|
|||||||
workspaces: ProjectWorkspace[];
|
workspaces: ProjectWorkspace[];
|
||||||
primaryWorkspace: ProjectWorkspace | null;
|
primaryWorkspace: ProjectWorkspace | null;
|
||||||
managedByPlugin?: ProjectManagedByPlugin | null;
|
managedByPlugin?: ProjectManagedByPlugin | null;
|
||||||
|
/**
|
||||||
|
* Number of tasks (issues) in the project. Populated by the projects list
|
||||||
|
* endpoint (IA Phase 4 — PAP-60); omitted on single-project payloads.
|
||||||
|
*/
|
||||||
|
taskCount?: number;
|
||||||
|
/**
|
||||||
|
* Active budget for the project, when set. Populated by the projects list
|
||||||
|
* endpoint (IA Phase 4 — PAP-60); omitted on single-project payloads.
|
||||||
|
*/
|
||||||
|
budget?: ProjectBudgetSummary | null;
|
||||||
archivedAt: Date | null;
|
archivedAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
|
|||||||
export const instanceExperimentalSettingsSchema = z.object({
|
export const instanceExperimentalSettingsSchema = z.object({
|
||||||
enableEnvironments: z.boolean().default(false),
|
enableEnvironments: z.boolean().default(false),
|
||||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||||
|
enableStreamlinedLeftNavigation: z.boolean().default(false),
|
||||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||||
enableCloudSync: z.boolean().default(false),
|
enableCloudSync: z.boolean().default(false),
|
||||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PROJECT_STATUSES } from "../constants.js";
|
import { PROJECT_STATUSES, PROJECT_ICON_NAMES } from "../constants.js";
|
||||||
import { envConfigSchema } from "./secret.js";
|
import { envConfigSchema } from "./secret.js";
|
||||||
import { trustAuthorizationPolicySchema } from "./trust-policy.js";
|
import { trustAuthorizationPolicySchema } from "./trust-policy.js";
|
||||||
|
|
||||||
@@ -107,6 +107,7 @@ const projectFields = {
|
|||||||
leadAgentId: z.string().uuid().optional().nullable(),
|
leadAgentId: z.string().uuid().optional().nullable(),
|
||||||
targetDate: z.string().optional().nullable(),
|
targetDate: z.string().optional().nullable(),
|
||||||
color: z.string().optional().nullable(),
|
color: z.string().optional().nullable(),
|
||||||
|
icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(),
|
||||||
env: envConfigSchema.optional().nullable(),
|
env: envConfigSchema.optional().nullable(),
|
||||||
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||||
archivedAt: z.string().datetime().optional().nullable(),
|
archivedAt: z.string().datetime().optional().nullable(),
|
||||||
|
|||||||
@@ -969,6 +969,7 @@ describe("company portability", () => {
|
|||||||
leadAgentId: "agent-1",
|
leadAgentId: "agent-1",
|
||||||
targetDate: "2026-03-31",
|
targetDate: "2026-03-31",
|
||||||
color: "#123456",
|
color: "#123456",
|
||||||
|
icon: "rocket",
|
||||||
status: "planned",
|
status: "planned",
|
||||||
executionWorkspacePolicy: {
|
executionWorkspacePolicy: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -1057,6 +1058,7 @@ describe("company portability", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||||
|
expect(extension).toContain('icon: "rocket"');
|
||||||
expect(extension).toContain("workspaces:");
|
expect(extension).toContain("workspaces:");
|
||||||
expect(extension).toContain("main-repo:");
|
expect(extension).toContain("main-repo:");
|
||||||
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
|
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
|
||||||
@@ -1144,6 +1146,9 @@ describe("company portability", () => {
|
|||||||
defaultProjectWorkspaceId: "workspace-imported",
|
defaultProjectWorkspaceId: "workspace-imported",
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
expect(projectSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
|
icon: "rocket",
|
||||||
|
}));
|
||||||
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
projectId: "project-imported",
|
projectId: "project-imported",
|
||||||
projectWorkspaceId: "workspace-imported",
|
projectWorkspaceId: "workspace-imported",
|
||||||
@@ -1151,6 +1156,57 @@ describe("company portability", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes invalid imported project icon names to null", async () => {
|
||||||
|
const portability = companyPortabilityService({} as any);
|
||||||
|
|
||||||
|
companySvc.create.mockResolvedValue({
|
||||||
|
id: "company-imported",
|
||||||
|
name: "Imported Paperclip",
|
||||||
|
});
|
||||||
|
accessSvc.ensureMembership.mockResolvedValue(undefined);
|
||||||
|
agentSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.list.mockResolvedValue([]);
|
||||||
|
projectSvc.create.mockResolvedValue({
|
||||||
|
id: "project-imported",
|
||||||
|
name: "Launch",
|
||||||
|
urlKey: "launch",
|
||||||
|
});
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
"COMPANY.md": [
|
||||||
|
"---",
|
||||||
|
'schema: "agentcompanies/v1"',
|
||||||
|
'name: "Imported Paperclip"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"projects/launch/PROJECT.md": [
|
||||||
|
"---",
|
||||||
|
'name: "Launch"',
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
".paperclip.yaml": [
|
||||||
|
'schema: "paperclip/v1"',
|
||||||
|
"projects:",
|
||||||
|
" launch:",
|
||||||
|
' icon: "not-a-project-icon"',
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
|
||||||
|
await portability.importBundle({
|
||||||
|
source: { type: "inline", rootPath: "paperclip-demo", files },
|
||||||
|
include: { company: true, agents: false, projects: true, issues: false },
|
||||||
|
target: { mode: "new_company", newCompanyName: "Imported Paperclip" },
|
||||||
|
collisionStrategy: "rename",
|
||||||
|
}, "user-1");
|
||||||
|
|
||||||
|
expect(projectSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||||
|
icon: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("infers portable git metadata from a local checkout without task warning fan-out", async () => {
|
it("infers portable git metadata from a local checkout without task warning fan-out", async () => {
|
||||||
const portability = companyPortabilityService({} as any);
|
const portability = companyPortabilityService({} as any);
|
||||||
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
|
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe("instance settings service", () => {
|
|||||||
})).toEqual({
|
})).toEqual({
|
||||||
enableEnvironments: true,
|
enableEnvironments: true,
|
||||||
enableIsolatedWorkspaces: true,
|
enableIsolatedWorkspaces: true,
|
||||||
|
enableStreamlinedLeftNavigation: false,
|
||||||
enableIssuePlanDecompositions: true,
|
enableIssuePlanDecompositions: true,
|
||||||
enableCloudSync: true,
|
enableCloudSync: true,
|
||||||
autoRestartDevServerWhenIdle: true,
|
autoRestartDevServerWhenIdle: true,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { companies, createDb, projects as projectsTable } from "@paperclipai/db";
|
||||||
|
import {
|
||||||
|
getEmbeddedPostgresTestSupport,
|
||||||
|
startEmbeddedPostgresTestDatabase,
|
||||||
|
} from "./helpers/embedded-postgres.js";
|
||||||
|
import { projectService } from "../services/projects.js";
|
||||||
|
|
||||||
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||||
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||||
|
|
||||||
|
if (!embeddedPostgresSupport.supported) {
|
||||||
|
console.warn(
|
||||||
|
`Skipping embedded Postgres project icon tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifies the PAP-69 data layer: the 0094 migration applies cleanly (the
|
||||||
|
// embedded harness runs all pending migrations on startup) and the `icon`
|
||||||
|
// column persists + round-trips through the projects service.
|
||||||
|
describeEmbeddedPostgres("project icon persistence", () => {
|
||||||
|
let db!: ReturnType<typeof createDb>;
|
||||||
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||||
|
let companyId!: string;
|
||||||
|
let prefixCounter = 0;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-project-icon-");
|
||||||
|
db = createDb(tempDb.connectionString);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.delete(projectsTable);
|
||||||
|
await db.delete(companies);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await tempDb?.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedCompany(): Promise<string> {
|
||||||
|
prefixCounter += 1;
|
||||||
|
const [company] = await db
|
||||||
|
.insert(companies)
|
||||||
|
.values({ name: "Icon Co", issuePrefix: `ICN${prefixCounter}` })
|
||||||
|
.returning();
|
||||||
|
return company.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("persists and round-trips a project icon on create", async () => {
|
||||||
|
companyId = await seedCompany();
|
||||||
|
const projects = projectService(db);
|
||||||
|
|
||||||
|
const created = await projects.create(companyId, { name: "Rocket", icon: "rocket" });
|
||||||
|
expect(created.icon).toBe("rocket");
|
||||||
|
|
||||||
|
const fetched = await projects.getById(created.id);
|
||||||
|
expect(fetched?.icon).toBe("rocket");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults icon to null when none is provided", async () => {
|
||||||
|
companyId = await seedCompany();
|
||||||
|
const projects = projectService(db);
|
||||||
|
|
||||||
|
const created = await projects.create(companyId, { name: "Plain" });
|
||||||
|
expect(created.icon).toBeNull();
|
||||||
|
|
||||||
|
const fetched = await projects.getById(created.id);
|
||||||
|
expect(fetched?.icon).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// PAP-71: new projects must NOT auto-assign a color — they stay neutral gray
|
||||||
|
// (color = null) unless an explicit color is supplied on create.
|
||||||
|
it("defaults color to null when none is provided (no auto-assign)", async () => {
|
||||||
|
companyId = await seedCompany();
|
||||||
|
const projects = projectService(db);
|
||||||
|
|
||||||
|
const created = await projects.create(companyId, { name: "Gray" });
|
||||||
|
expect(created.color).toBeNull();
|
||||||
|
|
||||||
|
const fetched = await projects.getById(created.id);
|
||||||
|
expect(fetched?.color).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still persists an explicit color when one is supplied", async () => {
|
||||||
|
companyId = await seedCompany();
|
||||||
|
const projects = projectService(db);
|
||||||
|
|
||||||
|
const created = await projects.create(companyId, { name: "Blue", color: "#3b82f6" });
|
||||||
|
expect(created.color).toBe("#3b82f6");
|
||||||
|
|
||||||
|
const fetched = await projects.getById(created.id);
|
||||||
|
expect(fetched?.color).toBe("#3b82f6");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildProjectListMetricMaps } from "../services/projects.ts";
|
||||||
|
|
||||||
|
describe("buildProjectListMetricMaps", () => {
|
||||||
|
it("maps task counts by project, coercing string counts to numbers", () => {
|
||||||
|
const { taskCountByProjectId } = buildProjectListMetricMaps(
|
||||||
|
[
|
||||||
|
{ projectId: "p1", count: 24 },
|
||||||
|
{ projectId: "p2", count: 11 as unknown as number },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskCountByProjectId.get("p1")).toBe(24);
|
||||||
|
expect(taskCountByProjectId.get("p2")).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores task-count rows with a null project id", () => {
|
||||||
|
const { taskCountByProjectId } = buildProjectListMetricMaps(
|
||||||
|
[{ projectId: null, count: 5 }],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(taskCountByProjectId.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps positive budgets with their window kind", () => {
|
||||||
|
const { budgetByProjectId } = buildProjectListMetricMaps(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{ scopeId: "p1", amount: 120_000, windowKind: "calendar_month_utc" },
|
||||||
|
{ scopeId: "p2", amount: 50_000, windowKind: "lifetime" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(budgetByProjectId.get("p1")).toEqual({ amountCents: 120_000, windowKind: "calendar_month_utc" });
|
||||||
|
expect(budgetByProjectId.get("p2")).toEqual({ amountCents: 50_000, windowKind: "lifetime" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits zero/negative budgets so they do not surface as 'set'", () => {
|
||||||
|
const { budgetByProjectId } = buildProjectListMetricMaps(
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
{ scopeId: "p1", amount: 0, windowKind: "lifetime" },
|
||||||
|
{ scopeId: "p2", amount: -10, windowKind: "lifetime" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(budgetByProjectId.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||||
ISSUE_PRIORITIES,
|
ISSUE_PRIORITIES,
|
||||||
ISSUE_STATUSES,
|
ISSUE_STATUSES,
|
||||||
|
PROJECT_ICON_NAMES,
|
||||||
PROJECT_STATUSES,
|
PROJECT_STATUSES,
|
||||||
ROUTINE_CATCH_UP_POLICIES,
|
ROUTINE_CATCH_UP_POLICIES,
|
||||||
ROUTINE_CONCURRENCY_POLICIES,
|
ROUTINE_CONCURRENCY_POLICIES,
|
||||||
@@ -471,6 +472,10 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | null {
|
|||||||
return parsed.success ? parsed.data : null;
|
return parsed.success ? parsed.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeProjectIconName(value: string | null | undefined): string | null {
|
||||||
|
return value && PROJECT_ICON_NAMES.includes(value as typeof PROJECT_ICON_NAMES[number]) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
function extractPortableScopedEnvInputs(
|
function extractPortableScopedEnvInputs(
|
||||||
scope: {
|
scope: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -579,6 +584,7 @@ type ProjectLike = {
|
|||||||
leadAgentId: string | null;
|
leadAgentId: string | null;
|
||||||
targetDate: string | null;
|
targetDate: string | null;
|
||||||
color: string | null;
|
color: string | null;
|
||||||
|
icon: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
env: Record<string, unknown> | null;
|
env: Record<string, unknown> | null;
|
||||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||||
@@ -2842,6 +2848,7 @@ function buildManifestFromPackageFiles(
|
|||||||
leadAgentSlug: asString(extension.leadAgentSlug),
|
leadAgentSlug: asString(extension.leadAgentSlug),
|
||||||
targetDate: asString(extension.targetDate),
|
targetDate: asString(extension.targetDate),
|
||||||
color: asString(extension.color),
|
color: asString(extension.color),
|
||||||
|
icon: asString(extension.icon),
|
||||||
status: asString(extension.status),
|
status: asString(extension.status),
|
||||||
env: normalizePortableProjectEnv(extension.env),
|
env: normalizePortableProjectEnv(extension.env),
|
||||||
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
|
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
|
||||||
@@ -3617,6 +3624,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||||||
leadAgentSlug: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null,
|
leadAgentSlug: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null,
|
||||||
targetDate: project.targetDate ?? null,
|
targetDate: project.targetDate ?? null,
|
||||||
color: project.color ?? null,
|
color: project.color ?? null,
|
||||||
|
icon: project.icon ?? null,
|
||||||
status: project.status,
|
status: project.status,
|
||||||
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
|
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
|
||||||
slug,
|
slug,
|
||||||
@@ -4708,6 +4716,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
|||||||
leadAgentId: projectLeadAgentId,
|
leadAgentId: projectLeadAgentId,
|
||||||
targetDate: manifestProject.targetDate,
|
targetDate: manifestProject.targetDate,
|
||||||
color: manifestProject.color,
|
color: manifestProject.color,
|
||||||
|
icon: normalizeProjectIconName(manifestProject.icon),
|
||||||
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
|
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
|
||||||
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
||||||
: "backlog",
|
: "backlog",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||||||
return {
|
return {
|
||||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||||
|
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? false,
|
||||||
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
||||||
enableCloudSync: parsed.data.enableCloudSync ?? false,
|
enableCloudSync: parsed.data.enableCloudSync ?? false,
|
||||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||||
@@ -55,6 +56,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||||||
return {
|
return {
|
||||||
enableEnvironments: false,
|
enableEnvironments: false,
|
||||||
enableIsolatedWorkspaces: false,
|
enableIsolatedWorkspaces: false,
|
||||||
|
enableStreamlinedLeftNavigation: false,
|
||||||
enableIssuePlanDecompositions: false,
|
enableIssuePlanDecompositions: false,
|
||||||
enableCloudSync: false,
|
enableCloudSync: false,
|
||||||
autoRestartDevServerWhenIdle: false,
|
autoRestartDevServerWhenIdle: false,
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
||||||
import type { Db } from "@paperclipai/db";
|
import type { Db } from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
projects,
|
projects,
|
||||||
projectGoals,
|
projectGoals,
|
||||||
goals,
|
goals,
|
||||||
|
issues,
|
||||||
|
budgetPolicies,
|
||||||
pluginManagedResources,
|
pluginManagedResources,
|
||||||
plugins,
|
plugins,
|
||||||
projectWorkspaces,
|
projectWorkspaces,
|
||||||
workspaceRuntimeServices,
|
workspaceRuntimeServices,
|
||||||
} from "@paperclipai/db";
|
} from "@paperclipai/db";
|
||||||
import {
|
import {
|
||||||
PROJECT_COLORS,
|
|
||||||
deriveProjectUrlKey,
|
deriveProjectUrlKey,
|
||||||
hasNonAsciiContent,
|
hasNonAsciiContent,
|
||||||
isUuidLike,
|
isUuidLike,
|
||||||
normalizeProjectUrlKey,
|
normalizeProjectUrlKey,
|
||||||
|
type BudgetWindowKind,
|
||||||
|
type ProjectBudgetSummary,
|
||||||
type ProjectCodebase,
|
type ProjectCodebase,
|
||||||
type ProjectExecutionWorkspacePolicy,
|
type ProjectExecutionWorkspacePolicy,
|
||||||
type ProjectGoalRef,
|
type ProjectGoalRef,
|
||||||
@@ -62,6 +65,8 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
|
|||||||
workspaces: ProjectWorkspace[];
|
workspaces: ProjectWorkspace[];
|
||||||
primaryWorkspace: ProjectWorkspace | null;
|
primaryWorkspace: ProjectWorkspace | null;
|
||||||
managedByPlugin: ProjectManagedByPlugin | null;
|
managedByPlugin: ProjectManagedByPlugin | null;
|
||||||
|
taskCount?: number;
|
||||||
|
budget?: ProjectBudgetSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectShortnameRow {
|
interface ProjectShortnameRow {
|
||||||
@@ -315,6 +320,86 @@ async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<Proje
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TaskCountRow = { projectId: string | null; count: number };
|
||||||
|
type ProjectBudgetRow = { scopeId: string; amount: number; windowKind: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the per-project task-count and budget lookups from the aggregate query
|
||||||
|
* rows. Pure (no DB) so the merge logic can be unit-tested in isolation.
|
||||||
|
* Only active policies with a positive amount surface as a budget.
|
||||||
|
*/
|
||||||
|
export function buildProjectListMetricMaps(taskCountRows: TaskCountRow[], budgetRows: ProjectBudgetRow[]) {
|
||||||
|
const taskCountByProjectId = new Map<string, number>();
|
||||||
|
for (const row of taskCountRows) {
|
||||||
|
if (row.projectId) taskCountByProjectId.set(row.projectId, Number(row.count) || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const budgetByProjectId = new Map<string, ProjectBudgetSummary>();
|
||||||
|
for (const row of budgetRows) {
|
||||||
|
if (row.amount > 0) {
|
||||||
|
budgetByProjectId.set(row.scopeId, {
|
||||||
|
amountCents: row.amount,
|
||||||
|
windowKind: row.windowKind as BudgetWindowKind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { taskCountByProjectId, budgetByProjectId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach lightweight list-only metrics (task count + budget) to a set of
|
||||||
|
* projects using two aggregate queries (no N+1). Used by the projects list
|
||||||
|
* view (IA Phase 4 — PAP-60).
|
||||||
|
*/
|
||||||
|
async function attachListMetrics(
|
||||||
|
db: Db,
|
||||||
|
companyId: string,
|
||||||
|
rows: ProjectWithGoals[],
|
||||||
|
): Promise<ProjectWithGoals[]> {
|
||||||
|
if (rows.length === 0) return rows;
|
||||||
|
|
||||||
|
const projectIds = rows.map((r) => r.id);
|
||||||
|
|
||||||
|
const [taskCountRows, budgetRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
projectId: issues.projectId,
|
||||||
|
count: sql<number>`count(*)::int`,
|
||||||
|
})
|
||||||
|
.from(issues)
|
||||||
|
.where(and(eq(issues.companyId, companyId), inArray(issues.projectId, projectIds)))
|
||||||
|
.groupBy(issues.projectId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
scopeId: budgetPolicies.scopeId,
|
||||||
|
amount: budgetPolicies.amount,
|
||||||
|
windowKind: budgetPolicies.windowKind,
|
||||||
|
})
|
||||||
|
.from(budgetPolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(budgetPolicies.companyId, companyId),
|
||||||
|
eq(budgetPolicies.scopeType, "project"),
|
||||||
|
eq(budgetPolicies.metric, "billed_cents"),
|
||||||
|
eq(budgetPolicies.isActive, true),
|
||||||
|
inArray(budgetPolicies.scopeId, projectIds),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { taskCountByProjectId, budgetByProjectId } = buildProjectListMetricMaps(
|
||||||
|
taskCountRows,
|
||||||
|
budgetRows,
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
taskCount: taskCountByProjectId.get(row.id) ?? 0,
|
||||||
|
budget: budgetByProjectId.get(row.id) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/** Sync the project_goals join table for a single project. */
|
/** Sync the project_goals join table for a single project. */
|
||||||
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
|
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
|
||||||
// Delete existing links
|
// Delete existing links
|
||||||
@@ -463,13 +548,8 @@ export function projectService(db: Db) {
|
|||||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||||
|
|
||||||
// Auto-assign a color from the palette if none provided
|
// Note: color is intentionally NOT auto-assigned. New projects default to
|
||||||
if (!projectData.color) {
|
// `color = null` (neutral gray) unless an explicit color is supplied. See PAP-68.
|
||||||
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
|
|
||||||
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
|
|
||||||
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
|
|
||||||
projectData.color = nextColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingProjects = await db
|
const existingProjects = await db
|
||||||
.select({ id: projects.id, name: projects.name })
|
.select({ id: projects.id, name: projects.name })
|
||||||
@@ -512,7 +592,8 @@ export function projectService(db: Db) {
|
|||||||
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
||||||
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
||||||
const withGoals = await attachGoals(db, rows);
|
const withGoals = await attachGoals(db, rows);
|
||||||
return attachWorkspaces(db, withGoals);
|
const withWorkspaces = await attachWorkspaces(db, withGoals);
|
||||||
|
return attachListMetrics(db, companyId, withWorkspaces);
|
||||||
},
|
},
|
||||||
|
|
||||||
listByIds: async (companyId: string, ids: string[]): Promise<ProjectWithGoals[]> => {
|
listByIds: async (companyId: string, ids: string[]): Promise<ProjectWithGoals[]> => {
|
||||||
|
|||||||
@@ -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 { 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({
|
export function RunButton({
|
||||||
onClick,
|
onClick,
|
||||||
@@ -49,3 +84,262 @@ export function PauseResumeButton({
|
|||||||
</Button>
|
</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 { useCompany } from "../context/CompanyContext";
|
||||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { AgentStatusBadge } from "./StatusBadge";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
import { formatDate, agentUrl } from "../lib/utils";
|
import { formatDate, agentUrl } from "../lib/utils";
|
||||||
import { Separator } from "@/components/ui/separator";
|
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-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<PropertyRow label="Status">
|
<PropertyRow label="Status">
|
||||||
<StatusBadge status={agent.status} />
|
<AgentStatusBadge status={agent.status} />
|
||||||
</PropertyRow>
|
</PropertyRow>
|
||||||
<PropertyRow label="Role">
|
<PropertyRow label="Role">
|
||||||
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
|
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
|
||||||
|
|||||||
@@ -23,4 +23,27 @@ describe("EntityRow", () => {
|
|||||||
expect(markup).toContain("text-foreground/55");
|
expect(markup).toContain("text-foreground/55");
|
||||||
expect(markup).not.toContain("text-inherit");
|
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;
|
identifier?: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: 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;
|
trailing?: ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
to?: string;
|
to?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
titleClassName?: string;
|
||||||
reserveSubtitleSpace?: boolean;
|
reserveSubtitleSpace?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,11 +27,13 @@ export function EntityRow({
|
|||||||
identifier,
|
identifier,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
meta,
|
||||||
trailing,
|
trailing,
|
||||||
selected,
|
selected,
|
||||||
to,
|
to,
|
||||||
onClick,
|
onClick,
|
||||||
className,
|
className,
|
||||||
|
titleClassName,
|
||||||
reserveSubtitleSpace,
|
reserveSubtitleSpace,
|
||||||
}: EntityRowProps) {
|
}: EntityRowProps) {
|
||||||
const isClickable = !!(to || onClick);
|
const isClickable = !!(to || onClick);
|
||||||
@@ -38,14 +47,14 @@ export function EntityRow({
|
|||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
{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">
|
<div className="flex items-center gap-2">
|
||||||
{identifier && (
|
{identifier && (
|
||||||
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-[1px]">
|
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-[1px]">
|
||||||
{identifier}
|
{identifier}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate" title={title}>{title}</span>
|
||||||
</div>
|
</div>
|
||||||
{(subtitle || reserveSubtitleSpace) && (
|
{(subtitle || reserveSubtitleSpace) && (
|
||||||
<p
|
<p
|
||||||
@@ -56,6 +65,8 @@ export function EntityRow({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>}
|
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#6366f1",
|
color: "#6366f1",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { PROJECT_COLORS } from "@paperclipai/shared";
|
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
@@ -165,7 +164,7 @@ export function NewProjectDialog() {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
status,
|
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 } : {}),
|
...(goalIds.length > 0 ? { goalIds } : {}),
|
||||||
...(targetDate ? { targetDate } : {}),
|
...(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,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#22c55e",
|
color: "#22c55e",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import { act, type ReactNode } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
@@ -82,12 +82,14 @@ vi.mock("./SidebarCompanyMenu", () => ({
|
|||||||
SidebarCompanyMenu: () => <div>Company menu</div>,
|
SidebarCompanyMenu: () => <div>Company menu</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./SidebarProjects", () => ({
|
vi.mock("./SidebarAgents", () => ({
|
||||||
SidebarProjects: () => null,
|
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
|
||||||
|
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./SidebarAgents", () => ({
|
vi.mock("./SidebarProjects", () => ({
|
||||||
SidebarAgents: () => null,
|
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
async function flushReact() {
|
async function flushReact() {
|
||||||
@@ -146,7 +148,10 @@ describe("Sidebar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders plugin sidebar launchers inside the Work section", async () => {
|
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 root = await renderSidebar();
|
||||||
|
|
||||||
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
|
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 () => {
|
it("renders plugin sidebar slots in Work below Workspaces", async () => {
|
||||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||||
const root = await renderSidebar();
|
const root = await renderSidebar();
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ import {
|
|||||||
GitBranch,
|
GitBranch,
|
||||||
Package,
|
Package,
|
||||||
Settings,
|
Settings,
|
||||||
|
FolderOpen,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { NavLink } from "@/lib/router";
|
import { NavLink } from "@/lib/router";
|
||||||
import { SidebarSection } from "./SidebarSection";
|
import { SidebarSection } from "./SidebarSection";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
import { SidebarProjects } from "./SidebarProjects";
|
|
||||||
import { SidebarAgents } from "./SidebarAgents";
|
import { SidebarAgents } from "./SidebarAgents";
|
||||||
|
import { SidebarProjects } from "./SidebarProjects";
|
||||||
import { useDialogActions } from "../context/DialogContext";
|
import { useDialogActions } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
@@ -47,6 +48,11 @@ export function Sidebar() {
|
|||||||
});
|
});
|
||||||
const liveRunCount = liveRuns?.length ?? 0;
|
const liveRunCount = liveRuns?.length ?? 0;
|
||||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
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 = {
|
const pluginContext = {
|
||||||
companyId: selectedCompanyId,
|
companyId: selectedCompanyId,
|
||||||
@@ -78,7 +84,7 @@ export function Sidebar() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => openNewIssue()}
|
onClick={() => openNewIssue()}
|
||||||
data-slot="icon-button"
|
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" />
|
<SquarePen className="h-4 w-4 shrink-0" />
|
||||||
<span className="truncate">New Issue</span>
|
<span className="truncate">New Issue</span>
|
||||||
@@ -102,6 +108,9 @@ export function Sidebar() {
|
|||||||
{showWorkspacesLink ? (
|
{showWorkspacesLink ? (
|
||||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{streamlined ? (
|
||||||
|
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
||||||
|
) : null}
|
||||||
<PluginSlotOutlet
|
<PluginSlotOutlet
|
||||||
slotTypes={["sidebar"]}
|
slotTypes={["sidebar"]}
|
||||||
context={pluginContext}
|
context={pluginContext}
|
||||||
@@ -117,9 +126,10 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
<SidebarProjects />
|
{/* Classic mode restores the per-project collapsible below Work. */}
|
||||||
|
{streamlined ? null : <SidebarProjects />}
|
||||||
|
|
||||||
<SidebarAgents />
|
<SidebarAgents streamlined={streamlined} />
|
||||||
|
|
||||||
<SidebarSection label="Company">
|
<SidebarSection label="Company">
|
||||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||||
|
|||||||
@@ -186,10 +186,19 @@ async function chooseSortMode(label: string) {
|
|||||||
|
|
||||||
function agentLinkLabels(container: HTMLElement) {
|
function agentLinkLabels(container: HTMLElement) {
|
||||||
return Array.from(container.querySelectorAll('a[href^="/agents/"]'))
|
return Array.from(container.querySelectorAll('a[href^="/agents/"]'))
|
||||||
|
.filter((anchor) => anchor.getAttribute("href") !== "/agents/all")
|
||||||
.map((anchor) => anchor.textContent?.trim())
|
.map((anchor) => anchor.textContent?.trim())
|
||||||
.filter(Boolean);
|
.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", () => {
|
describe("SidebarAgents", () => {
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: ReturnType<typeof createRoot> | null;
|
let root: ReturnType<typeof createRoot> | null;
|
||||||
@@ -249,7 +258,21 @@ describe("SidebarAgents", () => {
|
|||||||
vi.clearAllMocks();
|
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);
|
const currentRoot = createRoot(container);
|
||||||
root = currentRoot;
|
root = currentRoot;
|
||||||
|
|
||||||
@@ -467,6 +490,103 @@ describe("SidebarAgents", () => {
|
|||||||
expect(document.body.textContent).not.toContain("Updating...");
|
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 () => {
|
it("does not offer sidebar resume for budget-paused agents", async () => {
|
||||||
mockAgentsApi.list.mockResolvedValue([
|
mockAgentsApi.list.mockResolvedValue([
|
||||||
makeAgent({
|
makeAgent({
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import type { Agent } from "@paperclipai/shared";
|
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[] = [
|
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
||||||
{ value: "top", label: "Top" },
|
{ value: "top", label: "Top" },
|
||||||
{ value: "alphabetical", label: "Alphabetical" },
|
{ 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 [open, setOpen] = useState(true);
|
||||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -277,6 +283,25 @@ export function SidebarAgents() {
|
|||||||
[orderedAgents, sortMode],
|
[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 agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
||||||
const activeAgentId = agentMatch?.[1] ?? null;
|
const activeAgentId = agentMatch?.[1] ?? null;
|
||||||
const activeTab = agentMatch?.[2] ?? null;
|
const activeTab = agentMatch?.[2] ?? null;
|
||||||
@@ -406,7 +431,7 @@ export function SidebarAgents() {
|
|||||||
onRadioValueChange: persistSortMode,
|
onRadioValueChange: persistSortMode,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sortedAgents.map((agent: Agent) => {
|
{displayedAgents.map((agent: Agent) => {
|
||||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||||
return (
|
return (
|
||||||
<SidebarAgentItem
|
<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>
|
</SidebarSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ function makeProject(overrides: Partial<Project>): Project {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#ef4444",
|
color: "#ef4444",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { cn, projectRouteRef } from "../lib/utils";
|
|||||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||||
|
import { ProjectTile } from "./ProjectTile";
|
||||||
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -139,10 +140,7 @@ function ProjectItem({
|
|||||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||||
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
|
||||||
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
|
||||||
/>
|
|
||||||
<span className="flex-1 truncate">{project.name}</span>
|
<span className="flex-1 truncate">{project.name}</span>
|
||||||
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { cn } from "../lib/utils";
|
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 }) {
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -13,3 +21,39 @@ export function StatusBadge({ status }: { status: string }) {
|
|||||||
</span>
|
</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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -404,6 +404,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Agent heartbeat capsule motion (PAP-75).
|
||||||
|
`hb-pulse` marks a running agent (live); `hb-blink` marks a broken agent
|
||||||
|
(error). The blink alternates the fill between brand red and the page
|
||||||
|
background so the capsule reads as a faltering heartbeat. */
|
||||||
|
@keyframes hb-pulse {
|
||||||
|
0%, 100% { opacity: 0.4; transform: scale(0.85); }
|
||||||
|
50% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes hb-blink {
|
||||||
|
0%, 49.9% { background-color: #DC2626; }
|
||||||
|
50%, 100% { background-color: var(--background); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-pulse {
|
||||||
|
animation: hb-pulse 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hb-blink {
|
||||||
|
animation: hb-blink 1.2s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.hb-pulse,
|
||||||
|
.hb-blink {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* MDXEditor theme integration */
|
/* MDXEditor theme integration */
|
||||||
.paperclip-mdxeditor-scope,
|
.paperclip-mdxeditor-scope,
|
||||||
.paperclip-mdxeditor {
|
.paperclip-mdxeditor {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function makeProject(id: string, name: string): Project {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: null,
|
color: null,
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ describe("issueDetailBreadcrumb", () => {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: null,
|
color: null,
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -594,6 +594,7 @@ describe("optimistic issue comments", () => {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: null,
|
color: null,
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { formatProjectBudget } from "./utils";
|
||||||
|
|
||||||
|
describe("formatProjectBudget", () => {
|
||||||
|
it("renders a /mo suffix for monthly budgets", () => {
|
||||||
|
expect(formatProjectBudget({ amountCents: 120_000, windowKind: "calendar_month_utc" })).toBe("$1,200.00/mo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the bare amount for lifetime budgets", () => {
|
||||||
|
expect(formatProjectBudget({ amountCents: 50_000, windowKind: "lifetime" })).toBe("$500.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats sub-dollar amounts with cents", () => {
|
||||||
|
expect(formatProjectBudget({ amountCents: 150, windowKind: "lifetime" })).toBe("$1.50");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
Atom,
|
||||||
|
Box,
|
||||||
|
Boxes,
|
||||||
|
Brain,
|
||||||
|
Briefcase,
|
||||||
|
Bug,
|
||||||
|
Code,
|
||||||
|
Cog,
|
||||||
|
Compass,
|
||||||
|
Cpu,
|
||||||
|
Crown,
|
||||||
|
Database,
|
||||||
|
FileCode,
|
||||||
|
Flame,
|
||||||
|
Folder,
|
||||||
|
Gem,
|
||||||
|
GitBranch,
|
||||||
|
Globe,
|
||||||
|
Hammer,
|
||||||
|
Heart,
|
||||||
|
Hexagon,
|
||||||
|
Layers,
|
||||||
|
Lightbulb,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
MessageSquare,
|
||||||
|
Package,
|
||||||
|
Puzzle,
|
||||||
|
Radar,
|
||||||
|
Rocket,
|
||||||
|
Search,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
Target,
|
||||||
|
Telescope,
|
||||||
|
Terminal,
|
||||||
|
Wrench,
|
||||||
|
Zap,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { PROJECT_ICON_NAMES, type ProjectIconName } from "@paperclipai/shared";
|
||||||
|
|
||||||
|
export const PROJECT_ICONS: Record<ProjectIconName, LucideIcon> = {
|
||||||
|
folder: Folder,
|
||||||
|
rocket: Rocket,
|
||||||
|
code: Code,
|
||||||
|
terminal: Terminal,
|
||||||
|
database: Database,
|
||||||
|
globe: Globe,
|
||||||
|
package: Package,
|
||||||
|
boxes: Boxes,
|
||||||
|
box: Box,
|
||||||
|
layers: Layers,
|
||||||
|
briefcase: Briefcase,
|
||||||
|
compass: Compass,
|
||||||
|
target: Target,
|
||||||
|
flame: Flame,
|
||||||
|
zap: Zap,
|
||||||
|
star: Star,
|
||||||
|
bug: Bug,
|
||||||
|
wrench: Wrench,
|
||||||
|
hammer: Hammer,
|
||||||
|
lightbulb: Lightbulb,
|
||||||
|
sparkles: Sparkles,
|
||||||
|
shield: Shield,
|
||||||
|
lock: Lock,
|
||||||
|
search: Search,
|
||||||
|
cog: Cog,
|
||||||
|
brain: Brain,
|
||||||
|
cpu: Cpu,
|
||||||
|
"git-branch": GitBranch,
|
||||||
|
"file-code": FileCode,
|
||||||
|
puzzle: Puzzle,
|
||||||
|
gem: Gem,
|
||||||
|
atom: Atom,
|
||||||
|
heart: Heart,
|
||||||
|
mail: Mail,
|
||||||
|
"message-square": MessageSquare,
|
||||||
|
crown: Crown,
|
||||||
|
radar: Radar,
|
||||||
|
telescope: Telescope,
|
||||||
|
hexagon: Hexagon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ICON: ProjectIconName = "folder";
|
||||||
|
|
||||||
|
export function getProjectIcon(iconName: string | null | undefined): LucideIcon {
|
||||||
|
if (iconName && PROJECT_ICON_NAMES.includes(iconName as ProjectIconName)) {
|
||||||
|
return PROJECT_ICONS[iconName as ProjectIconName];
|
||||||
|
}
|
||||||
|
return PROJECT_ICONS[DEFAULT_ICON];
|
||||||
|
}
|
||||||
+40
-10
@@ -83,20 +83,50 @@ export const statusBadge: Record<string, string> = {
|
|||||||
export const statusBadgeDefault = "bg-muted text-muted-foreground";
|
export const statusBadgeDefault = "bg-muted text-muted-foreground";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Agent status dot — solid background for small indicator dots
|
// Agent status — brand state system (PAP-75)
|
||||||
|
//
|
||||||
|
// The agents section shows four states only. Each maps to a brand colour name;
|
||||||
|
// the indicator is a brand heartbeat capsule (8×16, r4) and the row chip is a
|
||||||
|
// brand `.task-chip` (1px border, light/dark variants). `active` is dead code
|
||||||
|
// that folds into idle; `running` pulses and `error` blinks. `terminated` and
|
||||||
|
// `pending_approval` are excluded from the list but may still render on detail
|
||||||
|
// pages, where they fall through to the gray default.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const agentStatusDot: Record<string, string> = {
|
export type AgentBadgeColor = "gray" | "blue" | "amber" | "red";
|
||||||
running: "bg-cyan-400 animate-pulse",
|
|
||||||
active: "bg-green-400",
|
/** Agent status → brand colour name. `active` aliases idle (never assigned). */
|
||||||
paused: "bg-yellow-400",
|
export const agentStatusColor: Record<string, AgentBadgeColor> = {
|
||||||
idle: "bg-yellow-400",
|
idle: "gray",
|
||||||
pending_approval: "bg-amber-400",
|
active: "gray",
|
||||||
error: "bg-red-400",
|
running: "blue",
|
||||||
archived: "bg-neutral-400",
|
paused: "amber",
|
||||||
|
error: "red",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const agentStatusDotDefault = "bg-neutral-400";
|
export const agentStatusColorDefault: AgentBadgeColor = "gray";
|
||||||
|
|
||||||
|
/** Brand `.task-chip` styles (1px border) per colour name — light + dark. */
|
||||||
|
export const agentStatusBadge: Record<AgentBadgeColor, string> = {
|
||||||
|
gray: "bg-[#F5F3F0] text-[#52585D] border-[#A8AEB2] dark:bg-[#6e696024] dark:text-[#9A958A] dark:border-[#9e958a73]",
|
||||||
|
blue: "bg-[#DBEAFE] text-[#1D4ED8] border-[#2563EB] dark:bg-[#2563eb2e] dark:text-[#2563EB] dark:border-[#2563eb73]",
|
||||||
|
amber: "bg-[#FEF3C7] text-[#B45309] border-[#F59E0B] dark:bg-[#f59e0b24] dark:text-[#F59E0B] dark:border-[#f59e0b73]",
|
||||||
|
red: "bg-[#FEE2E2] text-[#991B1B] border-[#DC2626] dark:bg-[#dc26262e] dark:text-[#DC2626] dark:border-[#dc262673]",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Heartbeat-capsule fill (solid) per colour name. gray darkens in dark mode. */
|
||||||
|
export const agentStatusCapsule: Record<AgentBadgeColor, string> = {
|
||||||
|
gray: "bg-[#A8AEB2] dark:bg-[#6E6960]",
|
||||||
|
blue: "bg-[#2563EB]",
|
||||||
|
amber: "bg-[#F59E0B]",
|
||||||
|
red: "bg-[#DC2626]",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Per-status capsule motion (running pulses, error blinks). Honors reduced-motion. */
|
||||||
|
export const agentStatusMotion: Record<string, string> = {
|
||||||
|
running: "hb-pulse",
|
||||||
|
error: "hb-blink",
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Priority colors
|
// Priority colors
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ export function formatNumber(n: number): string {
|
|||||||
return n.toLocaleString("en-US");
|
return n.toLocaleString("en-US");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a project's budget for the projects list view (IA Phase 4 — PAP-60).
|
||||||
|
* Monthly budgets render a `/mo` suffix; lifetime budgets show the bare amount.
|
||||||
|
*/
|
||||||
|
export function formatProjectBudget(budget: { amountCents: number; windowKind: string }): string {
|
||||||
|
const amount = formatCents(budget.amountCents);
|
||||||
|
return budget.windowKind === "calendar_month_utc" ? `${amount}/mo` : amount;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(date: Date | string): string {
|
export function formatDate(date: Date | string): string {
|
||||||
return new Date(date).toLocaleDateString("en-US", {
|
return new Date(date).toLocaleDateString("en-US", {
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|||||||
+24
-201
@@ -20,7 +20,6 @@ import { usePanel } from "../context/PanelContext";
|
|||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import { useDialogActions } from "../context/DialogContext";
|
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||||
@@ -33,14 +32,13 @@ import { MarkdownEditor } from "../components/MarkdownEditor";
|
|||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
|
||||||
import { MarkdownBody } from "../components/MarkdownBody";
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
import { CopyText } from "../components/CopyText";
|
import { CopyText } from "../components/CopyText";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { MembershipAction } from "../components/MembershipAction";
|
import { MembershipAction } from "../components/MembershipAction";
|
||||||
import { Identity } from "../components/Identity";
|
import { Identity } from "../components/Identity";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
import { AgentActionButtons } from "../components/AgentActionButtons";
|
||||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||||
import { TrustPresetSection } from "../components/TrustPresetSection";
|
import { TrustPresetSection } from "../components/TrustPresetSection";
|
||||||
import { FileTree, buildFileTree } from "../components/FileTree";
|
import { FileTree, buildFileTree } from "../components/FileTree";
|
||||||
@@ -52,18 +50,11 @@ import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
|
|||||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
import { describeRunRetryState } from "../lib/runRetryState";
|
import { describeRunRetryState } from "../lib/runRetryState";
|
||||||
import { buildDuplicateAgentPayload, duplicateAgentName, type DuplicateInstructionsBundle } from "../lib/duplicate-agent-payload";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
MoreHorizontal,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -71,7 +62,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Slash,
|
Slash,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Trash2,
|
|
||||||
Plus,
|
Plus,
|
||||||
Key,
|
Key,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -91,8 +81,6 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
|
|||||||
import {
|
import {
|
||||||
isUuidLike,
|
isUuidLike,
|
||||||
type Agent,
|
type Agent,
|
||||||
type AgentInstructionsBundle,
|
|
||||||
type AgentInstructionsFileSummary,
|
|
||||||
type AgentSkillEntry,
|
type AgentSkillEntry,
|
||||||
type AgentSkillSnapshot,
|
type AgentSkillSnapshot,
|
||||||
type AgentDetail as AgentDetailRecord,
|
type AgentDetail as AgentDetailRecord,
|
||||||
@@ -117,34 +105,6 @@ import {
|
|||||||
isReadOnlyUnmanagedSkillEntry,
|
isReadOnlyUnmanagedSkillEntry,
|
||||||
} from "../lib/agent-skills-state";
|
} from "../lib/agent-skills-state";
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function duplicateInstructionFilePath(
|
|
||||||
_bundle: AgentInstructionsBundle,
|
|
||||||
summary: AgentInstructionsFileSummary,
|
|
||||||
): string | null {
|
|
||||||
if (summary.deprecated || summary.virtual) return null;
|
|
||||||
return summary.path;
|
|
||||||
}
|
|
||||||
|
|
||||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
||||||
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
|
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
|
||||||
@@ -678,14 +638,11 @@ export function AgentDetail() {
|
|||||||
}>();
|
}>();
|
||||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
const { closePanel } = usePanel();
|
const { closePanel } = usePanel();
|
||||||
const { openNewIssue } = useDialogActions();
|
|
||||||
const { pushToast } = useToastActions();
|
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
|
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
|
||||||
const [moreOpen, setMoreOpen] = useState(false);
|
|
||||||
const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null);
|
const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null);
|
||||||
const needsDashboardData = activeView === "dashboard";
|
const needsDashboardData = activeView === "dashboard";
|
||||||
const needsRunData = activeView === "runs" || Boolean(urlRunId);
|
const needsRunData = activeView === "runs" || Boolean(urlRunId);
|
||||||
@@ -824,18 +781,17 @@ export function AgentDetail() {
|
|||||||
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
|
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
|
||||||
}, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
}, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||||
|
|
||||||
|
// Invoke / pause / resume / terminate / duplicate / reset live in the shared
|
||||||
|
// AgentActionButtons component. The detail header keeps only "approve" here,
|
||||||
|
// which is surfaced via the pending-approval banner below.
|
||||||
const agentAction = useMutation({
|
const agentAction = useMutation({
|
||||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
|
mutationFn: async (action: "approve") => {
|
||||||
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
|
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
|
||||||
switch (action) {
|
if (action === "approve") {
|
||||||
case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
|
return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||||
case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
|
|
||||||
case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
|
|
||||||
case "approve": return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
|
|
||||||
case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (data, action) => {
|
onSuccess: () => {
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
||||||
@@ -847,66 +803,12 @@ export function AgentDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (action === "invoke" && data && typeof data === "object" && "id" in data) {
|
|
||||||
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
setActionError(err instanceof Error ? err.message : "Action failed");
|
setActionError(err instanceof Error ? err.message : "Action failed");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const duplicateAgent = useMutation({
|
|
||||||
mutationFn: async () => {
|
|
||||||
if (!agent?.id || !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) => {
|
|
||||||
setActionError(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";
|
|
||||||
setActionError(message);
|
|
||||||
pushToast({
|
|
||||||
title: "Could not duplicate agent",
|
|
||||||
body: message,
|
|
||||||
tone: "error",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleDuplicateAgent = useCallback(() => {
|
|
||||||
if (!agent || 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, duplicateAgent]);
|
|
||||||
|
|
||||||
const budgetMutation = useMutation({
|
const budgetMutation = useMutation({
|
||||||
mutationFn: (amount: number) =>
|
mutationFn: (amount: number) =>
|
||||||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||||||
@@ -936,19 +838,6 @@ export function AgentDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetTaskSession = useMutation({
|
|
||||||
mutationFn: (taskKey: string | null) =>
|
|
||||||
agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined),
|
|
||||||
onSuccess: () => {
|
|
||||||
setActionError(null);
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
setActionError(err instanceof Error ? err.message : "Failed to reset session");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatePermissions = useMutation({
|
const updatePermissions = useMutation({
|
||||||
mutationFn: (permissions: AgentPermissionUpdate) =>
|
mutationFn: (permissions: AgentPermissionUpdate) =>
|
||||||
agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
|
agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
|
||||||
@@ -1086,27 +975,14 @@ export function AgentDetail() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
<AgentActionButtons
|
||||||
<Button
|
agent={agent}
|
||||||
variant="outline"
|
companyId={resolvedCompanyId}
|
||||||
size="sm"
|
assignLabel="Assign Task"
|
||||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
runLabel="Run Heartbeat"
|
||||||
>
|
actionsDisabled={agentAction.isPending}
|
||||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
onActionError={setActionError}
|
||||||
<span className="hidden sm:inline">Assign Task</span>
|
>
|
||||||
</Button>
|
|
||||||
<RunButton
|
|
||||||
onClick={() => agentAction.mutate("invoke")}
|
|
||||||
disabled={agentAction.isPending || isPendingApproval}
|
|
||||||
label="Run Heartbeat"
|
|
||||||
/>
|
|
||||||
<PauseResumeButton
|
|
||||||
isPaused={agent.status === "paused"}
|
|
||||||
onPause={() => agentAction.mutate("pause")}
|
|
||||||
onResume={() => agentAction.mutate("resume")}
|
|
||||||
disabled={agentAction.isPending || isPendingApproval}
|
|
||||||
/>
|
|
||||||
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
|
||||||
{mobileLiveRun && (
|
{mobileLiveRun && (
|
||||||
<Link
|
<Link
|
||||||
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
|
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
|
||||||
@@ -1119,60 +995,7 @@ export function AgentDetail() {
|
|||||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
</AgentActionButtons>
|
||||||
{/* Overflow menu */}
|
|
||||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon-xs">
|
|
||||||
<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(null);
|
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!urlRunId && (
|
{!urlRunId && (
|
||||||
@@ -1454,10 +1277,10 @@ function AgentOverview({
|
|||||||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||||
<RunActivityChart runs={runs} />
|
<RunActivityChart runs={runs} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
|
||||||
<PriorityChart issues={assignedIssues} />
|
<PriorityChart issues={assignedIssues} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Issues by Status" subtitle="Last 14 days">
|
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
|
||||||
<IssueStatusChart issues={assignedIssues} />
|
<IssueStatusChart issues={assignedIssues} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
||||||
@@ -1468,7 +1291,7 @@ function AgentOverview({
|
|||||||
{/* Recent Issues */}
|
{/* Recent Issues */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium">Recent Issues</h3>
|
<h3 className="text-sm font-medium">Recent Tasks</h3>
|
||||||
<Link
|
<Link
|
||||||
to={`/issues?participantAgentId=${agentId}`}
|
to={`/issues?participantAgentId=${agentId}`}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -1477,7 +1300,7 @@ function AgentOverview({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{assignedIssues.length === 0 ? (
|
{assignedIssues.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No recent issues.</p>
|
<p className="text-sm text-muted-foreground">No recent tasks.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="border border-border rounded-lg">
|
<div className="border border-border rounded-lg">
|
||||||
{assignedIssues.slice(0, 10).map((issue) => (
|
{assignedIssues.slice(0, 10).map((issue) => (
|
||||||
@@ -1491,7 +1314,7 @@ function AgentOverview({
|
|||||||
))}
|
))}
|
||||||
{assignedIssues.length > 10 && (
|
{assignedIssues.length > 10 && (
|
||||||
<div className="px-3 py-2 text-xs text-muted-foreground text-center border-t border-border">
|
<div className="px-3 py-2 text-xs text-muted-foreground text-center border-t border-border">
|
||||||
+{assignedIssues.length - 10} more issues
|
+{assignedIssues.length - 10} more tasks
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -3651,7 +3474,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
|||||||
>
|
>
|
||||||
{clearSessionsForTouchedIssues.isPending
|
{clearSessionsForTouchedIssues.isPending
|
||||||
? "clearing session..."
|
? "clearing session..."
|
||||||
: "clear session for these issues"}
|
: "clear session for these tasks"}
|
||||||
</button>
|
</button>
|
||||||
{clearSessionsForTouchedIssues.isError && (
|
{clearSessionsForTouchedIssues.isError && (
|
||||||
<p className="text-[11px] text-destructive mt-1">
|
<p className="text-[11px] text-destructive mt-1">
|
||||||
@@ -3671,7 +3494,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
|||||||
{/* Issues touched by this run */}
|
{/* Issues touched by this run */}
|
||||||
{touchedIssues && touchedIssues.length > 0 && (
|
{touchedIssues && touchedIssues.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground">Issues Touched ({touchedIssues.length})</span>
|
<span className="text-xs font-medium text-muted-foreground">Tasks Touched ({touchedIssues.length})</span>
|
||||||
<div className="border border-border rounded-lg divide-y divide-border">
|
<div className="border border-border rounded-lg divide-y divide-border">
|
||||||
{touchedIssues.map((issue) => (
|
{touchedIssues.map((issue) => (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -126,7 +126,11 @@ describe("Agents", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mockAgentsApi.list.mockResolvedValue([
|
mockAgentsApi.list.mockResolvedValue([
|
||||||
makeAgent({ adapterConfig: { model: "gpt-5.4" } }),
|
makeAgent({
|
||||||
|
adapterConfig: { model: "gpt-5.4" },
|
||||||
|
// Old enough that relativeTime() falls back to an absolute date string.
|
||||||
|
lastHeartbeatAt: new Date("2026-01-15T00:00:00Z"),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
mockAgentsApi.org.mockResolvedValue([
|
mockAgentsApi.org.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -180,5 +184,44 @@ describe("Agents", () => {
|
|||||||
|
|
||||||
expect(container.textContent).toContain("codex_local");
|
expect(container.textContent).toContain("codex_local");
|
||||||
expect(container.textContent).toContain("gpt-5.4");
|
expect(container.textContent).toContain("gpt-5.4");
|
||||||
|
|
||||||
|
// The heartbeat cell must render on a single line so full dates like
|
||||||
|
// "Apr 30, 2026" never wrap (PAP-85 defect #2).
|
||||||
|
const heartbeatCell = container.querySelector(".whitespace-nowrap.w-24");
|
||||||
|
expect(heartbeatCell).not.toBeNull();
|
||||||
|
expect(heartbeatCell?.textContent).not.toContain("\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives list-view rows a fixed-width title so meta columns align (PAP-86)", async () => {
|
||||||
|
root = createRoot(container);
|
||||||
|
await act(async () => {
|
||||||
|
root!.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ToastProvider>
|
||||||
|
<Agents />
|
||||||
|
</ToastProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
// Switch from the default org view to the list view.
|
||||||
|
const listToggle = Array.from(container.querySelectorAll("button")).find(
|
||||||
|
(btn) => btn.querySelector("svg.lucide-list"),
|
||||||
|
);
|
||||||
|
expect(listToggle).toBeDefined();
|
||||||
|
await act(async () => {
|
||||||
|
listToggle!.click();
|
||||||
|
});
|
||||||
|
await flushReact();
|
||||||
|
|
||||||
|
// The title cell carries a constant width (`w-56`), not a content-sized
|
||||||
|
// `min-w-[7rem]`, so the `meta` group starts at the same x on every row and
|
||||||
|
// the model + timestamp columns line up vertically.
|
||||||
|
const titleCell = container.querySelector(".w-56");
|
||||||
|
expect(titleCell).not.toBeNull();
|
||||||
|
expect(titleCell?.textContent).toContain("Alpha");
|
||||||
|
expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+86
-86
@@ -8,9 +8,9 @@ import { useDialogActions } from "../context/DialogContext";
|
|||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
|
||||||
|
import { AgentActionButtons } from "../components/AgentActionButtons";
|
||||||
import { MembershipAction } from "../components/MembershipAction";
|
import { MembershipAction } from "../components/MembershipAction";
|
||||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
@@ -18,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
|||||||
import { PageTabBar } from "../components/PageTabBar";
|
import { PageTabBar } from "../components/PageTabBar";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
|
import { Bot, Plus, List, GitBranch } from "lucide-react";
|
||||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||||
import {
|
import {
|
||||||
resourceMembershipState,
|
resourceMembershipState,
|
||||||
@@ -32,8 +32,12 @@ const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
|||||||
|
|
||||||
type FilterTab = "all" | "active" | "paused" | "error";
|
type FilterTab = "all" | "active" | "paused" | "error";
|
||||||
|
|
||||||
function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean): boolean {
|
// Agents in these states never appear in the agents list — `terminated` is
|
||||||
if (status === "terminated") return showTerminated;
|
// hidden like an archived company, and `pending_approval` is a hiring gate that
|
||||||
|
// lives in the task thread, not an agent run state (PAP-75).
|
||||||
|
const HIDDEN_AGENT_STATUSES = new Set(["terminated", "pending_approval"]);
|
||||||
|
|
||||||
|
function matchesFilter(status: string, tab: FilterTab): boolean {
|
||||||
if (tab === "all") return true;
|
if (tab === "all") return true;
|
||||||
if (tab === "active") return status === "active" || status === "running" || status === "idle";
|
if (tab === "active") return status === "active" || status === "running" || status === "idle";
|
||||||
if (tab === "paused") return status === "paused";
|
if (tab === "paused") return status === "paused";
|
||||||
@@ -41,9 +45,9 @@ function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean):
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] {
|
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
|
||||||
return agents
|
return agents
|
||||||
.filter((a) => matchesFilter(a.status, tab, showTerminated))
|
.filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,11 +58,17 @@ function getConfiguredModel(agent: Agent): string | null {
|
|||||||
return model.length > 0 ? model : null;
|
return model.length > 0 ? model : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
|
function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
|
||||||
return nodes
|
return nodes
|
||||||
.reduce<OrgNode[]>((acc, node) => {
|
.reduce<OrgNode[]>((acc, node) => {
|
||||||
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
|
const filteredReports = filterOrgTree(node.reports, tab);
|
||||||
if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
|
// Hidden agents (terminated / pending_approval) never render as a row, but
|
||||||
|
// any visible reports are promoted so the tree doesn't lose live agents.
|
||||||
|
if (HIDDEN_AGENT_STATUSES.has(node.status)) {
|
||||||
|
acc.push(...filteredReports);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if (matchesFilter(node.status, tab) || filteredReports.length > 0) {
|
||||||
acc.push({ ...node, reports: filteredReports });
|
acc.push({ ...node, reports: filteredReports });
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
@@ -78,8 +88,6 @@ export function Agents() {
|
|||||||
const [view, setView] = useState<"list" | "org">("org");
|
const [view, setView] = useState<"list" | "org">("org");
|
||||||
const forceListView = isMobile;
|
const forceListView = isMobile;
|
||||||
const effectiveView: "list" | "org" = forceListView ? "list" : view;
|
const effectiveView: "list" | "org" = forceListView ? "list" : view;
|
||||||
const [showTerminated, setShowTerminated] = useState(false);
|
|
||||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: agents, isLoading, error } = useQuery({
|
const { data: agents, isLoading, error } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -135,8 +143,8 @@ export function Agents() {
|
|||||||
return <PageSkeleton variant="list" />;
|
return <PageSkeleton variant="list" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = filterAgents(agents ?? [], tab, showTerminated);
|
const filtered = filterAgents(agents ?? [], tab);
|
||||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
|
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -154,36 +162,6 @@ export function Agents() {
|
|||||||
/>
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Filters */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-1.5 px-2 py-1.5 text-xs transition-colors border border-border",
|
|
||||||
filtersOpen || showTerminated ? "text-foreground bg-accent" : "text-muted-foreground hover:bg-accent/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
|
||||||
>
|
|
||||||
<SlidersHorizontal className="h-3 w-3" />
|
|
||||||
Filters
|
|
||||||
{showTerminated && <span className="ml-0.5 px-1 bg-foreground/10 rounded text-[10px]">1</span>}
|
|
||||||
</button>
|
|
||||||
{filtersOpen && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 z-50 w-48 border border-border bg-popover shadow-md p-1">
|
|
||||||
<button
|
|
||||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent/50 transition-colors"
|
|
||||||
onClick={() => setShowTerminated(!showTerminated)}
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
"flex items-center justify-center h-3.5 w-3.5 border border-border rounded-sm",
|
|
||||||
showTerminated && "bg-foreground"
|
|
||||||
)}>
|
|
||||||
{showTerminated && <span className="text-background text-[10px] leading-none">✓</span>}
|
|
||||||
</span>
|
|
||||||
Show terminated
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{/* View toggle */}
|
{/* View toggle */}
|
||||||
{!forceListView && (
|
{!forceListView && (
|
||||||
<div className="flex items-center border border-border">
|
<div className="flex items-center border border-border">
|
||||||
@@ -237,6 +215,11 @@ export function Agents() {
|
|||||||
<EntityRow
|
<EntityRow
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
title={agent.name}
|
title={agent.name}
|
||||||
|
// Fixed (truncating) title width so the `meta` group starts at a
|
||||||
|
// constant x on every row — that's what makes the model + timestamp
|
||||||
|
// columns line up vertically (PAP-86). Agent names vary in width, so
|
||||||
|
// a content-sized title (`min-w-[7rem]`) shifted meta's start per row.
|
||||||
|
titleClassName="w-56"
|
||||||
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||||
to={agentUrl(agent)}
|
to={agentUrl(agent)}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -244,12 +227,11 @@ export function Agents() {
|
|||||||
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
||||||
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
|
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
|
||||||
)}
|
)}
|
||||||
leading={
|
leading={<AgentStatusCapsule status={agent.status} />}
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
meta={
|
||||||
<span
|
<div className="hidden xl:flex items-center gap-3">
|
||||||
className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[agent.status] ?? agentStatusDotDefault}`}
|
<AgentMetaColumns agent={agent} />
|
||||||
/>
|
</div>
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
trailing={
|
trailing={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -261,7 +243,7 @@ export function Agents() {
|
|||||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge status={agent.status} />
|
<AgentStatusBadge status={agent.status} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="hidden sm:flex items-center gap-3">
|
<div className="hidden sm:flex items-center gap-3">
|
||||||
@@ -272,22 +254,25 @@ export function Agents() {
|
|||||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
|
|
||||||
{getAdapterLabel(agent.adapterType)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
|
|
||||||
title={getConfiguredModel(agent) ?? undefined}
|
|
||||||
>
|
|
||||||
{getConfiguredModel(agent) ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
|
||||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
|
||||||
</span>
|
|
||||||
<span className="w-20 flex justify-end">
|
<span className="w-20 flex justify-end">
|
||||||
<StatusBadge status={agent.status} />
|
<AgentStatusBadge status={agent.status} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Row actions mirror the agent detail page; stop the click
|
||||||
|
from bubbling to the row link so buttons don't navigate. */}
|
||||||
|
<div
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AgentActionButtons
|
||||||
|
agent={agent}
|
||||||
|
companyId={selectedCompanyId}
|
||||||
|
runLabel="Run Heartbeat"
|
||||||
|
showStatus={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<MembershipAction
|
<MembershipAction
|
||||||
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
|
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
|
||||||
pending={
|
pending={
|
||||||
@@ -386,8 +371,6 @@ function OrgTreeNode({
|
|||||||
membershipMutation.variables?.resourceType === "agent" &&
|
membershipMutation.variables?.resourceType === "agent" &&
|
||||||
membershipMutation.variables.resourceId === node.id;
|
membershipMutation.variables.resourceId === node.id;
|
||||||
|
|
||||||
const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingLeft: depth * 24 }}>
|
<div style={{ paddingLeft: depth * 24 }}>
|
||||||
<Link
|
<Link
|
||||||
@@ -398,10 +381,8 @@ function OrgTreeNode({
|
|||||||
membershipState === "left" && "text-foreground/55",
|
membershipState === "left" && "text-foreground/55",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
<AgentStatusCapsule status={node.status} />
|
||||||
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
|
<div className="flex-1 min-w-[7rem]">
|
||||||
</span>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="text-sm font-medium">{node.name}</span>
|
<span className="text-sm font-medium">{node.name}</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
{roleLabels[node.role] ?? node.role}
|
{roleLabels[node.role] ?? node.role}
|
||||||
@@ -417,7 +398,7 @@ function OrgTreeNode({
|
|||||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge status={node.status} />
|
<AgentStatusBadge status={node.status} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="hidden sm:flex items-center gap-3">
|
<div className="hidden sm:flex items-center gap-3">
|
||||||
@@ -429,23 +410,12 @@ function OrgTreeNode({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{agent && (
|
{agent && (
|
||||||
<>
|
<div className="hidden xl:flex items-center gap-3">
|
||||||
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
|
<AgentMetaColumns agent={agent} />
|
||||||
{getAdapterLabel(agent.adapterType)}
|
</div>
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
|
|
||||||
title={getConfiguredModel(agent) ?? undefined}
|
|
||||||
>
|
|
||||||
{getConfiguredModel(agent) ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
|
||||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
<span className="w-20 flex justify-end">
|
<span className="w-20 flex justify-end">
|
||||||
<StatusBadge status={node.status} />
|
<AgentStatusBadge status={node.status} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MembershipAction
|
<MembershipAction
|
||||||
@@ -488,6 +458,36 @@ function OrgTreeNode({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider/model + heartbeat columns shared by the list and org views. The
|
||||||
|
* model and adapter label share one fixed-width cell, each line truncating with
|
||||||
|
* an ellipsis so a long model id can never overlap the heartbeat column. The
|
||||||
|
* heartbeat is single-line (`whitespace-nowrap`) and wide enough for a full
|
||||||
|
* date like "Apr 30, 2026".
|
||||||
|
*/
|
||||||
|
function AgentMetaColumns({ agent }: { agent: Agent }) {
|
||||||
|
const model = getConfiguredModel(agent);
|
||||||
|
const adapterLabel = getAdapterLabel(agent.adapterType);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="w-44 min-w-0 leading-tight">
|
||||||
|
<div
|
||||||
|
className="truncate font-mono text-xs text-muted-foreground"
|
||||||
|
title={model ?? undefined}
|
||||||
|
>
|
||||||
|
{model ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="truncate font-mono text-[11px] text-muted-foreground/70" title={adapterLabel}>
|
||||||
|
{adapterLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="w-24 whitespace-nowrap text-right text-xs text-muted-foreground">
|
||||||
|
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function LiveRunIndicator({
|
function LiveRunIndicator({
|
||||||
agentRef,
|
agentRef,
|
||||||
runId,
|
runId,
|
||||||
|
|||||||
@@ -112,10 +112,9 @@ import {
|
|||||||
AvatarGroup,
|
AvatarGroup,
|
||||||
AvatarGroupCount,
|
AvatarGroupCount,
|
||||||
} from "@/components/ui/avatar";
|
} from "@/components/ui/avatar";
|
||||||
import { StatusBadge } from "@/components/StatusBadge";
|
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
|
||||||
import { StatusIcon } from "@/components/StatusIcon";
|
import { StatusIcon } from "@/components/StatusIcon";
|
||||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||||
import { agentStatusDot, agentStatusDotDefault } from "@/lib/status-colors";
|
|
||||||
import { EntityRow } from "@/components/EntityRow";
|
import { EntityRow } from "@/components/EntityRow";
|
||||||
import { EmptyState } from "@/components/EmptyState";
|
import { EmptyState } from "@/components/EmptyState";
|
||||||
import { MetricCard } from "@/components/MetricCard";
|
import { MetricCard } from "@/components/MetricCard";
|
||||||
@@ -551,14 +550,18 @@ export function DesignGuide() {
|
|||||||
</div>
|
</div>
|
||||||
</SubSection>
|
</SubSection>
|
||||||
|
|
||||||
<SubSection title="Agent status dots">
|
<SubSection title="Agent status (capsule + chip)">
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<p className="text-xs text-muted-foreground mb-3 max-w-prose">
|
||||||
{(["running", "active", "paused", "error", "archived"] as const).map((label) => (
|
The agents section uses a brand heartbeat capsule (8×16) plus a brand
|
||||||
|
<code className="mx-1">.task-chip</code>. Four states only: idle (gray),
|
||||||
|
running (blue, pulses), paused (amber), error (red, blinks). Motion
|
||||||
|
honors <code>prefers-reduced-motion</code>.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-6 flex-wrap">
|
||||||
|
{(["idle", "running", "paused", "error"] as const).map((label) => (
|
||||||
<div key={label} className="flex items-center gap-2">
|
<div key={label} className="flex items-center gap-2">
|
||||||
<span className="relative flex h-2.5 w-2.5">
|
<AgentStatusCapsule status={label} />
|
||||||
<span className={`inline-flex h-full w-full rounded-full ${agentStatusDot[label] ?? agentStatusDotDefault}`} />
|
<AgentStatusBadge status={label} />
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#14b8a6",
|
color: "#14b8a6",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -205,6 +205,8 @@ export function InstanceExperimentalSettings() {
|
|||||||
|
|
||||||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||||
|
const enableStreamlinedLeftNavigation =
|
||||||
|
experimentalQuery.data?.enableStreamlinedLeftNavigation === true;
|
||||||
const enableIssuePlanDecompositions =
|
const enableIssuePlanDecompositions =
|
||||||
experimentalQuery.data?.enableIssuePlanDecompositions === true;
|
experimentalQuery.data?.enableIssuePlanDecompositions === true;
|
||||||
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
||||||
@@ -301,6 +303,28 @@ export function InstanceExperimentalSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h2 className="text-sm font-semibold">Streamlined Left Navigation Bar</h2>
|
||||||
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||||
|
Reduces the maximum number of items in the left navigation bar — nests Projects under Work with a
|
||||||
|
dedicated Projects page, and shows only active agents (max 5 recently-active) in the sidebar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={enableStreamlinedLeftNavigation}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleMutation.mutate({
|
||||||
|
enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={toggleMutation.isPending}
|
||||||
|
aria-label="Toggle streamlined left navigation experimental setting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="rounded-xl border border-border bg-card p-5">
|
<section className="rounded-xl border border-border bg-card p-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#14b8a6",
|
color: "#14b8a6",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
+104
-41
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
import { PROJECT_COLORS, PROJECT_ICON_NAMES, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||||
import { budgetsApi } from "../api/budgets";
|
import { budgetsApi } from "../api/budgets";
|
||||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||||
@@ -18,6 +18,7 @@ import { queryKeys } from "../lib/queryKeys";
|
|||||||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
|
import { ProjectTile } from "../components/ProjectTile";
|
||||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||||
import { IssuesList } from "../components/IssuesList";
|
import { IssuesList } from "../components/IssuesList";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
@@ -27,7 +28,11 @@ import { MembershipAction } from "../components/MembershipAction";
|
|||||||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||||
import { projectRouteRef } from "../lib/utils";
|
import { projectRouteRef } from "../lib/utils";
|
||||||
|
import { PROJECT_ICONS } from "../lib/project-icons";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Tabs } from "@/components/ui/tabs";
|
import { Tabs } from "@/components/ui/tabs";
|
||||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||||
@@ -103,60 +108,116 @@ function OverviewContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Color picker popover ── */
|
/* ── Combined icon + color picker popover (PAP-72 / PAP-68 part 4) ── */
|
||||||
|
|
||||||
function ColorPicker({
|
const DEFAULT_PROJECT_ICON = "folder";
|
||||||
currentColor,
|
|
||||||
onSelect,
|
function ProjectTilePicker({
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
onSelectIcon,
|
||||||
|
onSelectColor,
|
||||||
}: {
|
}: {
|
||||||
currentColor: string;
|
color: string | null;
|
||||||
onSelect: (color: string) => void;
|
icon: string | null;
|
||||||
|
onSelectIcon: (icon: string) => void;
|
||||||
|
onSelectColor: (color: string | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
const filteredIcons = useMemo(() => {
|
||||||
if (!open) return;
|
const entries = PROJECT_ICON_NAMES.map((name) => [name, PROJECT_ICONS[name]] as const);
|
||||||
function handleClick(e: MouseEvent) {
|
if (!search) return entries;
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
const q = search.toLowerCase();
|
||||||
setOpen(false);
|
return entries.filter(([name]) => name.includes(q));
|
||||||
}
|
}, [search]);
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
|
// Keep the popover open across selections so the user can pick both an icon
|
||||||
|
// and a color in one pass; reset the search when it closes.
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={ref}>
|
<Popover
|
||||||
<button
|
open={open}
|
||||||
onClick={() => setOpen(!open)}
|
onOpenChange={(next) => {
|
||||||
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
|
setOpen(next);
|
||||||
style={{ backgroundColor: currentColor }}
|
if (!next) setSearch("");
|
||||||
aria-label="Change project color"
|
}}
|
||||||
/>
|
>
|
||||||
{open && (
|
<PopoverTrigger asChild>
|
||||||
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded-lg cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
|
||||||
|
aria-label="Change project icon and color"
|
||||||
|
>
|
||||||
|
<ProjectTile color={color} icon={icon} size="md" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 p-3" align="start">
|
||||||
|
{/* Icon search + grid */}
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Icon</p>
|
||||||
|
<Input
|
||||||
|
placeholder="Search icons..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="mb-2 h-8 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-7 gap-1 max-h-40 overflow-y-auto">
|
||||||
|
{filteredIcons.map(([name, Icon]) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectIcon(name)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
|
||||||
|
(icon ?? DEFAULT_PROJECT_ICON) === name && "bg-accent ring-1 ring-primary",
|
||||||
|
)}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filteredIcons.length === 0 && (
|
||||||
|
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color swatches */}
|
||||||
|
<div className="mt-3 border-t border-border pt-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground mb-2">Color</p>
|
||||||
<div className="grid grid-cols-5 gap-1.5">
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
{PROJECT_COLORS.map((color) => (
|
{/* Neutral / reset-to-gray option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectColor(null)}
|
||||||
|
className={`h-6 w-6 cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
|
||||||
|
color === null
|
||||||
|
? "ring-2 ring-foreground ring-offset-1 ring-offset-background rounded-md"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
aria-label="Reset to neutral gray"
|
||||||
|
title="Neutral (default)"
|
||||||
|
>
|
||||||
|
<ProjectTile color={null} size="sm" />
|
||||||
|
</button>
|
||||||
|
{PROJECT_COLORS.map((swatch) => (
|
||||||
<button
|
<button
|
||||||
key={color}
|
key={swatch}
|
||||||
onClick={() => {
|
type="button"
|
||||||
onSelect(color);
|
onClick={() => onSelectColor(swatch)}
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
|
className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
|
||||||
color === currentColor
|
swatch === color
|
||||||
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
|
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
|
||||||
: "hover:ring-2 hover:ring-foreground/30"
|
: "hover:ring-2 hover:ring-foreground/30"
|
||||||
}`}
|
}`}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: swatch }}
|
||||||
aria-label={`Select color ${color}`}
|
aria-label={`Select color ${swatch}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</PopoverContent>
|
||||||
</div>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,9 +759,11 @@ export function ProjectDetail() {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="h-7 flex items-center">
|
<div className="h-7 flex items-center">
|
||||||
<ColorPicker
|
<ProjectTilePicker
|
||||||
currentColor={project.color ?? "#6366f1"}
|
color={project.color ?? null}
|
||||||
onSelect={(color) => updateProject.mutate({ color })}
|
icon={project.icon ?? null}
|
||||||
|
onSelectIcon={(icon) => updateProject.mutate({ icon })}
|
||||||
|
onSelectColor={(color) => updateProject.mutate({ color })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#14b8a6",
|
color: "#14b8a6",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ function makeProject(overrides: Partial<Project>): Project {
|
|||||||
leadAgentId: null,
|
leadAgentId: null,
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#ef4444",
|
color: "#ef4444",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import { useDialogActions } from "../context/DialogContext";
|
|||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { EntityRow } from "../components/EntityRow";
|
import { EntityRow } from "../components/EntityRow";
|
||||||
|
import { ProjectTile } from "../components/ProjectTile";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { MembershipAction } from "../components/MembershipAction";
|
import { MembershipAction } from "../components/MembershipAction";
|
||||||
import { EmptyState } from "../components/EmptyState";
|
import { EmptyState } from "../components/EmptyState";
|
||||||
import { PageSkeleton } from "../components/PageSkeleton";
|
import { PageSkeleton } from "../components/PageSkeleton";
|
||||||
import { formatDate, projectUrl } from "../lib/utils";
|
import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils";
|
||||||
import {
|
import {
|
||||||
resourceMembershipState,
|
resourceMembershipState,
|
||||||
useResourceMembershipMutation,
|
useResourceMembershipMutation,
|
||||||
@@ -206,6 +207,7 @@ export function Projects() {
|
|||||||
return (
|
return (
|
||||||
<EntityRow
|
<EntityRow
|
||||||
key={project.id}
|
key={project.id}
|
||||||
|
leading={<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="sm" />}
|
||||||
title={project.name}
|
title={project.name}
|
||||||
subtitle={project.description ?? undefined}
|
subtitle={project.description ?? undefined}
|
||||||
reserveSubtitleSpace
|
reserveSubtitleSpace
|
||||||
@@ -213,8 +215,19 @@ export function Projects() {
|
|||||||
className={state === "left" ? "group text-foreground/55" : "group"}
|
className={state === "left" ? "group text-foreground/55" : "group"}
|
||||||
trailing={
|
trailing={
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className="hidden text-xs text-muted-foreground tabular-nums sm:inline"
|
||||||
|
title={`${formatNumber(project.taskCount ?? 0)} task${(project.taskCount ?? 0) === 1 ? "" : "s"}`}
|
||||||
|
>
|
||||||
|
{formatNumber(project.taskCount ?? 0)} task{(project.taskCount ?? 0) === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
{project.budget && (
|
||||||
|
<span className="hidden text-xs text-muted-foreground tabular-nums sm:inline">
|
||||||
|
{formatProjectBudget(project.budget)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{project.targetDate && (
|
{project.targetDate && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="hidden text-xs text-muted-foreground md:inline">
|
||||||
{formatDate(project.targetDate)}
|
{formatDate(project.targetDate)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -346,7 +346,7 @@ export function Search() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Search issues, comments, documents, artifacts, agents, projects…"
|
placeholder="Search issues, comments, documents, agents, projects…"
|
||||||
aria-label="Search query"
|
aria-label="Search query"
|
||||||
className="h-10 pl-9 pr-20 text-sm"
|
className="h-10 pl-9 pr-20 text-sm"
|
||||||
/>
|
/>
|
||||||
@@ -456,7 +456,7 @@ function SearchTabContent({
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">Type to search company memory.</h2>
|
<h2 className="text-lg font-semibold">Type to search company memory.</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Issues, comments, plan documents, artifacts, agents, projects — same surface, ranked by relevance.
|
Issues, comments, plan documents, agents, projects — same surface, ranked by relevance.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{recentSearches.length > 0 ? (
|
{recentSearches.length > 0 ? (
|
||||||
|
|||||||
@@ -590,6 +590,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
|
|||||||
leadAgentId: "agent-codex",
|
leadAgentId: "agent-codex",
|
||||||
targetDate: "2026-04-30",
|
targetDate: "2026-04-30",
|
||||||
color: "#0f766e",
|
color: "#0f766e",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ const storybookProject: Project = {
|
|||||||
leadAgentId: "agent-codex",
|
leadAgentId: "agent-codex",
|
||||||
targetDate: null,
|
targetDate: null,
|
||||||
color: "#0f766e",
|
color: "#0f766e",
|
||||||
|
icon: null,
|
||||||
env: null,
|
env: null,
|
||||||
pauseReason: null,
|
pauseReason: null,
|
||||||
pausedAt: null,
|
pausedAt: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user