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,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
workspaces: [],
|
||||
@@ -626,6 +627,7 @@ describe("import selection catalog", () => {
|
||||
leadAgentSlug: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
status: null,
|
||||
executionWorkspacePolicy: null,
|
||||
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,
|
||||
"tag": "0097_low_trust_source_trust",
|
||||
"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),
|
||||
targetDate: date("target_date"),
|
||||
color: text("color"),
|
||||
icon: text("icon"),
|
||||
env: jsonb("env").$type<AgentEnvConfig>(),
|
||||
pauseReason: text("pause_reason"),
|
||||
pausedAt: timestamp("paused_at", { withTimezone: true }),
|
||||
|
||||
@@ -532,6 +532,7 @@ function existingProject(): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#0f766e",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -1008,6 +1008,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: declaration.color ?? null,
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -124,6 +124,56 @@ export const AGENT_ICON_NAMES = [
|
||||
] as const;
|
||||
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 = [
|
||||
"backlog",
|
||||
"todo",
|
||||
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
WORKSPACE_BRANCH_ROUTINE_VARIABLE,
|
||||
MODEL_PROFILE_KEYS,
|
||||
AGENT_ICON_NAMES,
|
||||
PROJECT_ICON_NAMES,
|
||||
ISSUE_STATUSES,
|
||||
INBOX_MINE_ISSUE_STATUSES,
|
||||
INBOX_MINE_ISSUE_STATUS_FILTER,
|
||||
@@ -169,6 +170,7 @@ export {
|
||||
type AgentRole,
|
||||
type ModelProfileKey,
|
||||
type AgentIconName,
|
||||
type ProjectIconName,
|
||||
type IssueStatus,
|
||||
type IssuePriority,
|
||||
type IssueWorkMode,
|
||||
@@ -399,6 +401,7 @@ export type {
|
||||
AdapterEnvironmentTestResult,
|
||||
AssetImage,
|
||||
Project,
|
||||
ProjectBudgetSummary,
|
||||
ProjectCodebase,
|
||||
ProjectCodebaseOrigin,
|
||||
ProjectGoalRef,
|
||||
|
||||
@@ -58,6 +58,7 @@ export interface CompanyPortabilityProjectManifestEntry {
|
||||
leadAgentSlug: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
status: string | null;
|
||||
env: AgentEnvConfig | null;
|
||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||
|
||||
@@ -159,7 +159,7 @@ export type {
|
||||
DocumentTextRange,
|
||||
UpdateDocumentAnnotationThreadRequest,
|
||||
} 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 {
|
||||
CompanySearchHighlight,
|
||||
CompanySearchArtifactSummary,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
|
||||
export interface InstanceExperimentalSettings {
|
||||
enableEnvironments: boolean;
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
enableStreamlinedLeftNavigation: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableCloudSync: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PauseReason, ProjectStatus } from "../constants.js";
|
||||
import type { BudgetWindowKind, PauseReason, ProjectStatus } from "../constants.js";
|
||||
import type {
|
||||
ProjectExecutionWorkspacePolicy,
|
||||
ProjectWorkspaceRuntimeConfig,
|
||||
@@ -14,6 +14,17 @@ export interface ProjectGoalRef {
|
||||
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 {
|
||||
id: string;
|
||||
companyId: string;
|
||||
@@ -78,6 +89,7 @@ export interface Project {
|
||||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
env: AgentEnvConfig | null;
|
||||
pauseReason: PauseReason | null;
|
||||
pausedAt: Date | null;
|
||||
@@ -86,6 +98,16 @@ export interface Project {
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | 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;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -38,6 +38,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
|
||||
export const instanceExperimentalSettingsSchema = z.object({
|
||||
enableEnvironments: z.boolean().default(false),
|
||||
enableIsolatedWorkspaces: z.boolean().default(false),
|
||||
enableStreamlinedLeftNavigation: z.boolean().default(false),
|
||||
enableIssuePlanDecompositions: z.boolean().default(false),
|
||||
enableCloudSync: z.boolean().default(false),
|
||||
autoRestartDevServerWhenIdle: z.boolean().default(false),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { trustAuthorizationPolicySchema } from "./trust-policy.js";
|
||||
|
||||
@@ -107,6 +107,7 @@ const projectFields = {
|
||||
leadAgentId: z.string().uuid().optional().nullable(),
|
||||
targetDate: z.string().optional().nullable(),
|
||||
color: z.string().optional().nullable(),
|
||||
icon: z.enum(PROJECT_ICON_NAMES).optional().nullable(),
|
||||
env: envConfigSchema.optional().nullable(),
|
||||
executionWorkspacePolicy: projectExecutionWorkspacePolicySchema.optional().nullable(),
|
||||
archivedAt: z.string().datetime().optional().nullable(),
|
||||
|
||||
@@ -969,6 +969,7 @@ describe("company portability", () => {
|
||||
leadAgentId: "agent-1",
|
||||
targetDate: "2026-03-31",
|
||||
color: "#123456",
|
||||
icon: "rocket",
|
||||
status: "planned",
|
||||
executionWorkspacePolicy: {
|
||||
enabled: true,
|
||||
@@ -1057,6 +1058,7 @@ describe("company portability", () => {
|
||||
});
|
||||
|
||||
const extension = asTextFile(exported.files[".paperclip.yaml"]);
|
||||
expect(extension).toContain('icon: "rocket"');
|
||||
expect(extension).toContain("workspaces:");
|
||||
expect(extension).toContain("main-repo:");
|
||||
expect(extension).toContain('repoUrl: "https://github.com/paperclipai/paperclip.git"');
|
||||
@@ -1144,6 +1146,9 @@ describe("company portability", () => {
|
||||
defaultProjectWorkspaceId: "workspace-imported",
|
||||
}),
|
||||
}));
|
||||
expect(projectSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
icon: "rocket",
|
||||
}));
|
||||
expect(issueSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
|
||||
projectId: "project-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 () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-portability-git-"));
|
||||
|
||||
@@ -15,6 +15,7 @@ describe("instance settings service", () => {
|
||||
})).toEqual({
|
||||
enableEnvironments: true,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableCloudSync: 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,
|
||||
ISSUE_PRIORITIES,
|
||||
ISSUE_STATUSES,
|
||||
PROJECT_ICON_NAMES,
|
||||
PROJECT_STATUSES,
|
||||
ROUTINE_CATCH_UP_POLICIES,
|
||||
ROUTINE_CONCURRENCY_POLICIES,
|
||||
@@ -471,6 +472,10 @@ function normalizePortableProjectEnv(value: unknown): AgentEnvConfig | 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(
|
||||
scope: {
|
||||
label: string;
|
||||
@@ -579,6 +584,7 @@ type ProjectLike = {
|
||||
leadAgentId: string | null;
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
status: string;
|
||||
env: Record<string, unknown> | null;
|
||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||
@@ -2842,6 +2848,7 @@ function buildManifestFromPackageFiles(
|
||||
leadAgentSlug: asString(extension.leadAgentSlug),
|
||||
targetDate: asString(extension.targetDate),
|
||||
color: asString(extension.color),
|
||||
icon: asString(extension.icon),
|
||||
status: asString(extension.status),
|
||||
env: normalizePortableProjectEnv(extension.env),
|
||||
executionWorkspacePolicy: isPlainRecord(extension.executionWorkspacePolicy)
|
||||
@@ -3617,6 +3624,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
leadAgentSlug: project.leadAgentId ? (idToSlug.get(project.leadAgentId) ?? null) : null,
|
||||
targetDate: project.targetDate ?? null,
|
||||
color: project.color ?? null,
|
||||
icon: project.icon ?? null,
|
||||
status: project.status,
|
||||
executionWorkspacePolicy: exportPortableProjectExecutionWorkspacePolicy(
|
||||
slug,
|
||||
@@ -4708,6 +4716,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
leadAgentId: projectLeadAgentId,
|
||||
targetDate: manifestProject.targetDate,
|
||||
color: manifestProject.color,
|
||||
icon: normalizeProjectIconName(manifestProject.icon),
|
||||
status: manifestProject.status && PROJECT_STATUSES.includes(manifestProject.status as any)
|
||||
? manifestProject.status as typeof PROJECT_STATUSES[number]
|
||||
: "backlog",
|
||||
|
||||
@@ -43,6 +43,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
return {
|
||||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? false,
|
||||
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
|
||||
enableCloudSync: parsed.data.enableCloudSync ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
@@ -55,6 +56,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
enableCloudSync: 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 {
|
||||
projects,
|
||||
projectGoals,
|
||||
goals,
|
||||
issues,
|
||||
budgetPolicies,
|
||||
pluginManagedResources,
|
||||
plugins,
|
||||
projectWorkspaces,
|
||||
workspaceRuntimeServices,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
PROJECT_COLORS,
|
||||
deriveProjectUrlKey,
|
||||
hasNonAsciiContent,
|
||||
isUuidLike,
|
||||
normalizeProjectUrlKey,
|
||||
type BudgetWindowKind,
|
||||
type ProjectBudgetSummary,
|
||||
type ProjectCodebase,
|
||||
type ProjectExecutionWorkspacePolicy,
|
||||
type ProjectGoalRef,
|
||||
@@ -62,6 +65,8 @@ interface ProjectWithGoals extends Omit<ProjectRow, "executionWorkspacePolicy">
|
||||
workspaces: ProjectWorkspace[];
|
||||
primaryWorkspace: ProjectWorkspace | null;
|
||||
managedByPlugin: ProjectManagedByPlugin | null;
|
||||
taskCount?: number;
|
||||
budget?: ProjectBudgetSummary | null;
|
||||
}
|
||||
|
||||
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. */
|
||||
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
|
||||
// Delete existing links
|
||||
@@ -463,13 +548,8 @@ export function projectService(db: Db) {
|
||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||
|
||||
// Auto-assign a color from the palette if none provided
|
||||
if (!projectData.color) {
|
||||
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;
|
||||
}
|
||||
// Note: color is intentionally NOT auto-assigned. New projects default to
|
||||
// `color = null` (neutral gray) unless an explicit color is supplied. See PAP-68.
|
||||
|
||||
const existingProjects = await db
|
||||
.select({ id: projects.id, name: projects.name })
|
||||
@@ -512,7 +592,8 @@ export function projectService(db: Db) {
|
||||
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
|
||||
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
|
||||
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[]> => {
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { ApiError } from "../api/client";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { agentRouteRef } from "../lib/utils";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import {
|
||||
buildDuplicateAgentPayload,
|
||||
duplicateAgentName,
|
||||
type DuplicateInstructionsBundle,
|
||||
} from "../lib/duplicate-agent-payload";
|
||||
import type {
|
||||
Agent,
|
||||
AgentInstructionsBundle,
|
||||
AgentInstructionsFileSummary,
|
||||
HeartbeatRun,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export function RunButton({
|
||||
onClick,
|
||||
@@ -49,3 +84,262 @@ export function PauseResumeButton({
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function duplicateInstructionFilePath(
|
||||
_bundle: AgentInstructionsBundle,
|
||||
summary: AgentInstructionsFileSummary,
|
||||
): string | null {
|
||||
if (summary.deprecated || summary.virtual) return null;
|
||||
return summary.path;
|
||||
}
|
||||
|
||||
export async function loadDuplicateInstructionsBundle(
|
||||
agentId: string,
|
||||
companyId?: string,
|
||||
): Promise<DuplicateInstructionsBundle | null> {
|
||||
const bundle = await agentsApi.instructionsBundle(agentId, companyId);
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
for (const summary of bundle.files) {
|
||||
const path = duplicateInstructionFilePath(bundle, summary);
|
||||
if (!path) continue;
|
||||
const file = await agentsApi.instructionsFile(agentId, summary.path, companyId);
|
||||
files[path] = file.content;
|
||||
}
|
||||
|
||||
const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile)
|
||||
? bundle.entryFile
|
||||
: Object.keys(files)[0] ?? "AGENTS.md";
|
||||
return Object.keys(files).length > 0 ? { entryFile, files } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared agent action cluster used by both the agent detail header and the
|
||||
* agents list rows. Encapsulates the invoke / pause / resume / terminate /
|
||||
* duplicate / reset-session mutations so callers do not diverge in behavior.
|
||||
*/
|
||||
export function AgentActionButtons({
|
||||
agent,
|
||||
companyId,
|
||||
size = "sm",
|
||||
assignLabel = "Assign Task",
|
||||
runLabel = "Run now",
|
||||
showStatus = true,
|
||||
actionsDisabled = false,
|
||||
navigateToRunOnInvoke = true,
|
||||
onActionError,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
agent: Agent;
|
||||
companyId?: string | null;
|
||||
size?: "sm" | "default";
|
||||
assignLabel?: string;
|
||||
runLabel?: string;
|
||||
showStatus?: boolean;
|
||||
actionsDisabled?: boolean;
|
||||
navigateToRunOnInvoke?: boolean;
|
||||
/**
|
||||
* Optional inline error reporter. When provided it is used instead of a toast
|
||||
* for action failures (preserves the detail page's inline error banner). When
|
||||
* omitted, failures surface as toasts (used by the list view).
|
||||
*/
|
||||
onActionError?: (message: string | null) => void;
|
||||
/** Extra content rendered just before the overflow menu (e.g. live-run link). */
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { pushToast } = useToastActions();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
const resolvedCompanyId = companyId ?? agent.companyId;
|
||||
const canonicalAgentRef = agentRouteRef(agent);
|
||||
const isPaused = agent.status === "paused";
|
||||
|
||||
const reportError = useCallback(
|
||||
(message: string) => {
|
||||
if (onActionError) {
|
||||
onActionError(message);
|
||||
} else {
|
||||
pushToast({ title: "Action failed", body: message, tone: "error" });
|
||||
}
|
||||
},
|
||||
[onActionError, pushToast],
|
||||
);
|
||||
|
||||
const invalidateAgent = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(canonicalAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
|
||||
}
|
||||
}, [agent.id, canonicalAgentRef, queryClient, resolvedCompanyId]);
|
||||
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
|
||||
switch (action) {
|
||||
case "invoke": return agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "pause": return agentsApi.pause(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "resume": return agentsApi.resume(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "approve": return agentsApi.approve(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "terminate": return agentsApi.terminate(agent.id, resolvedCompanyId ?? undefined);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, action) => {
|
||||
onActionError?.(null);
|
||||
invalidateAgent();
|
||||
if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
reportError(err instanceof Error ? err.message : "Action failed");
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateAgent = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!resolvedCompanyId) {
|
||||
throw new Error("Agent is not ready to duplicate");
|
||||
}
|
||||
const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId);
|
||||
const payload = buildDuplicateAgentPayload(agent, instructionsBundle);
|
||||
try {
|
||||
return await agentsApi.create(resolvedCompanyId, payload);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) {
|
||||
const hire = await agentsApi.hire(resolvedCompanyId, payload);
|
||||
return hire.agent;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: async (createdAgent) => {
|
||||
onActionError?.(null);
|
||||
if (resolvedCompanyId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
}
|
||||
pushToast({ title: "Agent duplicated", body: createdAgent.name, tone: "success" });
|
||||
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to duplicate agent";
|
||||
onActionError?.(message);
|
||||
pushToast({ title: "Could not duplicate agent", body: message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDuplicateAgent = useCallback(() => {
|
||||
if (duplicateAgent.isPending) return;
|
||||
const nextName = duplicateAgentName(agent.name);
|
||||
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
|
||||
setMoreOpen(false);
|
||||
if (!confirmed) return;
|
||||
duplicateAgent.mutate();
|
||||
}, [agent.name, duplicateAgent]);
|
||||
|
||||
const resetTaskSession = useMutation({
|
||||
mutationFn: () => agentsApi.resetSession(agent.id, null, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
onActionError?.(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
|
||||
},
|
||||
onError: (err) => {
|
||||
reportError(err instanceof Error ? err.message : "Failed to reset session");
|
||||
},
|
||||
});
|
||||
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const disabled = actionsDisabled || agentAction.isPending;
|
||||
|
||||
return (
|
||||
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{assignLabel}</span>
|
||||
</Button>
|
||||
<RunButton
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
disabled={disabled || isPendingApproval}
|
||||
label={runLabel}
|
||||
size={size}
|
||||
/>
|
||||
<PauseResumeButton
|
||||
isPaused={isPaused}
|
||||
onPause={() => agentAction.mutate("pause")}
|
||||
onResume={() => agentAction.mutate("resume")}
|
||||
disabled={disabled || isPendingApproval}
|
||||
size={size}
|
||||
/>
|
||||
{showStatus && (
|
||||
<span className="hidden sm:inline">
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Open actions for ${agent.name}`}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
disabled={duplicateAgent.isPending}
|
||||
onClick={handleDuplicateAgent}
|
||||
>
|
||||
{duplicateAgent.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
Duplicate Agent
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(agent.id);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy Agent ID
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
resetTaskSession.mutate();
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset Sessions
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
agentAction.mutate("terminate");
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Terminate
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatDate, agentUrl } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -41,7 +41,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
<StatusBadge status={agent.status} />
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Role">
|
||||
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
|
||||
|
||||
@@ -23,4 +23,27 @@ describe("EntityRow", () => {
|
||||
expect(markup).toContain("text-foreground/55");
|
||||
expect(markup).not.toContain("text-inherit");
|
||||
});
|
||||
|
||||
it("renders an optional meta slot and stops the title from flex-growing", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EntityRow
|
||||
title="Alpha"
|
||||
meta={<span data-testid="meta-cell">gpt-5.4</span>}
|
||||
trailing={<span data-testid="trailing-cell">badge</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// meta content renders alongside trailing
|
||||
expect(markup).toContain("meta-cell");
|
||||
expect(markup).toContain("trailing-cell");
|
||||
// a flex-1 spacer is inserted (between meta and trailing); the title block
|
||||
// itself no longer flex-grows
|
||||
expect(markup).toContain('class="flex-1"');
|
||||
expect(markup).not.toContain("min-w-0 flex-1");
|
||||
});
|
||||
|
||||
it("keeps the title flex-growing when no meta is provided", () => {
|
||||
const markup = renderToStaticMarkup(<EntityRow title="Alpha" />);
|
||||
expect(markup).toContain("min-w-0 flex-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,18 @@ interface EntityRowProps {
|
||||
identifier?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Optional metadata columns rendered immediately after the title. When set,
|
||||
* the title stops flex-growing and a spacer is inserted between `meta` and
|
||||
* `trailing`, so meta sits next to the name while trailing stays pinned right.
|
||||
*/
|
||||
meta?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
selected?: boolean;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
reserveSubtitleSpace?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,11 +27,13 @@ export function EntityRow({
|
||||
identifier,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
trailing,
|
||||
selected,
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
titleClassName,
|
||||
reserveSubtitleSpace,
|
||||
}: EntityRowProps) {
|
||||
const isClickable = !!(to || onClick);
|
||||
@@ -38,14 +47,14 @@ export function EntityRow({
|
||||
const content = (
|
||||
<>
|
||||
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn("min-w-0", !meta && "flex-1", titleClassName)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{identifier && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-[1px]">
|
||||
{identifier}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{title}</span>
|
||||
<span className="truncate" title={title}>{title}</span>
|
||||
</div>
|
||||
{(subtitle || reserveSubtitleSpace) && (
|
||||
<p
|
||||
@@ -56,6 +65,8 @@ export function EntityRow({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="flex items-center gap-2 shrink-0">{meta}</div>}
|
||||
{meta && <div className="flex-1" />}
|
||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -294,6 +294,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#6366f1",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PROJECT_COLORS } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
@@ -165,7 +164,7 @@ export function NewProjectDialog() {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
|
||||
// No color is sent — new projects persist color = null (neutral gray). See PAP-68.
|
||||
...(goalIds.length > 0 ? { goalIds } : {}),
|
||||
...(targetDate ? { targetDate } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
|
||||
describe("ProjectTile", () => {
|
||||
it("renders a neutral gray folder tile by default", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile />);
|
||||
// Neutral tile uses the muted token, never an inline background color.
|
||||
expect(markup).toContain("bg-muted");
|
||||
expect(markup).toContain("text-muted-foreground");
|
||||
expect(markup).not.toContain("background-color");
|
||||
// Folder icon is the default when no icon prop is supplied.
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("renders the chosen Lucide icon when an icon prop is provided", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile icon="rocket" />);
|
||||
expect(markup).toContain("lucide-rocket");
|
||||
expect(markup).not.toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("falls back to the folder icon for an unknown icon name", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile icon="not-a-real-icon" />);
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("tints the background when a color is provided", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile color="#22c55e" />);
|
||||
expect(markup).toContain("background-color:#22c55e");
|
||||
expect(markup).toContain("text-white");
|
||||
// Tinted tile drops the muted neutral background.
|
||||
expect(markup).not.toContain("bg-muted");
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("treats null color as neutral (not tinted)", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile color={null} />);
|
||||
expect(markup).toContain("bg-muted");
|
||||
expect(markup).not.toContain("background-color");
|
||||
});
|
||||
|
||||
it("applies size styles", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile size="lg" />);
|
||||
expect(markup).toContain("h-9 w-9");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from "../lib/utils";
|
||||
import { getProjectIcon } from "../lib/project-icons";
|
||||
|
||||
/**
|
||||
* Reusable project tile (IA Phase 3 — PAP-58; icon picker added PAP-68 part 3).
|
||||
*
|
||||
* Default render is a neutral gray rounded rectangle with a folder icon.
|
||||
* An optional `color` tints the background; an optional `icon` selects which
|
||||
* Lucide glyph to render (defaults to `folder`).
|
||||
*
|
||||
* Used by the Projects list rows and the project detail header. Both `color`
|
||||
* and `icon` live on the project itself (`project.color` / `project.icon`).
|
||||
*/
|
||||
|
||||
export type ProjectTileSize = "xs" | "sm" | "md" | "lg";
|
||||
|
||||
const SIZE_STYLES: Record<ProjectTileSize, { box: string; icon: string }> = {
|
||||
xs: { box: "h-4 w-4 rounded-sm", icon: "h-2.5 w-2.5" },
|
||||
sm: { box: "h-6 w-6 rounded-md", icon: "h-3.5 w-3.5" },
|
||||
md: { box: "h-7 w-7 rounded-lg", icon: "h-4 w-4" },
|
||||
lg: { box: "h-9 w-9 rounded-lg", icon: "h-5 w-5" },
|
||||
};
|
||||
|
||||
export interface ProjectTileProps {
|
||||
/** Optional project color. When unset, the tile stays neutral gray. */
|
||||
color?: string | null;
|
||||
/** Optional Lucide icon name. When unset, defaults to `folder`. */
|
||||
icon?: string | null;
|
||||
size?: ProjectTileSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectTile({ color, icon, size = "md", className }: ProjectTileProps) {
|
||||
const dims = SIZE_STYLES[size];
|
||||
const tinted = Boolean(color);
|
||||
const Icon = getProjectIcon(icon);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center",
|
||||
dims.box,
|
||||
tinted ? "text-white" : "bg-muted text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
style={tinted ? { backgroundColor: color ?? undefined } : undefined}
|
||||
>
|
||||
<Icon className={dims.icon} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ function createProject(): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#22c55e",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -82,12 +82,14 @@ vi.mock("./SidebarCompanyMenu", () => ({
|
||||
SidebarCompanyMenu: () => <div>Company menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => null,
|
||||
vi.mock("./SidebarAgents", () => ({
|
||||
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
|
||||
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarAgents", () => ({
|
||||
SidebarAgents: () => null,
|
||||
vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
|
||||
}));
|
||||
|
||||
async function flushReact() {
|
||||
@@ -146,7 +148,10 @@ describe("Sidebar", () => {
|
||||
});
|
||||
|
||||
it("renders plugin sidebar launchers inside the Work section", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
|
||||
@@ -162,6 +167,60 @@ describe("Sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("streamlined (flag ON): keeps Issue wording, top-level Projects link, no per-project collapsible", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Issue");
|
||||
expect(container.textContent).not.toContain("New Task");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Issues");
|
||||
expect(navLabels).not.toContain("Tasks");
|
||||
|
||||
const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects");
|
||||
expect(projectsLink?.getAttribute("href")).toBe("/projects");
|
||||
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("classic (flag OFF): New Issue button, Issues label, per-project collapsible, no top-level Projects link", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Issue");
|
||||
expect(container.textContent).not.toContain("New Task");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Issues");
|
||||
expect(navLabels).not.toContain("Tasks");
|
||||
// No top-level Projects nav link in classic mode (D5 option A).
|
||||
expect(navLabels).not.toContain("Projects");
|
||||
|
||||
// Per-project collapsible restored below Work.
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders plugin sidebar slots in Work below Workspaces", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = await renderSidebar();
|
||||
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
GitBranch,
|
||||
Package,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -47,6 +48,11 @@ export function Sidebar() {
|
||||
});
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
// IA flag (PAP-89): branch the sidebar nav presentation. Default OFF = classic
|
||||
// (per-project collapsible, no Projects nav link). ON = streamlined
|
||||
// (top-level Projects link). Issue/Task wording is split to PR #7651.
|
||||
// Gating is navigation-only; all routes stay registered in both modes.
|
||||
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true;
|
||||
|
||||
const pluginContext = {
|
||||
companyId: selectedCompanyId,
|
||||
@@ -78,7 +84,7 @@ export function Sidebar() {
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
data-slot="icon-button"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
@@ -102,6 +108,9 @@ export function Sidebar() {
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
{streamlined ? (
|
||||
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["sidebar"]}
|
||||
context={pluginContext}
|
||||
@@ -117,9 +126,10 @@ export function Sidebar() {
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
{/* Classic mode restores the per-project collapsible below Work. */}
|
||||
{streamlined ? null : <SidebarProjects />}
|
||||
|
||||
<SidebarAgents />
|
||||
<SidebarAgents streamlined={streamlined} />
|
||||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
|
||||
@@ -186,10 +186,19 @@ async function chooseSortMode(label: string) {
|
||||
|
||||
function agentLinkLabels(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll('a[href^="/agents/"]'))
|
||||
.filter((anchor) => anchor.getAttribute("href") !== "/agents/all")
|
||||
.map((anchor) => anchor.textContent?.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function seeAllAgentsLink(container: HTMLElement) {
|
||||
return (
|
||||
Array.from(container.querySelectorAll('a[href="/agents/all"]')).find((anchor) =>
|
||||
anchor.textContent?.includes("See all agents"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
describe("SidebarAgents", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
@@ -249,7 +258,21 @@ describe("SidebarAgents", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderSidebarAgents() {
|
||||
async function renderSidebarAgents(streamlined = true) {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents streamlined={streamlined} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function renderSidebarAgentsWithDefaultProps() {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
@@ -467,6 +490,103 @@ describe("SidebarAgents", () => {
|
||||
expect(document.body.textContent).not.toContain("Updating...");
|
||||
});
|
||||
|
||||
it("shows only active agents when any agent has a live run", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
{ id: "run-1", agentId: "agent-b", status: "running" },
|
||||
]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
|
||||
const labels = agentLinkLabels(container);
|
||||
expect(labels).toHaveLength(1);
|
||||
expect(labels[0]).toContain("Bravo");
|
||||
// PAP-76: the full-list entry point stays visible even when only active
|
||||
// agents are shown.
|
||||
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("shows up to 5 recently-active agents plus a See all link when none are running", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(5);
|
||||
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("classic mode (flag OFF) shows all agents and no See all link even when one is running", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
{ id: "run-1", agentId: "agent-b", status: "running" },
|
||||
]);
|
||||
|
||||
await renderSidebarAgents(false);
|
||||
|
||||
// Show-all: every agent is listed regardless of live-run state. (Bravo's
|
||||
// label includes its live-run badge text, so match by prefix.)
|
||||
const labels = agentLinkLabels(container);
|
||||
expect(labels).toHaveLength(3);
|
||||
expect(labels[0]).toBe("Alpha");
|
||||
expect(labels[1]).toContain("Bravo");
|
||||
expect(labels[2]).toBe("Charlie");
|
||||
// No recent-5 truncation, so no "See all agents" link in classic mode.
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("classic mode (flag OFF) shows more than 5 agents without truncation", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgents(false);
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(7);
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to classic mode when rendered outside the Sidebar flag path", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgentsWithDefaultProps();
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(7);
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not offer sidebar resume for budget-paused agents", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({
|
||||
|
||||
@@ -44,6 +44,12 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* When no agent is running, the sidebar falls back to showing at most this many
|
||||
* recently-active agents plus a "See all agents" link (IA Phase 5).
|
||||
*/
|
||||
const RECENT_AGENT_LIMIT = 5;
|
||||
|
||||
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "alphabetical", label: "Alphabetical" },
|
||||
@@ -210,7 +216,7 @@ function SidebarAgentItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarAgents() {
|
||||
export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const queryClient = useQueryClient();
|
||||
@@ -277,6 +283,25 @@ export function SidebarAgents() {
|
||||
[orderedAgents, sortMode],
|
||||
);
|
||||
|
||||
// IA Phase 5 (streamlined): if any agent has a live run, show only those
|
||||
// active agents. Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either
|
||||
// way a "See all agents" link is shown so the full list is always reachable.
|
||||
// Classic mode (PAP-89, flag OFF) restores the show-all behavior.
|
||||
const runningAgents = useMemo(
|
||||
() => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0),
|
||||
[sortedAgents, liveCountByAgent],
|
||||
);
|
||||
const hasActiveAgents = runningAgents.length > 0;
|
||||
const displayedAgents = !streamlined
|
||||
? sortedAgents
|
||||
: hasActiveAgents
|
||||
? runningAgents
|
||||
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
|
||||
// Always expose "See all agents" whenever the displayed list is a subset of all
|
||||
// agents, so users never lose the entry point to the full list. In classic mode
|
||||
// every agent is already shown, so the link is unnecessary.
|
||||
const showSeeAllLink = streamlined && sortedAgents.length > 0;
|
||||
|
||||
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
||||
const activeAgentId = agentMatch?.[1] ?? null;
|
||||
const activeTab = agentMatch?.[2] ?? null;
|
||||
@@ -406,7 +431,7 @@ export function SidebarAgents() {
|
||||
onRadioValueChange: persistSortMode,
|
||||
}}
|
||||
>
|
||||
{sortedAgents.map((agent: Agent) => {
|
||||
{displayedAgents.map((agent: Agent) => {
|
||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||
return (
|
||||
<SidebarAgentItem
|
||||
@@ -424,6 +449,19 @@ export function SidebarAgents() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showSeeAllLink && (
|
||||
<Link
|
||||
to="/agents/all"
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<Users className="shrink-0 h-3.5 w-3.5" />
|
||||
<span>See all agents</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ function makeProject(overrides: Partial<Project>): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#ef4444",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -139,10 +140,7 @@ function ProjectItem({
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
||||
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
||||
/>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { cn } from "../lib/utils";
|
||||
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
||||
import {
|
||||
statusBadge,
|
||||
statusBadgeDefault,
|
||||
agentStatusColor,
|
||||
agentStatusColorDefault,
|
||||
agentStatusBadge,
|
||||
agentStatusCapsule,
|
||||
agentStatusMotion,
|
||||
} from "../lib/status-colors";
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
@@ -13,3 +21,39 @@ export function StatusBadge({ status }: { status: string }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent status chip — brand `.task-chip` (1px border, light/dark variants).
|
||||
* Distinct from the shared {@link StatusBadge} so the agents section can carry
|
||||
* the brand state colours without affecting run/issue/goal badges. `active`
|
||||
* renders as "idle" (alias for dead code).
|
||||
*/
|
||||
export function AgentStatusBadge({ status }: { status: string }) {
|
||||
const color = agentStatusColor[status] ?? agentStatusColorDefault;
|
||||
const label = status === "active" ? "idle" : status;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium leading-none whitespace-nowrap shrink-0",
|
||||
agentStatusBadge[color]
|
||||
)}
|
||||
>
|
||||
{label.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent status indicator — brand heartbeat capsule (vertical 8×16, r4). Running
|
||||
* agents pulse, broken (error) agents blink; both honor `prefers-reduced-motion`.
|
||||
*/
|
||||
export function AgentStatusCapsule({ status }: { status: string }) {
|
||||
const color = agentStatusColor[status] ?? agentStatusColorDefault;
|
||||
const motion = agentStatusMotion[status] ?? "";
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("inline-block h-4 w-2 rounded-[4px] shrink-0", agentStatusCapsule[color], motion)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
.paperclip-mdxeditor-scope,
|
||||
.paperclip-mdxeditor {
|
||||
|
||||
@@ -46,6 +46,7 @@ function makeProject(id: string, name: string): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("issueDetailBreadcrumb", () => {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -594,6 +594,7 @@ describe("optimistic issue comments", () => {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: 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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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> = {
|
||||
running: "bg-cyan-400 animate-pulse",
|
||||
active: "bg-green-400",
|
||||
paused: "bg-yellow-400",
|
||||
idle: "bg-yellow-400",
|
||||
pending_approval: "bg-amber-400",
|
||||
error: "bg-red-400",
|
||||
archived: "bg-neutral-400",
|
||||
export type AgentBadgeColor = "gray" | "blue" | "amber" | "red";
|
||||
|
||||
/** Agent status → brand colour name. `active` aliases idle (never assigned). */
|
||||
export const agentStatusColor: Record<string, AgentBadgeColor> = {
|
||||
idle: "gray",
|
||||
active: "gray",
|
||||
running: "blue",
|
||||
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
|
||||
|
||||
@@ -29,6 +29,15 @@ export function formatNumber(n: number): string {
|
||||
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 {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
|
||||
+24
-201
@@ -20,7 +20,6 @@ import { usePanel } from "../context/PanelContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
@@ -33,14 +32,13 @@ import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { MembershipAction } from "../components/MembershipAction";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
||||
import { AgentActionButtons } from "../components/AgentActionButtons";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { TrustPresetSection } from "../components/TrustPresetSection";
|
||||
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 { cn } from "../lib/utils";
|
||||
import { describeRunRetryState } from "../lib/runRetryState";
|
||||
import { buildDuplicateAgentPayload, duplicateAgentName, type DuplicateInstructionsBundle } from "../lib/duplicate-agent-payload";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
@@ -71,7 +62,6 @@ import {
|
||||
Loader2,
|
||||
Slash,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Plus,
|
||||
Key,
|
||||
Eye,
|
||||
@@ -91,8 +81,6 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
|
||||
import {
|
||||
isUuidLike,
|
||||
type Agent,
|
||||
type AgentInstructionsBundle,
|
||||
type AgentInstructionsFileSummary,
|
||||
type AgentSkillEntry,
|
||||
type AgentSkillSnapshot,
|
||||
type AgentDetail as AgentDetailRecord,
|
||||
@@ -117,34 +105,6 @@ import {
|
||||
isReadOnlyUnmanagedSkillEntry,
|
||||
} 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 }> = {
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-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 { closePanel } = usePanel();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { pushToast } = useToastActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null);
|
||||
const needsDashboardData = activeView === "dashboard";
|
||||
const needsRunData = activeView === "runs" || Boolean(urlRunId);
|
||||
@@ -824,18 +781,17 @@ export function AgentDetail() {
|
||||
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
|
||||
}, [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({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
|
||||
mutationFn: async (action: "approve") => {
|
||||
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
|
||||
switch (action) {
|
||||
case "invoke": return agentsApi.invoke(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);
|
||||
if (action === "approve") {
|
||||
return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, action) => {
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
||||
@@ -847,66 +803,12 @@ export function AgentDetail() {
|
||||
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) => {
|
||||
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({
|
||||
mutationFn: (amount: number) =>
|
||||
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({
|
||||
mutationFn: (permissions: AgentPermissionUpdate) =>
|
||||
agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
|
||||
@@ -1086,27 +975,14 @@ export function AgentDetail() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<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>
|
||||
<AgentActionButtons
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId}
|
||||
assignLabel="Assign Task"
|
||||
runLabel="Run Heartbeat"
|
||||
actionsDisabled={agentAction.isPending}
|
||||
onActionError={setActionError}
|
||||
>
|
||||
{mobileLiveRun && (
|
||||
<Link
|
||||
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>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</AgentActionButtons>
|
||||
</div>
|
||||
|
||||
{!urlRunId && (
|
||||
@@ -1454,10 +1277,10 @@ function AgentOverview({
|
||||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||
<RunActivityChart runs={runs} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
|
||||
<PriorityChart issues={assignedIssues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Status" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
|
||||
<IssueStatusChart issues={assignedIssues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
||||
@@ -1468,7 +1291,7 @@ function AgentOverview({
|
||||
{/* Recent Issues */}
|
||||
<div className="space-y-3">
|
||||
<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
|
||||
to={`/issues?participantAgentId=${agentId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -1477,7 +1300,7 @@ function AgentOverview({
|
||||
</Link>
|
||||
</div>
|
||||
{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">
|
||||
{assignedIssues.slice(0, 10).map((issue) => (
|
||||
@@ -1491,7 +1314,7 @@ function AgentOverview({
|
||||
))}
|
||||
{assignedIssues.length > 10 && (
|
||||
<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>
|
||||
@@ -3651,7 +3474,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
>
|
||||
{clearSessionsForTouchedIssues.isPending
|
||||
? "clearing session..."
|
||||
: "clear session for these issues"}
|
||||
: "clear session for these tasks"}
|
||||
</button>
|
||||
{clearSessionsForTouchedIssues.isError && (
|
||||
<p className="text-[11px] text-destructive mt-1">
|
||||
@@ -3671,7 +3494,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
{/* Issues touched by this run */}
|
||||
{touchedIssues && touchedIssues.length > 0 && (
|
||||
<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">
|
||||
{touchedIssues.map((issue) => (
|
||||
<Link
|
||||
|
||||
@@ -126,7 +126,11 @@ describe("Agents", () => {
|
||||
});
|
||||
|
||||
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([
|
||||
{
|
||||
@@ -180,5 +184,44 @@ describe("Agents", () => {
|
||||
|
||||
expect(container.textContent).toContain("codex_local");
|
||||
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 { useSidebar } from "../context/SidebarContext";
|
||||
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 { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
@@ -18,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
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 {
|
||||
resourceMembershipState,
|
||||
@@ -32,8 +32,12 @@ const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
type FilterTab = "all" | "active" | "paused" | "error";
|
||||
|
||||
function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean): boolean {
|
||||
if (status === "terminated") return showTerminated;
|
||||
// Agents in these states never appear in the agents list — `terminated` is
|
||||
// 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 === "active") return status === "active" || status === "running" || status === "idle";
|
||||
if (tab === "paused") return status === "paused";
|
||||
@@ -41,9 +45,9 @@ function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean):
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] {
|
||||
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -54,11 +58,17 @@ function getConfiguredModel(agent: Agent): string | 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
|
||||
.reduce<OrgNode[]>((acc, node) => {
|
||||
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
|
||||
if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
|
||||
const filteredReports = filterOrgTree(node.reports, tab);
|
||||
// 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 });
|
||||
}
|
||||
return acc;
|
||||
@@ -78,8 +88,6 @@ export function Agents() {
|
||||
const [view, setView] = useState<"list" | "org">("org");
|
||||
const forceListView = isMobile;
|
||||
const effectiveView: "list" | "org" = forceListView ? "list" : view;
|
||||
const [showTerminated, setShowTerminated] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const { data: agents, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -135,8 +143,8 @@ export function Agents() {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
const filtered = filterAgents(agents ?? [], tab, showTerminated);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
|
||||
const filtered = filterAgents(agents ?? [], tab);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -154,36 +162,6 @@ export function Agents() {
|
||||
/>
|
||||
</Tabs>
|
||||
<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 */}
|
||||
{!forceListView && (
|
||||
<div className="flex items-center border border-border">
|
||||
@@ -237,6 +215,11 @@ export function Agents() {
|
||||
<EntityRow
|
||||
key={agent.id}
|
||||
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}` : ""}`}
|
||||
to={agentUrl(agent)}
|
||||
className={cn(
|
||||
@@ -244,12 +227,11 @@ export function Agents() {
|
||||
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
||||
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
|
||||
)}
|
||||
leading={
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[agent.status] ?? agentStatusDotDefault}`}
|
||||
/>
|
||||
</span>
|
||||
leading={<AgentStatusCapsule status={agent.status} />}
|
||||
meta={
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<AgentMetaColumns agent={agent} />
|
||||
</div>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -261,7 +243,7 @@ export function Agents() {
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={agent.status} />
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
)}
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
@@ -272,22 +254,25 @@ export function Agents() {
|
||||
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">
|
||||
<StatusBadge status={agent.status} />
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</span>
|
||||
</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
|
||||
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
|
||||
pending={
|
||||
@@ -386,8 +371,6 @@ function OrgTreeNode({
|
||||
membershipMutation.variables?.resourceType === "agent" &&
|
||||
membershipMutation.variables.resourceId === node.id;
|
||||
|
||||
const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault;
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 24 }}>
|
||||
<Link
|
||||
@@ -398,10 +381,8 @@ function OrgTreeNode({
|
||||
membershipState === "left" && "text-foreground/55",
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<AgentStatusCapsule status={node.status} />
|
||||
<div className="flex-1 min-w-[7rem]">
|
||||
<span className="text-sm font-medium">{node.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{roleLabels[node.role] ?? node.role}
|
||||
@@ -417,7 +398,7 @@ function OrgTreeNode({
|
||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={node.status} />
|
||||
<AgentStatusBadge status={node.status} />
|
||||
)}
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
@@ -429,23 +410,12 @@ function OrgTreeNode({
|
||||
/>
|
||||
)}
|
||||
{agent && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<AgentMetaColumns agent={agent} />
|
||||
</div>
|
||||
)}
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={node.status} />
|
||||
<AgentStatusBadge status={node.status} />
|
||||
</span>
|
||||
</div>
|
||||
<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({
|
||||
agentRef,
|
||||
runId,
|
||||
|
||||
@@ -112,10 +112,9 @@ import {
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
} from "@/components/ui/avatar";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "@/lib/status-colors";
|
||||
import { EntityRow } from "@/components/EntityRow";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { MetricCard } from "@/components/MetricCard";
|
||||
@@ -551,14 +550,18 @@ export function DesignGuide() {
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Agent status dots">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{(["running", "active", "paused", "error", "archived"] as const).map((label) => (
|
||||
<SubSection title="Agent status (capsule + chip)">
|
||||
<p className="text-xs text-muted-foreground mb-3 max-w-prose">
|
||||
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">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className={`inline-flex h-full w-full rounded-full ${agentStatusDot[label] ?? agentStatusDotDefault}`} />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<AgentStatusCapsule status={label} />
|
||||
<AgentStatusBadge status={label} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -155,6 +155,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -205,6 +205,8 @@ export function InstanceExperimentalSettings() {
|
||||
|
||||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const enableStreamlinedLeftNavigation =
|
||||
experimentalQuery.data?.enableStreamlinedLeftNavigation === true;
|
||||
const enableIssuePlanDecompositions =
|
||||
experimentalQuery.data?.enableIssuePlanDecompositions === true;
|
||||
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
||||
@@ -301,6 +303,28 @@ export function InstanceExperimentalSettings() {
|
||||
</div>
|
||||
</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">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -116,6 +116,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
+104
-41
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
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 { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
@@ -18,6 +18,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { ProjectTile } from "../components/ProjectTile";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
@@ -27,7 +28,11 @@ import { MembershipAction } from "../components/MembershipAction";
|
||||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { projectRouteRef } from "../lib/utils";
|
||||
import { PROJECT_ICONS } from "../lib/project-icons";
|
||||
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 { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
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({
|
||||
currentColor,
|
||||
onSelect,
|
||||
const DEFAULT_PROJECT_ICON = "folder";
|
||||
|
||||
function ProjectTilePicker({
|
||||
color,
|
||||
icon,
|
||||
onSelectIcon,
|
||||
onSelectColor,
|
||||
}: {
|
||||
currentColor: string;
|
||||
onSelect: (color: string) => void;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
onSelectIcon: (icon: string) => void;
|
||||
onSelectColor: (color: string | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
const filteredIcons = useMemo(() => {
|
||||
const entries = PROJECT_ICON_NAMES.map((name) => [name, PROJECT_ICONS[name]] as const);
|
||||
if (!search) return entries;
|
||||
const q = search.toLowerCase();
|
||||
return entries.filter(([name]) => name.includes(q));
|
||||
}, [search]);
|
||||
|
||||
// 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 (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
|
||||
style={{ backgroundColor: currentColor }}
|
||||
aria-label="Change project color"
|
||||
/>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<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">
|
||||
{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
|
||||
key={color}
|
||||
onClick={() => {
|
||||
onSelect(color);
|
||||
setOpen(false);
|
||||
}}
|
||||
key={swatch}
|
||||
type="button"
|
||||
onClick={() => onSelectColor(swatch)}
|
||||
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"
|
||||
: "hover:ring-2 hover:ring-foreground/30"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
style={{ backgroundColor: swatch }}
|
||||
aria-label={`Select color ${swatch}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -698,9 +759,11 @@ export function ProjectDetail() {
|
||||
) : null}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-7 flex items-center">
|
||||
<ColorPicker
|
||||
currentColor={project.color ?? "#6366f1"}
|
||||
onSelect={(color) => updateProject.mutate({ color })}
|
||||
<ProjectTilePicker
|
||||
color={project.color ?? null}
|
||||
icon={project.icon ?? null}
|
||||
onSelectIcon={(icon) => updateProject.mutate({ icon })}
|
||||
onSelectColor={(color) => updateProject.mutate({ color })}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
|
||||
@@ -137,6 +137,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -76,6 +76,7 @@ function makeProject(overrides: Partial<Project>): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#ef4444",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -7,11 +7,12 @@ import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { ProjectTile } from "../components/ProjectTile";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { MembershipAction } from "../components/MembershipAction";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { formatDate, projectUrl } from "../lib/utils";
|
||||
import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils";
|
||||
import {
|
||||
resourceMembershipState,
|
||||
useResourceMembershipMutation,
|
||||
@@ -206,6 +207,7 @@ export function Projects() {
|
||||
return (
|
||||
<EntityRow
|
||||
key={project.id}
|
||||
leading={<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="sm" />}
|
||||
title={project.name}
|
||||
subtitle={project.description ?? undefined}
|
||||
reserveSubtitleSpace
|
||||
@@ -213,8 +215,19 @@ export function Projects() {
|
||||
className={state === "left" ? "group text-foreground/55" : "group"}
|
||||
trailing={
|
||||
<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 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="hidden text-xs text-muted-foreground md:inline">
|
||||
{formatDate(project.targetDate)}
|
||||
</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"
|
||||
className="h-10 pl-9 pr-20 text-sm"
|
||||
/>
|
||||
@@ -456,7 +456,7 @@ function SearchTabContent({
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Type to search company memory.</h2>
|
||||
<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>
|
||||
</div>
|
||||
{recentSearches.length > 0 ? (
|
||||
|
||||
@@ -590,6 +590,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: "agent-codex",
|
||||
targetDate: "2026-04-30",
|
||||
color: "#0f766e",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -296,6 +296,7 @@ const storybookProject: Project = {
|
||||
leadAgentId: "agent-codex",
|
||||
targetDate: null,
|
||||
color: "#0f766e",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
Reference in New Issue
Block a user