Merge origin/master into PAP-10430 task copy PR

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-06 14:21:12 +00:00
commit 9e88e3ba85
86 files changed
+23506 -527

No files matched your search

+2
View File
@@ -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: [],
@@ -163,6 +163,7 @@ describe("issue subresource commands", () => {
]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-client-keys", "yes"]);
await run(["issue", "interaction:accept", ISSUE_ID, INTERACTION_ID, "--selected-option-ids", "file-a,file-b"]);
await run(["issue", "interaction:reject", ISSUE_ID, INTERACTION_ID, "--reason", "no"]);
await run(["issue", "interaction:cancel", ISSUE_ID, INTERACTION_ID, "--reason", "stale"]);
await run([
@@ -196,6 +197,7 @@ describe("issue subresource commands", () => {
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/accept`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/reject`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/cancel`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/interactions/${INTERACTION_ID}/respond`],
@@ -215,6 +217,9 @@ describe("issue subresource commands", () => {
["GET", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
["POST", `http://localhost:3100/api/issues/${ISSUE_ID}/feedback-votes`],
]);
expect(JSON.parse(String(fetchMock.mock.calls[4]?.[1]?.body))).toEqual({
selectedOptionIds: ["file-a", "file-b"],
});
});
});
+1
View File
@@ -24,6 +24,7 @@ describe("project and goal commands", () => {
vi.restoreAllMocks();
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_COMPANY_ID;
});
afterEach(() => {
+4 -1
View File
@@ -149,6 +149,7 @@ interface IssueRecoveryResolveOptions extends BaseClientOptions {
interface InteractionAcceptOptions extends BaseClientOptions {
selectedClientKeys?: string;
selectedOptionIds?: string;
}
interface InteractionReasonOptions extends BaseClientOptions {
@@ -745,11 +746,13 @@ export function registerIssueCommands(program: Command): void {
.argument("<issueId>", "Issue ID")
.argument("<interactionId>", "Interaction ID")
.option("--selected-client-keys <csv>", "Client keys to accept")
.option("--selected-option-ids <csv>", "Checkbox option IDs to accept")
.action(async (issueId: string, interactionId: string, opts: InteractionAcceptOptions) => {
try {
const ctx = resolveCommandContext(opts);
const payload = acceptIssueThreadInteractionSchema.parse({
selectedClientKeys: opts.selectedClientKeys === undefined ? undefined : parseCsv(opts.selectedClientKeys),
selectedOptionIds: opts.selectedOptionIds === undefined ? undefined : parseCsv(opts.selectedOptionIds),
});
const interaction = await ctx.api.post(apiPath`/api/issues/${issueId}/interactions/${interactionId}/accept`, payload);
printOutput(interaction, { json: ctx.json });
@@ -761,7 +764,7 @@ export function registerIssueCommands(program: Command): void {
for (const [name, action, schema, description] of [
["interaction:reject", "reject", rejectIssueThreadInteractionSchema, "Reject an issue thread interaction"],
["interaction:cancel", "cancel", cancelIssueThreadInteractionSchema, "Cancel an ask_user_questions issue thread interaction"],
["interaction:cancel", "cancel", cancelIssueThreadInteractionSchema, "Cancel an issue thread interaction"],
] as const) {
addCommonClientOptions(
issue
@@ -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
}
]
}
+1
View File
@@ -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 }),
+56
View File
@@ -292,6 +292,62 @@ describe("paperclip MCP tools", () => {
});
});
it("creates request_checkbox_confirmation interactions with checkbox payloads", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "interaction-1", kind: "request_checkbox_confirmation" }),
);
vi.stubGlobal("fetch", fetchMock);
const tool = getTool("paperclipRequestCheckboxConfirmation");
await tool.execute({
issueId: "PAP-1135",
idempotencyKey: "confirmation:PAP-1135:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
detailsMarkdown: "Pick the files to attach.",
options: [
{ id: "file-a", label: "File A", description: "Primary draft" },
{ id: "file-b", label: "File B" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
acceptLabel: "Use selected files",
rejectLabel: "Do not attach files",
rejectRequiresReason: true,
allowDeclineReason: false,
},
});
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(String(url)).toBe("http://localhost:3100/api/issues/PAP-1135/interactions");
expect(init.method).toBe("POST");
expect(JSON.parse(String(init.body))).toEqual({
kind: "request_checkbox_confirmation",
continuationPolicy: "wake_assignee",
idempotencyKey: "confirmation:PAP-1135:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
detailsMarkdown: "Pick the files to attach.",
options: [
{ id: "file-a", label: "File A", description: "Primary draft" },
{ id: "file-b", label: "File B" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
acceptLabel: "Use selected files",
rejectLabel: "Do not attach files",
rejectRequiresReason: true,
allowDeclineReason: false,
},
});
});
it("creates approvals with the expected company-scoped payload", async () => {
const fetchMock = vi.fn().mockResolvedValue(
mockJsonResponse({ id: "approval-1" }),
+24
View File
@@ -6,6 +6,7 @@ import {
createApprovalSchema,
createIssueInputSchema,
issueThreadInteractionContinuationPolicySchema,
requestCheckboxConfirmationPayloadSchema,
requestConfirmationPayloadSchema,
suggestTasksPayloadSchema,
updateIssueSchema,
@@ -144,6 +145,17 @@ const createRequestConfirmationToolSchema = z.object({
payload: requestConfirmationPayloadSchema,
});
const createRequestCheckboxConfirmationToolSchema = z.object({
issueId: issueIdSchema,
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
});
const approvalDecisionSchema = z.object({
approvalId: approvalIdSchema,
action: z.enum(["approve", "reject", "requestRevision", "resubmit"]),
@@ -516,6 +528,18 @@ export function createToolDefinitions(client: PaperclipApiClient): ToolDefinitio
},
}),
),
makeTool(
"paperclipRequestCheckboxConfirmation",
"Create a request_checkbox_confirmation interaction on an issue",
createRequestCheckboxConfirmationToolSchema,
async ({ issueId, ...body }) =>
client.requestJson("POST", `/issues/${encodeURIComponent(issueId)}/interactions`, {
body: {
kind: "request_checkbox_confirmation",
...body,
},
}),
),
makeTool(
"paperclipUpsertIssueDocument",
"Create or update an issue document",
@@ -532,6 +532,7 @@ function existingProject(): Project {
leadAgentId: null,
targetDate: null,
color: "#0f766e",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+4
View File
@@ -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,
@@ -1680,6 +1681,9 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
async requestConfirmation(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "request_confirmation" }, companyId, options) as Promise<any>;
},
async requestCheckboxConfirmation(issueId, interaction, companyId, options) {
return this.createInteraction(issueId, { ...interaction, kind: "request_checkbox_confirmation" }, companyId, options) as Promise<any>;
},
documents: {
async list(issueId, companyId) {
requireCapability(manifest, capabilitySet, "issue.documents.read");
+9 -1
View File
@@ -27,6 +27,7 @@ import type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
IssueSurfaceVisibility,
@@ -122,6 +123,7 @@ export type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
CreateIssueThreadInteraction,
PluginIssueOriginKind,
IssueSurfaceVisibility,
@@ -1321,7 +1323,7 @@ export interface PluginIssueSummariesClient {
* - `issues.orchestration.read` for orchestration summaries
* - `issue.comments.read` for `listComments`
* - `issue.comments.create` for `createComment`
* - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, and `requestConfirmation`
* - `issue.interactions.create` for `createInteraction`, `suggestTasks`, `askUserQuestions`, `requestConfirmation`, and `requestCheckboxConfirmation`
* - `issue.documents.read` for `documents.list` and `documents.get`
* - `issue.documents.write` for `documents.upsert` and `documents.delete`
*/
@@ -1454,6 +1456,12 @@ export interface PluginIssuesClient {
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestConfirmationInteraction>;
requestCheckboxConfirmation(
issueId: string,
interaction: Omit<Extract<CreateIssueThreadInteraction, { kind: "request_checkbox_confirmation" }>, "kind">,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestCheckboxConfirmationInteraction>;
/** Read and write issue documents. Requires `issue.documents.read` / `issue.documents.write`. */
documents: PluginIssueDocumentsClient;
/** Read and write blocker relationships. */
@@ -43,6 +43,7 @@ import { fileURLToPath } from "node:url";
import type {
AskUserQuestionsInteraction,
PaperclipPluginManifestV1,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@paperclipai/shared";
@@ -913,6 +914,23 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
}) as Promise<RequestConfirmationInteraction>;
},
async requestCheckboxConfirmation(
issueId: string,
interaction,
companyId: string,
options?: { authorAgentId?: string },
): Promise<RequestCheckboxConfirmationInteraction> {
return callHost("issues.createInteraction", {
issueId,
companyId,
interaction: {
...interaction,
kind: "request_checkbox_confirmation",
},
authorAgentId: options?.authorAgentId,
}) as Promise<RequestCheckboxConfirmationInteraction>;
},
documents: {
async list(issueId: string, companyId: string) {
return callHost("issues.documents.list", { issueId, companyId });
@@ -72,3 +72,59 @@ describe("createTestHarness action context", () => {
await expect(harness.performAction("legacy", { ok: true })).resolves.toEqual({ ok: true });
});
});
describe("createTestHarness issue interactions", () => {
it("creates request_checkbox_confirmation interactions through the typed host helper", async () => {
const harness = createTestHarness({
manifest,
capabilities: ["issues.create", "issue.interactions.create"],
});
const issue = await harness.ctx.issues.create({
companyId: "company-1",
title: "Pick files",
});
const interaction = await harness.ctx.issues.requestCheckboxConfirmation(
issue.id,
{
idempotencyKey: "checkbox:files",
title: "Choose files",
payload: {
version: 1,
prompt: "Which files should be included?",
options: [
{ id: "file-a", label: "File A" },
{ id: "file-b", label: "File B", description: "Secondary draft" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
},
},
"company-1",
{ authorAgentId: "agent-1" },
);
expect(interaction).toMatchObject({
issueId: issue.id,
companyId: "company-1",
kind: "request_checkbox_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
idempotencyKey: "checkbox:files",
title: "Choose files",
createdByAgentId: "agent-1",
payload: {
version: 1,
prompt: "Which files should be included?",
options: [
{ id: "file-a", label: "File A" },
{ id: "file-b", label: "File B", description: "Secondary draft" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 1,
maxSelected: 2,
},
});
});
});
+53
View File
@@ -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",
@@ -179,9 +229,12 @@ export const ISSUE_THREAD_INTERACTION_KINDS = [
"suggest_tasks",
"ask_user_questions",
"request_confirmation",
"request_checkbox_confirmation",
] as const;
export type IssueThreadInteractionKind = (typeof ISSUE_THREAD_INTERACTION_KINDS)[number];
export const REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT = 200;
export const ISSUE_THREAD_INTERACTION_STATUSES = [
"pending",
"accepted",
+11
View File
@@ -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,
@@ -179,6 +181,7 @@ export {
type IssueThreadInteractionKind,
type IssueThreadInteractionStatus,
type IssueThreadInteractionContinuationPolicy,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
type BuiltInIssueOriginKind,
type PluginIssueOriginKind,
type IssueOriginKind,
@@ -398,6 +401,7 @@ export type {
AdapterEnvironmentTestResult,
AssetImage,
Project,
ProjectBudgetSummary,
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,
@@ -531,6 +535,9 @@ export type {
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@@ -541,6 +548,7 @@ export type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
@@ -977,6 +985,9 @@ export {
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { createIssueThreadInteractionSchema } from "./validators/issue.js";
import {
acceptIssueThreadInteractionSchema,
createIssueThreadInteractionSchema,
} from "./validators/issue.js";
describe("issue thread interaction schemas", () => {
it("parses request_confirmation payloads with default no-wake continuation", () => {
@@ -67,29 +70,37 @@ describe("issue thread interaction schemas", () => {
});
it("accepts custom targets for request_confirmation interactions", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the external checklist?",
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
revisionNumber: 1,
label: "Checklist v1",
href: "https://example.com/checklist",
for (const href of [
"https://example.com/checklist",
"http://example.com/checklist",
"/PAP/issues/PAP-123#document-plan",
"#document-plan",
]) {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the external checklist?",
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
revisionNumber: 1,
label: "Checklist v1",
href,
},
},
},
});
});
expect(parsed.kind).toBe("request_confirmation");
if (parsed.kind !== "request_confirmation") return;
expect(parsed.payload.target).toMatchObject({
type: "custom",
key: "external-checklist",
label: "Checklist v1",
});
expect(parsed.kind).toBe("request_confirmation");
if (parsed.kind !== "request_confirmation") return;
expect(parsed.payload.target).toMatchObject({
type: "custom",
key: "external-checklist",
label: "Checklist v1",
href,
});
}
});
it("rejects unsafe request_confirmation target hrefs", () => {
@@ -107,7 +118,16 @@ describe("issue thread interaction schemas", () => {
},
} as const;
for (const href of ["javascript:alert(1)", "data:text/html,hi", "//evil.example/path"]) {
for (const href of [
"javascript:alert(1)",
"data:text/html,hi",
"//evil.example/path",
"file:///tmp/x",
"mailto:user@example.com",
"slack://channel?id=1",
"vscode://file/tmp/x",
"ftp://example.com/file",
]) {
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
@@ -117,7 +137,134 @@ describe("issue thread interaction schemas", () => {
href,
},
},
})).toThrow("href must not use javascript:, data:, or protocol-relative URLs");
})).toThrow("href must be a root-relative path, same-page fragment, or http(s) URL");
}
});
it("parses request_checkbox_confirmation payloads with checkbox defaults", () => {
const parsed = createIssueThreadInteractionSchema.parse({
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-2", label: "Old screenshot", description: "Captured during QA." },
],
defaultSelectedOptionIds: ["item-2"],
minSelected: 0,
maxSelected: 2,
acceptLabel: "Archive selected",
rejectRequiresReason: true,
target: {
type: "issue_document",
key: "plan",
revisionId: "33333333-3333-4333-8333-333333333333",
revisionNumber: 2,
},
},
});
expect(parsed).toMatchObject({
kind: "request_checkbox_confirmation",
continuationPolicy: "wake_assignee",
payload: {
allowDeclineReason: true,
defaultSelectedOptionIds: ["item-2"],
minSelected: 0,
maxSelected: 2,
},
});
});
it("rejects invalid request_checkbox_confirmation option references and bounds", () => {
const base = {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-2", label: "Old screenshot" },
],
},
} as const;
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
options: [
{ id: "item-1", label: "Draft report" },
{ id: "item-1", label: "Duplicate" },
],
},
})).toThrow("Option ids must be unique within one checkbox confirmation");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
defaultSelectedOptionIds: ["missing"],
},
})).toThrow("defaultSelectedOptionIds must reference existing option ids");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
defaultSelectedOptionIds: ["item-1"],
minSelected: 2,
},
})).toThrow("defaultSelectedOptionIds must satisfy minSelected");
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
minSelected: 2,
maxSelected: 1,
},
})).toThrow("maxSelected must be greater than or equal to minSelected");
});
it("rejects unsafe request_checkbox_confirmation target hrefs", () => {
const base = {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which items should be archived?",
options: [{ id: "item-1", label: "Draft report" }],
target: {
type: "custom",
key: "external-checklist",
revisionId: "checklist-v1",
label: "Checklist v1",
},
},
} as const;
for (const href of ["file:///tmp/x", "slack://channel?id=1", "vscode://file/tmp/x"]) {
expect(() => createIssueThreadInteractionSchema.parse({
...base,
payload: {
...base.payload,
target: {
...base.payload.target,
href,
},
},
})).toThrow("href must be a root-relative path, same-page fragment, or http(s) URL");
}
});
it("accepts empty checkbox selections and rejects duplicate selected option ids", () => {
expect(acceptIssueThreadInteractionSchema.parse({ selectedOptionIds: [] })).toEqual({
selectedOptionIds: [],
});
expect(() => acceptIssueThreadInteractionSchema.parse({
selectedOptionIds: ["item-1", "item-1"],
})).toThrow("selectedOptionIds must be unique");
});
});
@@ -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;
+5 -1
View File
@@ -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,
@@ -288,6 +288,9 @@ export type {
RequestConfirmationTarget,
RequestConfirmationPayload,
RequestConfirmationResult,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
AcceptedPlanDecompositionStatus,
AcceptedPlanDecompositionChild,
AcceptedPlanDecomposition,
@@ -298,6 +301,7 @@ export type {
SuggestTasksInteraction,
AskUserQuestionsInteraction,
RequestConfirmationInteraction,
RequestCheckboxConfirmationInteraction,
IssueThreadInteraction,
IssueThreadInteractionPayload,
IssueThreadInteractionResult,
+1
View File
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings {
enableEnvironments: boolean;
enableIsolatedWorkspaces: boolean;
enableStreamlinedLeftNavigation: boolean;
enableIssuePlanDecompositions: boolean;
enableCloudSync: boolean;
autoRestartDevServerWhenIdle: boolean;
+40 -3
View File
@@ -786,6 +786,30 @@ export interface RequestConfirmationPayload {
target?: RequestConfirmationTarget | null;
}
export interface RequestCheckboxConfirmationOption {
id: string;
label: string;
description?: string | null;
}
export interface RequestCheckboxConfirmationPayload {
version: 1;
prompt: string;
detailsMarkdown?: string | null;
options: RequestCheckboxConfirmationOption[];
defaultSelectedOptionIds?: string[];
minSelected?: number;
maxSelected?: number | null;
acceptLabel?: string | null;
rejectLabel?: string | null;
rejectRequiresReason?: boolean;
rejectReasonLabel?: string | null;
allowDeclineReason?: boolean;
declineReasonPlaceholder?: string | null;
supersedeOnUserComment?: boolean;
target?: RequestConfirmationTarget | null;
}
export interface RequestConfirmationResult {
version: 1;
outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target";
@@ -794,6 +818,10 @@ export interface RequestConfirmationResult {
staleTarget?: RequestConfirmationTarget | null;
}
export interface RequestCheckboxConfirmationResult extends RequestConfirmationResult {
selectedOptionIds?: string[];
}
export interface IssueThreadInteractionBase extends IssueThreadInteractionActorFields {
id: string;
companyId: string;
@@ -829,20 +857,29 @@ export interface RequestConfirmationInteraction extends IssueThreadInteractionBa
result?: RequestConfirmationResult | null;
}
export interface RequestCheckboxConfirmationInteraction extends IssueThreadInteractionBase {
kind: "request_checkbox_confirmation";
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
}
export type IssueThreadInteraction =
| SuggestTasksInteraction
| AskUserQuestionsInteraction
| RequestConfirmationInteraction;
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
export type IssueThreadInteractionPayload =
| SuggestTasksPayload
| AskUserQuestionsPayload
| RequestConfirmationPayload;
| RequestConfirmationPayload
| RequestCheckboxConfirmationPayload;
export type IssueThreadInteractionResult =
| SuggestTasksResult
| AskUserQuestionsResult
| RequestConfirmationResult;
| RequestConfirmationResult
| RequestCheckboxConfirmationResult;
export interface IssueAttachment {
id: string;
+23 -1
View File
@@ -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;
+3
View File
@@ -261,6 +261,9 @@ export {
requestConfirmationTargetSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
requestCheckboxConfirmationOptionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
createIssueThreadInteractionSchema,
acceptIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
@@ -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),
+150 -5
View File
@@ -25,6 +25,7 @@ import {
ISSUE_THREAD_INTERACTION_KINDS,
ISSUE_THREAD_INTERACTION_STATUSES,
MODEL_PROFILE_KEYS,
REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT,
} from "../constants.js";
import { multilineTextSchema } from "./text.js";
import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js";
@@ -681,11 +682,10 @@ export const askUserQuestionsResultSchema = z.object({
});
const requestConfirmationHrefSchema = z.string().trim().min(1).max(2000).refine((value) => {
const lower = value.toLowerCase();
return !lower.startsWith("javascript:")
&& !lower.startsWith("data:")
&& !value.startsWith("//");
}, "href must not use javascript:, data:, or protocol-relative URLs");
if (value.startsWith("#")) return true;
if (value.startsWith("/")) return !value.startsWith("//");
return /^https?:\/\//i.test(value);
}, "href must be a root-relative path, same-page fragment, or http(s) URL");
const requestConfirmationTargetBaseSchema = z.object({
label: z.string().trim().min(1).max(120).nullable().optional(),
@@ -727,6 +727,106 @@ export const requestConfirmationPayloadSchema = z.object({
target: requestConfirmationTargetSchema.nullable().optional(),
});
export const requestCheckboxConfirmationOptionSchema = z.object({
id: z.string().trim().min(1).max(120),
label: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullable().optional(),
});
export const requestCheckboxConfirmationPayloadSchema = z.object({
version: z.literal(1),
prompt: z.string().trim().min(1).max(1000),
detailsMarkdown: z.string().max(20000).nullable().optional(),
options: z.array(requestCheckboxConfirmationOptionSchema)
.min(1)
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT),
defaultSelectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional()
.default([]),
minSelected: z.number().int().min(0).optional().default(0),
maxSelected: z.number().int().min(0).nullable().optional(),
acceptLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectLabel: z.string().trim().min(1).max(80).nullable().optional(),
rejectRequiresReason: z.boolean().optional(),
rejectReasonLabel: z.string().trim().min(1).max(160).nullable().optional(),
allowDeclineReason: z.boolean().optional().default(true),
declineReasonPlaceholder: z.string().trim().min(1).max(240).nullable().optional(),
supersedeOnUserComment: z.boolean().optional(),
target: requestConfirmationTargetSchema.nullable().optional(),
}).superRefine((value, ctx) => {
const optionIds = new Set<string>();
for (const [index, option] of value.options.entries()) {
if (optionIds.has(option.id)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Option ids must be unique within one checkbox confirmation",
path: ["options", index, "id"],
});
}
optionIds.add(option.id);
}
const defaultSelectedOptionIds = new Set<string>();
for (const [index, optionId] of value.defaultSelectedOptionIds.entries()) {
if (defaultSelectedOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must be unique",
path: ["defaultSelectedOptionIds", index],
});
continue;
}
defaultSelectedOptionIds.add(optionId);
if (!optionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must reference existing option ids",
path: ["defaultSelectedOptionIds", index],
});
}
}
const maxSelected = value.maxSelected ?? null;
if (value.minSelected > value.options.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "minSelected cannot exceed the option count",
path: ["minSelected"],
});
}
if (value.defaultSelectedOptionIds.length < value.minSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds must satisfy minSelected",
path: ["defaultSelectedOptionIds"],
});
}
if (maxSelected != null) {
if (maxSelected < value.minSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "maxSelected must be greater than or equal to minSelected",
path: ["maxSelected"],
});
}
if (maxSelected > value.options.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "maxSelected cannot exceed the option count",
path: ["maxSelected"],
});
}
if (value.defaultSelectedOptionIds.length > maxSelected) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "defaultSelectedOptionIds cannot exceed maxSelected",
path: ["defaultSelectedOptionIds"],
});
}
}
});
export const requestConfirmationResultSchema = z.object({
version: z.literal(1),
outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target"]),
@@ -735,6 +835,25 @@ export const requestConfirmationResultSchema = z.object({
staleTarget: requestConfirmationTargetSchema.nullable().optional(),
});
export const requestCheckboxConfirmationResultSchema = requestConfirmationResultSchema.extend({
selectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional(),
}).superRefine((value, ctx) => {
const selectedOptionIds = value.selectedOptionIds ?? [];
const seenOptionIds = new Set<string>();
for (const [index, optionId] of selectedOptionIds.entries()) {
if (seenOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedOptionIds must be unique",
path: ["selectedOptionIds", index],
});
}
seenOptionIds.add(optionId);
}
});
export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("suggest_tasks"),
@@ -766,12 +885,25 @@ export const createIssueThreadInteractionSchema = z.discriminatedUnion("kind", [
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("none"),
payload: requestConfirmationPayloadSchema,
}),
z.object({
kind: z.literal("request_checkbox_confirmation"),
idempotencyKey: z.string().trim().max(255).nullable().optional(),
sourceCommentId: z.string().uuid().nullable().optional(),
sourceRunId: z.string().uuid().nullable().optional(),
title: z.string().trim().max(240).nullable().optional(),
summary: z.string().trim().max(1000).nullable().optional(),
continuationPolicy: issueThreadInteractionContinuationPolicySchema.optional().default("wake_assignee"),
payload: requestCheckboxConfirmationPayloadSchema,
}),
]);
export type CreateIssueThreadInteraction = z.infer<typeof createIssueThreadInteractionSchema>;
export const acceptIssueThreadInteractionSchema = z.object({
selectedClientKeys: z.array(z.string().trim().min(1).max(120)).min(1).max(50).optional(),
selectedOptionIds: z.array(z.string().trim().min(1).max(120))
.max(REQUEST_CHECKBOX_CONFIRMATION_OPTION_LIMIT)
.optional(),
}).superRefine((value, ctx) => {
const seenClientKeys = new Set<string>();
for (const [index, clientKey] of (value.selectedClientKeys ?? []).entries()) {
@@ -785,6 +917,19 @@ export const acceptIssueThreadInteractionSchema = z.object({
}
seenClientKeys.add(clientKey);
}
const seenOptionIds = new Set<string>();
for (const [index, optionId] of (value.selectedOptionIds ?? []).entries()) {
if (seenOptionIds.has(optionId)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "selectedOptionIds must be unique",
path: ["selectedOptionIds", index],
});
continue;
}
seenOptionIds.add(optionId);
}
});
export type AcceptIssueThreadInteraction = z.infer<typeof acceptIssueThreadInteractionSchema>;
+2 -1
View File
@@ -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,
@@ -542,6 +542,74 @@ describe.sequential("issue thread interaction routes", () => {
);
});
it("accepts request checkbox confirmations with selected option ids and wakes the assignee", async () => {
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
interaction: {
id: "interaction-checkbox",
companyId: "company-1",
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
kind: "request_checkbox_confirmation",
status: "accepted",
continuationPolicy: "wake_assignee",
idempotencyKey: null,
sourceCommentId: null,
sourceRunId: "run-checkbox",
payload: {
version: 1,
prompt: "Delete selected files?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt" },
],
},
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["file-b"],
},
createdAt: "2026-04-20T12:00:00.000Z",
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
},
createdIssues: [],
});
const app = await createApp();
const res = await request(app)
.post("/api/issues/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/interactions/interaction-checkbox/accept")
.send({ selectedOptionIds: ["file-b"] });
expect(res.status).toBe(200);
expect(mockInteractionService.acceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" }),
"interaction-checkbox",
{ selectedOptionIds: ["file-b"] },
expect.objectContaining({ userId: "local-board" }),
);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(
ASSIGNEE_AGENT_ID,
expect.objectContaining({
reason: "issue_commented",
payload: expect.objectContaining({
interactionId: "interaction-checkbox",
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
}),
}),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.thread_interaction_accepted",
details: expect.objectContaining({
interactionKind: "request_checkbox_confirmation",
interactionStatus: "accepted",
}),
}),
);
});
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
@@ -746,6 +746,164 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
})).rejects.toThrow("A decline reason is required for this confirmation");
});
it("accepts request_checkbox_confirmation interactions with selected option ids", async () => {
const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation accept");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which files should be deleted?",
options: [
{ id: "file-a", label: "a.txt" },
{ id: "file-b", label: "b.txt" },
{ id: "file-c", label: "c.txt" },
],
defaultSelectedOptionIds: ["file-a"],
minSelected: 0,
maxSelected: 2,
},
}, {
userId: "local-board",
});
expect(created).toMatchObject({
kind: "request_checkbox_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
payload: {
supersedeOnUserComment: true,
allowDeclineReason: true,
},
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["file-c", "file-a"],
}, {
userId: "local-board",
});
expect(accepted.createdIssues).toEqual([]);
expect(accepted.interaction).toMatchObject({
kind: "request_checkbox_confirmation",
status: "accepted",
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["file-a", "file-c"],
},
resolvedByUserId: "local-board",
});
});
it("enforces request_checkbox_confirmation selected option references and bounds", async () => {
const { companyId, goalId, issueId } = await seedConfirmationIssue("Checkbox confirmation bounds");
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Pick one or two options.",
options: [
{ id: "one", label: "One" },
{ id: "two", label: "Two" },
{ id: "three", label: "Three" },
],
defaultSelectedOptionIds: ["one"],
minSelected: 1,
maxSelected: 2,
},
}, {
userId: "local-board",
});
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: [],
}, {
userId: "local-board",
})).rejects.toThrow("Select at least 1 checkbox confirmation option(s)");
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["missing"],
}, {
userId: "local-board",
})).rejects.toThrow("Unknown checkbox confirmation optionId: missing");
await expect(interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedOptionIds: ["one", "two", "three"],
}, {
userId: "local-board",
})).rejects.toThrow("Select no more than 2 checkbox confirmation option(s)");
});
it("expires request_checkbox_confirmation interactions when a user comments after creation", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Checkbox confirmation supersede");
const commentId = randomUUID();
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Which files should be deleted?",
options: [{ id: "file-a", label: "a.txt" }],
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
kind: "request_checkbox_confirmation",
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId,
},
});
});
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
@@ -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);
});
});
+4 -2
View File
@@ -6033,7 +6033,9 @@ export function issueRoutes(
});
}
const acceptedPlanTarget = readAcceptedPlanConfirmationTarget(interaction.payload);
const acceptedPlanTarget = interaction.kind === "request_confirmation"
? readAcceptedPlanConfirmationTarget(interaction.payload)
: null;
const acceptedPlanConfirmation =
interaction.kind === "request_confirmation" &&
interaction.status === "accepted" &&
@@ -6091,7 +6093,7 @@ export function issueRoutes(
rejectionReason:
interaction.kind === "suggest_tasks"
? (interaction.result?.rejectionReason ?? null)
: interaction.kind === "request_confirmation"
: interaction.kind === "request_confirmation" || interaction.kind === "request_checkbox_confirmation"
? (interaction.result?.reason ?? null)
: null,
},
@@ -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",
+2
View File
@@ -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,
+113 -21
View File
@@ -16,6 +16,7 @@ import type {
CancelIssueThreadInteraction,
CreateIssueThreadInteraction,
IssueThreadInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
RequestConfirmationTarget,
RejectIssueThreadInteraction,
@@ -30,6 +31,8 @@ import {
cancelIssueThreadInteractionSchema,
createIssueThreadInteractionSchema,
rejectIssueThreadInteractionSchema,
requestCheckboxConfirmationPayloadSchema,
requestCheckboxConfirmationResultSchema,
requestConfirmationPayloadSchema,
requestConfirmationResultSchema,
suggestTasksPayloadSchema,
@@ -70,6 +73,19 @@ type IssueResolutionContext = {
assigneeUserId: string | null;
};
const REQUEST_CONFIRMATION_INTERACTION_KINDS = [
"request_confirmation",
"request_checkbox_confirmation",
] as const;
type RequestConfirmationLikeKind = (typeof REQUEST_CONFIRMATION_INTERACTION_KINDS)[number];
type RequestConfirmationLikeInteraction =
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
function isRequestConfirmationLikeKind(kind: string): kind is RequestConfirmationLikeKind {
return (REQUEST_CONFIRMATION_INTERACTION_KINDS as readonly string[]).includes(kind);
}
function isIssueThreadInteractionIdempotencyConflict(error: unknown): boolean {
if (typeof error !== "object" || error === null) return false;
const err = error as { code?: string; constraint?: string; constraint_name?: string };
@@ -128,6 +144,13 @@ function hydrateInteraction(
payload: requestConfirmationPayloadSchema.parse(row.payload),
result: row.result ? requestConfirmationResultSchema.parse(row.result) : null,
} satisfies RequestConfirmationInteraction;
case "request_checkbox_confirmation":
return {
...base,
kind: "request_checkbox_confirmation",
payload: requestCheckboxConfirmationPayloadSchema.parse(row.payload),
result: row.result ? requestCheckboxConfirmationResultSchema.parse(row.result) : null,
} satisfies RequestCheckboxConfirmationInteraction;
default:
throw unprocessable(`Unknown interaction kind: ${row.kind}`);
}
@@ -149,7 +172,7 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
current: IssueThreadInteractionRow;
actor: InteractionActor;
}) {
if (args.current.kind !== "request_confirmation") return false;
if (!isRequestConfirmationLikeKind(args.current.kind)) return false;
if (!args.current.createdByAgentId) return false;
if (!args.actor.userId) return false;
if (!args.issue.assigneeUserId) return false;
@@ -158,19 +181,31 @@ function shouldReturnAcceptedConfirmationToCreatorAgent(args: {
return true;
}
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationInteraction) {
function shouldSupersedeRequestConfirmationOnUserComment(interaction: RequestConfirmationLikeInteraction) {
return interaction.payload.supersedeOnUserComment === true;
}
function normalizeCreateInteractionInput(input: CreateIssueThreadInteraction): CreateIssueThreadInteraction {
if (input.kind !== "request_confirmation") return input;
return {
...input,
payload: {
...input.payload,
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
switch (input.kind) {
case "request_confirmation":
return {
...input,
payload: {
...input.payload,
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
case "request_checkbox_confirmation":
return {
...input,
payload: {
...input.payload,
supersedeOnUserComment: input.payload.supersedeOnUserComment ?? true,
},
};
default:
return input;
}
}
function isCommentAtOrAfterInteraction(args: {
@@ -255,6 +290,36 @@ function resolveSelectedSuggestedTasks(args: {
};
}
function resolveSelectedCheckboxConfirmationOptions(args: {
interaction: RequestCheckboxConfirmationInteraction;
selectedOptionIds?: AcceptIssueThreadInteraction["selectedOptionIds"];
}) {
const optionIds = new Set(args.interaction.payload.options.map((option) => option.id));
const selectedOptionIds = args.selectedOptionIds ?? args.interaction.payload.defaultSelectedOptionIds ?? [];
const selectedOptionIdSet = new Set<string>();
for (const optionId of selectedOptionIds) {
if (!optionIds.has(optionId)) {
throw unprocessable(`Unknown checkbox confirmation optionId: ${optionId}`);
}
selectedOptionIdSet.add(optionId);
}
const selectedCount = selectedOptionIdSet.size;
const minSelected = args.interaction.payload.minSelected ?? 0;
const maxSelected = args.interaction.payload.maxSelected ?? null;
if (selectedCount < minSelected) {
throw unprocessable(`Select at least ${minSelected} checkbox confirmation option(s)`);
}
if (maxSelected != null && selectedCount > maxSelected) {
throw unprocessable(`Select no more than ${maxSelected} checkbox confirmation option(s)`);
}
return args.interaction.payload.options
.filter((option) => selectedOptionIdSet.has(option.id))
.map((option) => option.id);
}
function normalizeQuestionAnswers(args: {
questions: AskUserQuestionsInteraction["payload"]["questions"];
answers: RespondIssueThreadInteraction["answers"];
@@ -401,8 +466,8 @@ async function expireStaleRequestConfirmationTarget(db: Db | any, args: {
row: IssueThreadInteractionRow;
actor: InteractionActor;
}): Promise<IssueThreadInteraction | null> {
if (args.row.kind !== "request_confirmation" || args.row.status !== "pending") return null;
const interaction = hydrateInteraction(args.row) as RequestConfirmationInteraction;
if (!isRequestConfirmationLikeKind(args.row.kind) || args.row.status !== "pending") return null;
const interaction = hydrateInteraction(args.row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target ?? null;
if (!target) return null;
if (target.type !== "issue_document") return null;
@@ -522,6 +587,7 @@ export function issueThreadInteractionService(db: Db) {
async function acceptRequestConfirmation(args: {
issue: { id: string; companyId: string };
current: IssueThreadInteractionRow;
input: AcceptIssueThreadInteraction;
actor: InteractionActor;
}): Promise<{
interaction: IssueThreadInteraction;
@@ -535,6 +601,15 @@ export function issueThreadInteractionService(db: Db) {
return { interaction: expired, continuationIssue: null };
}
const interaction = hydrateInteraction(args.current);
const selectedOptionIds =
interaction.kind === "request_checkbox_confirmation"
? resolveSelectedCheckboxConfirmationOptions({
interaction,
selectedOptionIds: args.input.selectedOptionIds,
})
: undefined;
const now = new Date();
return db.transaction(async (tx) => {
const [updated] = await tx
@@ -544,6 +619,7 @@ export function issueThreadInteractionService(db: Db) {
result: {
version: 1,
outcome: "accepted",
...(selectedOptionIds ? { selectedOptionIds } : {}),
},
resolvedByAgentId: args.actor.agentId ?? null,
resolvedByUserId: args.actor.userId ?? null,
@@ -624,7 +700,7 @@ export function issueThreadInteractionService(db: Db) {
return expired;
}
const interaction = hydrateInteraction(args.current) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(args.current) as RequestConfirmationLikeInteraction;
const reason = args.input.reason?.trim() ?? "";
if (interaction.payload.rejectRequiresReason === true && reason.length === 0) {
throw unprocessable("A decline reason is required for this confirmation");
@@ -729,7 +805,7 @@ export function issueThreadInteractionService(db: Db) {
}
}
if (data.kind === "request_confirmation") {
if (data.kind === "request_confirmation" || data.kind === "request_checkbox_confirmation") {
await assertRequestConfirmationTargetIsCurrent(db, {
companyId: issue.companyId,
issueId: issue.id,
@@ -798,6 +874,21 @@ export function issueThreadInteractionService(db: Db) {
const accepted = await acceptRequestConfirmation({
issue,
current,
input: data,
actor,
});
return {
interaction: accepted.interaction,
continuationIssue: accepted.continuationIssue,
createdIssues: [],
};
}
case "request_checkbox_confirmation": {
await assertIssueWorkspaceFinalizedForAccept({ db, issue });
const accepted = await acceptRequestConfirmation({
issue,
current,
input: data,
actor,
});
return {
@@ -970,6 +1061,7 @@ export function issueThreadInteractionService(db: Db) {
case "suggest_tasks":
return issueThreadInteractionService(db).rejectSuggestedTasks(issue, interactionId, data, actor, current);
case "request_confirmation":
case "request_checkbox_confirmation":
return rejectRequestConfirmation({
issue,
current,
@@ -1038,12 +1130,12 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
));
const superseded = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
return (
shouldSupersedeRequestConfirmationOnUserComment(interaction)
&& isCommentAtOrAfterInteraction({
@@ -1096,7 +1188,7 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
)),
db
@@ -1122,7 +1214,7 @@ export function issueThreadInteractionService(db: Db) {
}
>();
for (const row of rows) {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
if (!shouldSupersedeRequestConfirmationOnUserComment(interaction)) continue;
const supersedingComment = comments.find((comment) => isCommentAtOrAfterInteraction({
@@ -1182,12 +1274,12 @@ export function issueThreadInteractionService(db: Db) {
.where(and(
eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id),
eq(issueThreadInteractions.kind, "request_confirmation"),
inArray(issueThreadInteractions.kind, [...REQUEST_CONFIRMATION_INTERACTION_KINDS]),
eq(issueThreadInteractions.status, "pending"),
));
const staleRows = rows.filter((row) => {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target;
if (!target || target.type !== "issue_document") return false;
const targetIssueId = target.issueId ?? issue.id;
@@ -1206,7 +1298,7 @@ export function issueThreadInteractionService(db: Db) {
const now = new Date();
const expired: IssueThreadInteraction[] = [];
for (const row of staleRows) {
const interaction = hydrateInteraction(row) as RequestConfirmationInteraction;
const interaction = hydrateInteraction(row) as RequestConfirmationLikeInteraction;
const target = interaction.payload.target ?? null;
const currentTarget = buildIssueDocumentTargetFromDocument({
issueId: issue.id,
+91 -10
View File
@@ -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[]> => {
+61
View File
@@ -194,6 +194,67 @@ POST /api/companies/{companyId}/approvals
`issueIds` links the approval into the issue thread. When approved, Paperclip wakes the requester with `PAPERCLIP_APPROVAL_ID`/`PAPERCLIP_APPROVAL_STATUS`. Keep the payload concise and decision-ready.
## Issue-Thread Interactions
Issue-thread interactions are first-class cards that render in the issue thread and capture a typed board/user response. Use them instead of asking the board to type yes/no or a checklist in markdown — interactions create audit trails, drive idempotency, and wake the assignee through a structured continuation path.
Four kinds are supported. Pick the smallest kind that fits the decision shape:
| Kind | When to use | When **not** to use |
| ------------------------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `request_confirmation` | Single yes/no decision bound to a target (e.g. accept a plan revision, approve a launch). | Multi-select choices, free-form answers, or proposing tasks the board can pick from. |
| `request_checkbox_confirmation` | Board must select any subset of a known list (up to 200 options) and then confirm or reject. | Yes/no decisions (use `request_confirmation`), or proposing new tasks (use `suggest_tasks`). |
| `ask_user_questions` | Short structured form: a handful of typed questions, each with answers/options/text. | Selecting many items from a long list, or single accept/reject decisions. |
| `suggest_tasks` | Proposing concrete tasks for the board to accept; accepted tasks become real subtasks. | Asking the board to confirm a plan or arbitrary selection. Tasks are the unit; not arbitrary ids. |
Key shared semantics:
- **Continuation policy.** `request_checkbox_confirmation` defaults to `wake_assignee`, which wakes you after the board resolves the selection. `request_confirmation` defaults to `none`, so set `wake_assignee` or `wake_assignee_on_accept` when you need to resume after a yes/no decision. `none` never wakes you — only use it when you truly do not need to resume.
- **Target binding and staleness.** `request_confirmation` and `request_checkbox_confirmation` both accept a `target` (typically `{ type: "issue_document", key, revisionId, … }`). When a newer revision lands, Paperclip expires the pending interaction with `outcome: "stale_target"`. Rebuild against the latest revision and create a fresh interaction.
- **Supersede on user comment.** Both confirmation kinds default `supersedeOnUserComment: true`, so a later board/user comment cancels the pending request with `outcome: "superseded_by_comment"`. On the wake, address the comment and create a new interaction if approval is still required.
- **Idempotency.** Use a deterministic `idempotencyKey` such as `confirmation:${issueId}:plan:${revisionId}` or `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries do not stack duplicate cards.
- **Source issue posture.** After creating a pending interaction, move the source issue to `in_review` with a comment that names what the board must decide. The pending interaction is the explicit waiting path.
Create a `request_checkbox_confirmation` (board selects any subset, then confirms):
```json
POST /api/issues/{issueId}/interactions
{
"kind": "request_checkbox_confirmation",
"idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
"title": "Confirm files to delete",
"summary": "Pick the files you want removed before I run the cleanup.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Check the files you want deleted.",
"detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
"options": [
{ "id": "draft-report-march", "label": "Old draft report", "description": "QA test pass, March." },
{ "id": "tmp-export-2025", "label": "tmp/export-2025.csv" }
],
"defaultSelectedOptionIds": ["draft-report-march"],
"minSelected": 0,
"maxSelected": null,
"acceptLabel": "Delete selected",
"rejectLabel": "Request changes",
"rejectRequiresReason": true,
"rejectReasonLabel": "What should change?",
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
```
When the board accepts, your wake delivers `result.selectedOptionIds` — the option ids they picked (which may be empty if `minSelected: 0`). Rejection delivers `result.reason` and a `commentId`.
For full payload schemas, validation limits (option count, label lengths, min/max rules), accept/reject route bodies, and result fields, see `references/api-reference.md` -> **Checkbox confirmations**.
## Niche Workflow Pointers
Load `references/workflows.md` when the task matches one of these:
+114 -2
View File
@@ -686,6 +686,118 @@ Rules:
- A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names what the board/user must decide.
- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, set the source issue to `in_review`, and wait for acceptance before creating implementation subtasks.
### Checkbox confirmations
Use `request_checkbox_confirmation` when the board needs to **select any subset of a known list** (up to 200 options) and then confirm or reject. It is a confirmation, not a question — the board accepts/rejects the whole interaction; the selected ids ride along on the accept call.
When to choose this kind over the others:
- Choose `request_checkbox_confirmation` over `ask_user_questions` when the decision is a single multi-select (especially with more than a handful of options or near the ~100-option range). `ask_user_questions` is for short structured forms, not long lists.
- Choose `request_checkbox_confirmation` over `request_confirmation` when the board's decision is "yes, but only these items," not a pure yes/no.
- Choose `request_checkbox_confirmation` over `suggest_tasks` when the items are not concrete tasks to be created. `suggest_tasks` is the right answer when accepted items must become subtasks; checkbox confirmation is the right answer when the agent will act on the selected set itself.
Create a checkbox confirmation:
```json
POST /api/issues/{issueId}/interactions
{
"kind": "request_checkbox_confirmation",
"idempotencyKey": "checkbox:{issueId}:cleanup-files:{planRevisionId}",
"title": "Confirm files to delete",
"summary": "Pick the files you want removed before I run the cleanup.",
"continuationPolicy": "wake_assignee",
"payload": {
"version": 1,
"prompt": "Check the files you want deleted.",
"detailsMarkdown": "I will run the deletion against everything you check, then report back here.",
"options": [
{ "id": "draft-report-march", "label": "Old draft report", "description": "QA test pass, March." },
{ "id": "tmp-export-2025", "label": "tmp/export-2025.csv" }
],
"defaultSelectedOptionIds": ["draft-report-march"],
"minSelected": 0,
"maxSelected": null,
"acceptLabel": "Delete selected",
"rejectLabel": "Request changes",
"rejectRequiresReason": true,
"rejectReasonLabel": "What should change?",
"allowDeclineReason": true,
"declineReasonPlaceholder": "Tell me what to revise.",
"supersedeOnUserComment": true,
"target": {
"type": "issue_document",
"issueId": "{issueId}",
"key": "plan",
"revisionId": "{latestPlanRevisionId}"
}
}
}
```
Payload field reference (`RequestCheckboxConfirmationPayload`):
| Field | Type | Default | Notes |
| --------------------------- | ------------------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | `1` | required | Versioned for forward compatibility. |
| `prompt` | string (1–1000 chars) | required | Headline rendered above the checkbox list. |
| `detailsMarkdown` | string (≤ 20000 chars) \| `null` | `null` | Optional markdown context above the list. |
| `options` | `[{ id, label, description? }]` | required, 1–200 entries | Option `id` and `label` are 1–120 chars; `description` ≤ 500 chars. Option ids must be unique within the payload. |
| `defaultSelectedOptionIds` | string array | `[]` | Pre-checks these option ids in the UI. Each id must reference an option in `options`. Length must not exceed `maxSelected` when set. |
| `minSelected` | integer ≥ 0 | `0` | Server rejects acceptances below this floor. Cannot exceed `options.length`. |
| `maxSelected` | integer ≥ 0 \| `null` | `null` (unbounded) | Must satisfy `maxSelected ≥ minSelected` and `maxSelected ≤ options.length` when set. |
| `acceptLabel` | string (1–80) \| `null` | `null` (UI default) | Button label for accept. |
| `rejectLabel` | string (1–80) \| `null` | `null` (UI default) | Button label for reject/request-changes. |
| `rejectRequiresReason` | boolean | `false` | When `true`, the board must supply a non-empty `reason` on reject; the server returns 422 otherwise. |
| `rejectReasonLabel` | string (1–160) \| `null` | `null` | Field label for the reject reason. |
| `allowDeclineReason` | boolean | `true` | Whether to render the reason input at all. |
| `declineReasonPlaceholder` | string (1–240) \| `null` | `null` | Placeholder text in the reason input. |
| `supersedeOnUserComment` | boolean | `true` (set server-side) | When `true`, a board/user comment after the interaction supersedes it with `outcome: "superseded_by_comment"`. |
| `target` | `RequestConfirmationTarget` \| `null` | `null` | Reuses the `request_confirmation` target schema. Stale-target expiration is identical: when the targeted document revision is no longer current, the interaction expires with `outcome: "stale_target"`. |
Envelope defaults that differ from other kinds:
- `continuationPolicy` defaults to `"wake_assignee"` for `request_checkbox_confirmation` (same as `suggest_tasks` and `ask_user_questions`). Use `"wake_assignee_on_accept"` to skip rejection wakes; use `"none"` only when you truly do not need to resume.
Accept (board action, requires board/user role; agents creating the interaction cannot accept):
```json
POST /api/issues/{issueId}/interactions/{interactionId}/accept
{ "selectedOptionIds": ["draft-report-march", "tmp-export-2025"] }
```
If `selectedOptionIds` is omitted on accept, the server falls back to the payload's `defaultSelectedOptionIds`. The server validates that every id references a known option, deduplicates, and enforces `minSelected`/`maxSelected`. Unknown ids return 422.
Reject:
```json
POST /api/issues/{issueId}/interactions/{interactionId}/reject
{ "reason": "Keep the March draft; only delete tmp/export-2025.csv." }
```
`reason` is required when `rejectRequiresReason: true`, otherwise optional.
Resolved result (`RequestCheckboxConfirmationResult`):
```json
{
"version": 1,
"outcome": "accepted",
"selectedOptionIds": ["draft-report-march", "tmp-export-2025"]
}
```
Other outcomes match `request_confirmation`:
- `rejected` — `{ outcome: "rejected", reason, commentId }`. `selectedOptionIds` is absent.
- `superseded_by_comment` — `{ outcome: "superseded_by_comment", commentId }`. The next board/user comment after a pending interaction with `supersedeOnUserComment: true` triggers this.
- `stale_target` — `{ outcome: "stale_target", staleTarget }`. Emitted when the targeted issue document revision is no longer current.
Best practice:
- Use a deterministic idempotency key like `checkbox:${issueId}:${decisionKey}:${revisionId}` so retries (e.g. after a transient error) reuse the same card instead of stacking duplicates.
- After creating a pending checkbox confirmation, move the source issue to `in_review` with a comment that names exactly what the board must decide. Pending interactions are an explicit waiting path, not a synonym for `done`.
- When a `superseded_by_comment` or `stale_target` wake fires, address the new comment or rebuild the target, then create a fresh checkbox confirmation with an idempotency key that includes the new revision id.
### Checking approval status
```
@@ -792,8 +904,8 @@ Terminal states: `done`, `cancelled`
| GET | `/api/issues/:issueId/comments/:commentId` | Get a specific comment by ID |
| POST | `/api/issues/:issueId/comments` | Add comment (@-mentions trigger wakeups) |
| GET | `/api/issues/:issueId/interactions` | List issue-thread interactions |
| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation |
| POST | `/api/issues/:issueId/interactions` | Create issue-thread interaction (`suggest_tasks`, `ask_user_questions`, `request_confirmation`, `request_checkbox_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/accept` | Accept suggested tasks or confirmation (body: `selectedClientKeys` for `suggest_tasks`; `selectedOptionIds` for `request_checkbox_confirmation`) |
| POST | `/api/issues/:issueId/interactions/:interactionId/reject` | Reject suggested tasks or confirmation |
| POST | `/api/issues/:issueId/interactions/:interactionId/respond` | Respond to structured questions |
| GET | `/api/issues/:issueId/documents` | List issue documents |
+1 -1
View File
@@ -213,7 +213,7 @@ export const issuesApi = {
acceptInteraction: (
id: string,
interactionId: string,
data?: { selectedClientKeys?: string[] },
data?: { selectedClientKeys?: string[]; selectedOptionIds?: string[] },
) =>
api.post<IssueThreadInteraction>(`/issues/${id}/interactions/${interactionId}/accept`, data ?? {}),
rejectInteraction: (id: string, interactionId: string, reason?: string) =>
+295 -1
View File
@@ -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>
);
}
+2 -2
View File
@@ -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
View File
@@ -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");
});
});
+13 -2
View File
@@ -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>}
</>
);
+19 -4
View File
@@ -59,6 +59,7 @@ import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
IssueThreadInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
@@ -161,11 +162,18 @@ interface IssueChatMessageContext {
onDeleteComment?: (commentId: string) => Promise<void> | void;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -361,11 +369,18 @@ interface IssueChatThreadProps {
stoppingRunId?: string | null;
onImageClick?: (src: string) => void;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -294,6 +294,7 @@ function createProject(overrides: Partial<Project> = {}): Project {
leadAgentId: null,
targetDate: null,
color: "#6366f1",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
@@ -8,12 +8,17 @@ import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip";
import {
acceptedManyRequestCheckboxConfirmationInteraction,
boundedRequestCheckboxConfirmationInteraction,
pendingAskUserQuestionsInteraction,
commentExpiredRequestConfirmationInteraction,
disabledDeclineReasonRequestConfirmationInteraction,
failedRequestConfirmationInteraction,
manyOptionsRequestCheckboxConfirmationInteraction,
pendingRequestCheckboxConfirmationInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
staleTargetRequestCheckboxConfirmationInteraction,
staleTargetRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
} from "../fixtures/issueThreadInteractionFixtures";
@@ -338,4 +343,143 @@ describe("IssueThreadInteractionCard", () => {
"This request could not be resolved. Try again or create a new request.",
);
});
it("exposes pending checkbox options with select-all and clear controls", () => {
const host = renderCard({
interaction: pendingRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
expect(checkboxes).toHaveLength(
pendingRequestCheckboxConfirmationInteraction.payload.options.length,
);
expect(checkboxes[0]?.getAttribute("aria-checked")).toBe("false");
expect(host.textContent).toContain("0 of 4 options selected");
const selectAll = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent === "Select all",
);
act(() => {
selectAll?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("All 4 options selected");
const clear = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent === "Clear selection",
);
act(() => {
clear?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("0 of 4 options selected");
});
it("submits selected option ids on accept", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestCheckboxConfirmationInteraction,
onAcceptInteraction,
});
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')];
await act(async () => {
(checkboxes[0] as HTMLButtonElement).click();
(checkboxes[2] as HTMLButtonElement).click();
});
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Delete selected"),
);
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).toHaveBeenCalledWith(
expect.objectContaining({ kind: "request_checkbox_confirmation" }),
undefined,
["draft-march-report", "draft-scratch-notes"],
);
});
it("blocks accept until the minimum selection is met", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: {
...boundedRequestCheckboxConfirmationInteraction,
payload: {
...boundedRequestCheckboxConfirmationInteraction.payload,
defaultSelectedOptionIds: [],
},
},
onAcceptInteraction,
});
const confirmButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Confirm regions"),
);
await act(async () => {
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAcceptInteraction).not.toHaveBeenCalled();
expect(host.textContent).toContain("Select at least 2 options.");
});
it("disables remaining checkboxes once the max selection is reached", () => {
const host = renderCard({
interaction: boundedRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
// Defaults select us-west + us-east; bumping to the 3-item max should lock the rest.
const checkboxes = [...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[];
const unchecked = checkboxes.filter((box) => box.getAttribute("aria-checked") === "false");
act(() => {
unchecked[0]?.click();
});
const stillUnchecked = ([...host.querySelectorAll('[role="checkbox"]')] as HTMLButtonElement[])
.filter((box) => box.getAttribute("aria-checked") === "false");
expect(stillUnchecked.length).toBeGreaterThan(0);
expect(stillUnchecked.every((box) => box.hasAttribute("disabled"))).toBe(true);
const selectAllButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Select all"),
);
expect(selectAllButton?.hasAttribute("disabled")).toBe(true);
});
it("summarizes large accepted selections by count and bounds the chips", () => {
const host = renderCard({
interaction: acceptedManyRequestCheckboxConfirmationInteraction,
});
expect(host.textContent).toContain("Confirmed 42 of 100 options");
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
// 42 selected, but only the first 8 labels render inline, then a "+N more" chip.
expect(host.textContent).toContain("+34 more");
});
it("stays compact and scrollable with around 100 options", () => {
const host = renderCard({
interaction: manyOptionsRequestCheckboxConfirmationInteraction,
onAcceptInteraction: vi.fn(),
});
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(100);
const scrollRegion = host.querySelector('[aria-label="Selectable options"]');
expect(scrollRegion?.className).toContain("max-h-80");
expect(scrollRegion?.className).toContain("overflow-y-auto");
});
it("renders stale-target expiry for checkbox confirmations", () => {
const host = renderCard({
interaction: staleTargetRequestCheckboxConfirmationInteraction,
});
expect(host.textContent).toContain("Expired by target change");
expect(host.textContent).toContain("Plan v3");
expect(host.textContent).toContain("Plan v4");
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(0);
});
});
+532 -71
View File
@@ -7,11 +7,15 @@ import {
buildSuggestedTaskTree,
collectSuggestedTaskClientKeys,
countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type RequestConfirmationResult,
type RequestConfirmationTarget,
type SuggestTasksInteraction,
type SuggestTasksResultCreatedTask,
@@ -34,11 +38,18 @@ interface IssueThreadInteractionCardProps {
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
onAcceptInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: SuggestTasksInteraction | RequestConfirmationInteraction,
interaction:
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
onSubmitInteractionAnswers?: (
@@ -96,6 +107,8 @@ function interactionKindLabel(kind: IssueThreadInteraction["kind"]) {
return "Ask user questions";
case "request_confirmation":
return "Confirmation";
case "request_checkbox_confirmation":
return "Checkbox confirmation";
default:
return kind;
}
@@ -971,33 +984,18 @@ function requestConfirmationTargetLabel(target: RequestConfirmationTarget) {
return `${target.key}${revision}`;
}
function requestConfirmationTargetHref({
interaction,
target,
}: {
interaction: RequestConfirmationInteraction;
target: RequestConfirmationTarget;
}) {
if (target.href) return target.href;
if (target.type === "issue_document") {
const issueId = target.issueId ?? interaction.issueId;
return `/issues/${issueId}#document-${encodeURIComponent(target.key)}`;
}
return null;
}
function RequestConfirmationTargetChip({
interaction,
issueId,
target,
tone = "default",
}: {
interaction: RequestConfirmationInteraction;
issueId: string;
target: RequestConfirmationTarget | null | undefined;
tone?: "default" | "subtle";
}) {
if (!target) return null;
const href = requestConfirmationTargetHref({ interaction, target });
const href = getRequestConfirmationTargetHref({ issueId, target });
const className = cn(
"inline-flex max-w-full items-center gap-1.5 rounded-sm border px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em]",
tone === "default"
@@ -1027,81 +1025,121 @@ function RequestConfirmationTargetChip({
);
}
function ConfirmationDeclinedBlock({
issueId,
result,
target,
}: {
issueId: string;
result: RequestConfirmationResult | null | undefined;
target: RequestConfirmationTarget | null;
}) {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Declined</span>
<RequestConfirmationTargetChip issueId={issueId} target={target} />
</div>
{result?.reason ? (
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
{result.reason}
</blockquote>
) : null}
</div>
);
}
function ConfirmationExpiredBlock({
issueId,
result,
target,
}: {
issueId: string;
result: RequestConfirmationResult | null | undefined;
target: RequestConfirmationTarget | null;
}) {
const outcome = result?.outcome;
const staleTarget = result?.staleTarget ?? null;
const expiredByComment = outcome === "superseded_by_comment";
const expiredByTargetChange = outcome === "stale_target";
return (
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
{expiredByComment ? "Expired by comment" : "Expired by target change"}
</div>
<p className="leading-6">
{expiredByComment
? "A board comment superseded this request before it was resolved."
: "The requested target changed before this request was resolved."}
</p>
{expiredByComment && result?.commentId ? (
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
<a href={`#comment-${result.commentId}`}>Jump to comment</a>
</Button>
) : null}
{expiredByTargetChange ? (
<div className="flex flex-wrap items-center gap-2">
<RequestConfirmationTargetChip
issueId={issueId}
target={staleTarget}
tone="subtle"
/>
{staleTarget && target ? (
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
) : null}
<RequestConfirmationTargetChip issueId={issueId} target={target} />
</div>
) : null}
</div>
);
}
function ConfirmationFailedBlock() {
return (
<p className="text-sm leading-6 text-muted-foreground">
This request could not be resolved. Try again or create a new request.
</p>
);
}
function RequestConfirmationResolution({
interaction,
}: {
interaction: RequestConfirmationInteraction;
}) {
const outcome = interaction.result?.outcome;
const target = interaction.payload.target ?? null;
const staleTarget = interaction.result?.staleTarget ?? null;
if (interaction.status === "accepted") {
return (
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Confirmed</span>
<RequestConfirmationTargetChip interaction={interaction} target={target} />
<RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
</div>
);
}
if (interaction.status === "rejected") {
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">Declined</span>
<RequestConfirmationTargetChip interaction={interaction} target={target} />
</div>
{interaction.result?.reason ? (
<blockquote className="rounded-sm border-l-2 border-rose-500/70 bg-rose-500/10 px-3 py-2 text-sm leading-6 text-rose-900 dark:text-rose-100">
{interaction.result.reason}
</blockquote>
) : null}
</div>
<ConfirmationDeclinedBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "expired") {
const expiredByComment = outcome === "superseded_by_comment";
const expiredByTargetChange = outcome === "stale_target";
return (
<div className="space-y-3 rounded-sm border border-amber-500/60 bg-amber-500/10 px-4 py-3 text-sm text-amber-900 dark:text-amber-100">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700">
{expiredByComment ? "Expired by comment" : "Expired by target change"}
</div>
<p className="leading-6">
{expiredByComment
? "A board comment superseded this confirmation before it was resolved."
: "The requested target changed before this confirmation was resolved."}
</p>
{expiredByComment && interaction.result?.commentId ? (
<Button asChild size="sm" variant="ghost" className="h-7 px-2 text-amber-950 hover:bg-amber-500/15 dark:text-amber-50">
<a href={`#comment-${interaction.result.commentId}`}>Jump to comment</a>
</Button>
) : null}
{expiredByTargetChange ? (
<div className="flex flex-wrap items-center gap-2">
<RequestConfirmationTargetChip
interaction={interaction}
target={staleTarget}
tone="subtle"
/>
{staleTarget && target ? (
<ChevronRight className="h-3.5 w-3.5 text-amber-700" />
) : null}
<RequestConfirmationTargetChip interaction={interaction} target={target} />
</div>
) : null}
</div>
<ConfirmationExpiredBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "failed") {
return (
<p className="text-sm leading-6 text-muted-foreground">
This request could not be resolved. Try again or create a new request.
</p>
);
return <ConfirmationFailedBlock />;
}
return null;
@@ -1188,7 +1226,7 @@ function RequestConfirmationCard({
</div>
) : null}
<RequestConfirmationTargetChip
interaction={interaction}
issueId={interaction.issueId}
target={interaction.payload.target}
/>
</div>
@@ -1289,6 +1327,421 @@ function RequestConfirmationCard({
);
}
const CHECKBOX_SUMMARY_LABEL_LIMIT = 8;
function RequestCheckboxConfirmationResolution({
interaction,
}: {
interaction: RequestCheckboxConfirmationInteraction;
}) {
const target = interaction.payload.target ?? null;
if (interaction.status === "accepted") {
const totalOptions = interaction.payload.options.length;
const selectedLabels = getCheckboxConfirmationSelectedLabels({
payload: interaction.payload,
result: interaction.result,
});
const selectedCount = interaction.result?.selectedOptionIds?.length ?? selectedLabels.length;
const visibleLabels = selectedLabels.slice(0, CHECKBOX_SUMMARY_LABEL_LIMIT);
const hiddenCount = selectedLabels.length - visibleLabels.length;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm leading-6 text-foreground">
<span className="font-medium">
{selectedCount === 0
? "Confirmed with no options selected"
: `Confirmed ${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"}`}
</span>
<RequestConfirmationTargetChip issueId={interaction.issueId} target={target} />
</div>
{visibleLabels.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{visibleLabels.map((label, index) => (
<TaskField key={`${label}-${index}`} label="Selected" value={label} />
))}
{hiddenCount > 0 ? (
<span className="inline-flex items-center rounded-sm border border-border/60 bg-transparent px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
+{hiddenCount} more
</span>
) : null}
</div>
) : null}
</div>
);
}
if (interaction.status === "rejected") {
return (
<ConfirmationDeclinedBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "expired") {
return (
<ConfirmationExpiredBlock
issueId={interaction.issueId}
result={interaction.result}
target={target}
/>
);
}
if (interaction.status === "failed") {
return <ConfirmationFailedBlock />;
}
return null;
}
function CheckboxOptionRow({
id,
label,
description,
checked,
disabled,
onToggle,
}: {
id: string;
label: string;
description?: string | null;
checked: boolean;
disabled: boolean;
onToggle: (checked: boolean) => void;
}) {
return (
<label
htmlFor={id}
className={cn(
"flex cursor-pointer items-start gap-2.5 border-b border-border/60 px-3 py-2 last:border-b-0 transition-colors",
checked ? "bg-sky-500/10" : "hover:bg-sky-500/5",
disabled && "cursor-not-allowed opacity-60",
)}
>
<Checkbox
id={id}
checked={checked}
disabled={disabled}
onCheckedChange={(value) => onToggle(value === true)}
aria-label={label}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium leading-5 text-foreground">{label}</div>
{description ? (
<p className="mt-0.5 text-sm leading-5 text-muted-foreground">{description}</p>
) : null}
</div>
</label>
);
}
function RequestCheckboxConfirmationCard({
interaction,
onAcceptInteraction,
onRejectInteraction,
}: {
interaction: RequestCheckboxConfirmationInteraction;
onAcceptInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
selectedClientKeys: undefined,
selectedOptionIds: string[],
) => Promise<void> | void;
onRejectInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
reason?: string,
) => Promise<void> | void;
}) {
const options = interaction.payload.options;
const optionIds = useMemo(() => options.map((option) => option.id), [options]);
const validOptionIds = useMemo(() => new Set(optionIds), [optionIds]);
const minSelected = interaction.payload.minSelected ?? 0;
const maxSelected = interaction.payload.maxSelected ?? null;
const defaultSelected = useMemo(
() =>
new Set(
(interaction.payload.defaultSelectedOptionIds ?? []).filter((id) => validOptionIds.has(id)),
),
[interaction.payload.defaultSelectedOptionIds, validOptionIds],
);
const [selectedOptionIds, setSelectedOptionIds] = useState<Set<string>>(() => new Set(defaultSelected));
const [rejecting, setRejecting] = useState(false);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
const [rejectReason, setRejectReason] = useState(interaction.result?.reason ?? "");
const [rejectAttempted, setRejectAttempted] = useState(false);
const [acceptAttempted, setAcceptAttempted] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const optionSeed = useMemo(() => optionIds.join("\n"), [optionIds]);
useEffect(() => {
setSelectedOptionIds(new Set(defaultSelected));
setRejectReason(interaction.result?.reason ?? "");
setRejectAttempted(false);
setAcceptAttempted(false);
setActionError(null);
if (interaction.status !== "pending") {
setRejecting(false);
setWorking(null);
}
// optionSeed guards against option list identity churn for the same interaction.
}, [interaction.id, interaction.status, interaction.result?.reason, defaultSelected, optionSeed]);
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
const trimmedRejectReason = rejectReason.trim();
const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0;
const declineReasonInvalid = rejectRequiresReason && !canReject;
const declineReasonPlaceholder =
interaction.payload.declineReasonPlaceholder ?? "Optional: tell the agent what you'd change.";
const selectedCount = selectedOptionIds.size;
const totalOptions = options.length;
const atMax = maxSelected != null && selectedCount >= maxSelected;
const belowMin = selectedCount < minSelected;
const aboveMax = maxSelected != null && selectedCount > maxSelected;
const selectionValid = !belowMin && !aboveMax;
const validationMessage = belowMin
? minSelected === 1
? "Select at least 1 option."
: `Select at least ${minSelected} options.`
: aboveMax && maxSelected != null
? maxSelected === 1
? "Select at most 1 option."
: `Select at most ${maxSelected} options.`
: null;
function toggleOption(optionId: string, checked: boolean) {
setSelectedOptionIds((current) => {
const next = new Set(current);
if (checked) {
next.add(optionId);
} else {
next.delete(optionId);
}
return next;
});
}
function handleSelectAll() {
const capped = maxSelected != null ? optionIds.slice(0, maxSelected) : optionIds;
setSelectedOptionIds(new Set(capped));
}
function handleClearSelection() {
setSelectedOptionIds(new Set());
}
async function handleAccept() {
setAcceptAttempted(true);
if (!onAcceptInteraction || !selectionValid) return;
setWorking("accept");
setActionError(null);
try {
await onAcceptInteraction(interaction, undefined, [...selectedOptionIds]);
} catch {
setActionError("Try again");
} finally {
setWorking(null);
}
}
async function handleReject() {
setRejectAttempted(true);
if (!onRejectInteraction || !canReject) return;
setWorking("reject");
setActionError(null);
try {
await onRejectInteraction(interaction, trimmedRejectReason || undefined);
setRejecting(false);
} catch {
setActionError("Try again");
} finally {
setWorking(null);
}
}
if (interaction.status !== "pending") {
return (
<div className="space-y-4">
<RequestCheckboxConfirmationResolution interaction={interaction} />
</div>
);
}
const selectionSummary = totalOptions > 0 && selectedCount === totalOptions
? `All ${totalOptions} options selected`
: `${selectedCount} of ${totalOptions} ${totalOptions === 1 ? "option" : "options"} selected`;
const boundsHint = maxSelected != null
? `Pick ${minSelected === maxSelected ? `exactly ${maxSelected}` : `${minSelected}–${maxSelected}`}.`
: minSelected > 0
? `Pick at least ${minSelected}.`
: null;
return (
<div className="space-y-4">
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-4">
<div className="text-sm leading-6 text-foreground">{interaction.payload.prompt}</div>
{interaction.payload.detailsMarkdown ? (
<div className="border-t border-border/60 pt-3 text-sm">
<MarkdownBody>{interaction.payload.detailsMarkdown}</MarkdownBody>
</div>
) : null}
<RequestConfirmationTargetChip
issueId={interaction.issueId}
target={interaction.payload.target}
/>
</div>
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{selectionSummary}</span>
{boundsHint ? <span>{boundsHint}</span> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null || selectedCount === totalOptions || (maxSelected != null && selectedCount >= maxSelected)}
onClick={handleSelectAll}
>
Select all
</Button>
<Button
size="sm"
variant="ghost"
disabled={working !== null || selectedCount === 0}
onClick={handleClearSelection}
>
Clear selection
</Button>
</div>
</div>
<div
role="group"
aria-label="Selectable options"
className="max-h-80 overflow-y-auto rounded-sm border border-border/70"
>
{options.map((option) => {
const checked = selectedOptionIds.has(option.id);
return (
<CheckboxOptionRow
key={option.id}
id={`${interaction.id}-${option.id}`}
label={option.label}
description={option.description}
checked={checked}
disabled={working !== null || (!checked && atMax)}
onToggle={(value) => toggleOption(option.id, value)}
/>
);
})}
</div>
{acceptAttempted && validationMessage ? (
<p className="text-xs text-destructive">{validationMessage}</p>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant={rejecting ? "outline" : "default"}
disabled={!onAcceptInteraction || working !== null}
onClick={() => void handleAccept()}
>
{working === "accept" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Confirming...
</>
) : (
interaction.payload.acceptLabel ?? "Confirm selected"
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => {
if (!allowDeclineReason) {
void handleReject();
return;
}
setRejectAttempted(false);
setRejecting((current) => !current);
}}
>
{interaction.payload.rejectLabel ?? "Request changes"}
</Button>
</div>
{rejecting ? (
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
<Textarea
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder={declineReasonPlaceholder}
aria-invalid={rejectAttempted && declineReasonInvalid}
className={cn(
"min-h-24 bg-background text-sm",
rejectAttempted && declineReasonInvalid
&& "border-rose-500 focus-visible:ring-rose-500/25",
)}
/>
{rejectAttempted && declineReasonInvalid ? (
<p className="text-xs text-destructive">A reason is required.</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => {
setRejecting(false);
setRejectAttempted(false);
}}
>
Cancel
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => void handleReject()}
>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Saving...
</>
) : (
interaction.payload.rejectLabel ?? "Request changes"
)}
</Button>
</div>
</div>
) : null}
{actionError ? (
<div className="rounded-sm border border-destructive/60 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
</div>
</div>
);
}
export function IssueThreadInteractionCard({
interaction,
agentMap,
@@ -1347,7 +1800,9 @@ export function IssueThreadInteractionCard({
? "Suggested task tree"
: interaction.kind === "ask_user_questions"
? interaction.payload.title ?? "Questions for the operator"
: "Confirmation requested")}
: interaction.kind === "request_checkbox_confirmation"
? "Checkbox confirmation requested"
: "Confirmation requested")}
</div>
{interaction.summary ? (
<p className="mt-2 max-w-3xl text-sm leading-6 text-muted-foreground">
@@ -1385,6 +1840,12 @@ export function IssueThreadInteractionCard({
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
/>
) : interaction.kind === "request_checkbox_confirmation" ? (
<RequestCheckboxConfirmationCard
interaction={interaction}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
) : (
<RequestConfirmationCard
interaction={interaction}
+1 -2
View File
@@ -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 } : {}),
});
+46
View File
@@ -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");
});
});
+52
View File
@@ -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,
+65 -6
View File
@@ -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();
+14 -4
View File
@@ -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} />
+121 -1
View File
@@ -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({
+40 -2
View File
@@ -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,
+2 -4
View File
@@ -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>
+45 -1
View File
@@ -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)}
/>
);
}
@@ -6,6 +6,7 @@ import type {
import type { IssueTimelineEvent } from "../lib/issue-timeline-events";
import type {
AskUserQuestionsInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
@@ -224,6 +225,65 @@ function createRequestConfirmationInteraction(
};
}
function createRequestCheckboxConfirmationInteraction(
overrides: Partial<RequestCheckboxConfirmationInteraction>,
): RequestCheckboxConfirmationInteraction {
return {
id: "interaction-checkbox-default",
companyId: issueThreadInteractionFixtureMeta.companyId,
issueId: issueThreadInteractionFixtureMeta.issueId,
kind: "request_checkbox_confirmation",
title: "Choose the stale drafts to delete",
summary:
"The agent found several stale draft documents and needs the board to confirm exactly which ones to remove.",
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-codex",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-20T14:46:00.000Z"),
updatedAt: new Date("2026-04-20T14:46:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Check the draft documents you want me to delete.",
detailsMarkdown:
"Only the checked items will be deleted. Leave an item unchecked to keep it for now.",
options: [
{
id: "draft-march-report",
label: "Old draft report",
description: "Created by QA during the March test pass.",
},
{
id: "draft-spec-v1",
label: "Spec v1 (superseded)",
description: "Replaced by the approved v2 specification.",
},
{
id: "draft-scratch-notes",
label: "Scratch notes",
description: "Unstructured notes from the kickoff call.",
},
{
id: "draft-import-sample",
label: "Import sample fixture",
description: "Temporary fixture used while wiring the importer.",
},
],
defaultSelectedOptionIds: [],
minSelected: 0,
maxSelected: null,
acceptLabel: "Delete selected",
rejectLabel: "Request changes",
rejectRequiresReason: false,
},
result: null,
...overrides,
};
}
export const pendingSuggestedTasksInteraction = createSuggestTasksInteraction({});
export const acceptedSuggestedTasksInteraction = createSuggestTasksInteraction({
@@ -465,6 +525,145 @@ export const failedRequestConfirmationInteraction = createRequestConfirmationInt
updatedAt: new Date("2026-04-20T14:42:00.000Z"),
});
export const pendingRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({});
export const boundedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-bounded",
title: "Pick the regions to deploy first",
summary: "Choose between two and three regions for the Phase 1 rollout.",
payload: {
version: 1,
prompt: "Which regions should we deploy to initially?",
detailsMarkdown: "Select at least 2 and at most 3 regions.",
options: [
{ id: "us-west", label: "US West (Oregon)", description: "Lowest latency for our primary user base." },
{ id: "us-east", label: "US East (Virginia)", description: "Redundancy and east coast coverage." },
{ id: "eu-west", label: "EU West (Ireland)", description: "GDPR compliance and European users." },
{ id: "ap-southeast", label: "AP Southeast (Singapore)", description: "Asia-Pacific expansion." },
],
defaultSelectedOptionIds: ["us-west", "us-east"],
minSelected: 2,
maxSelected: 3,
acceptLabel: "Confirm regions",
rejectLabel: "Reconsider",
rejectRequiresReason: true,
rejectReasonLabel: "What should change about the region set?",
},
});
export const acceptedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-accepted",
status: "accepted",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:49:00.000Z"),
updatedAt: new Date("2026-04-20T14:49:00.000Z"),
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: ["draft-march-report", "draft-spec-v1"],
},
});
const manyOptionList = Array.from({ length: 100 }, (_, index) => {
const number = index + 1;
return {
id: `record-${number}`,
label: `Customer record #${number}`,
description: number % 4 === 0
? "Flagged as a possible duplicate during the last import."
: undefined,
};
});
export const manyOptionsRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-many",
title: "Select the customer records to archive",
summary: "The cleanup job found 100 stale customer records. Confirm which ones to archive.",
payload: {
version: 1,
prompt: "Check every customer record you want archived.",
detailsMarkdown: "The list scrolls. Use Select all / Clear selection to move quickly.",
options: manyOptionList,
defaultSelectedOptionIds: [],
minSelected: 0,
maxSelected: null,
acceptLabel: "Archive selected",
rejectLabel: "Request changes",
rejectRequiresReason: false,
},
});
export const acceptedManyRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-many-accepted",
status: "accepted",
title: "Select the customer records to archive",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:52:00.000Z"),
updatedAt: new Date("2026-04-20T14:52:00.000Z"),
payload: manyOptionsRequestCheckboxConfirmationInteraction.payload,
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: manyOptionList.slice(0, 42).map((option) => option.id),
},
});
export const rejectedRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-rejected",
status: "rejected",
resolvedByUserId: issueThreadInteractionFixtureMeta.currentUserId,
resolvedAt: new Date("2026-04-20T14:50:00.000Z"),
updatedAt: new Date("2026-04-20T14:50:00.000Z"),
result: {
version: 1,
outcome: "rejected",
reason: "Don't delete anything yet — let me confirm with the data owner first.",
},
});
export const staleTargetRequestCheckboxConfirmationInteraction =
createRequestCheckboxConfirmationInteraction({
id: "interaction-checkbox-stale",
status: "expired",
resolvedByAgentId: "agent-codex",
resolvedAt: new Date("2026-04-20T14:51:00.000Z"),
updatedAt: new Date("2026-04-20T14:51:00.000Z"),
payload: {
version: 1,
prompt: "Check the draft documents you want me to delete.",
acceptLabel: "Delete selected",
rejectLabel: "Request changes",
options: [
{ id: "draft-march-report", label: "Old draft report" },
{ id: "draft-spec-v1", label: "Spec v1 (superseded)" },
],
target: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "44444444-4444-4444-8444-444444444444",
revisionNumber: 4,
},
},
result: {
version: 1,
outcome: "stale_target",
staleTarget: {
type: "issue_document",
issueId: issueThreadInteractionFixtureMeta.issueId,
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
revisionNumber: 3,
},
},
});
export const issueThreadInteractionComments: IssueChatComment[] = [
createComment({
id: "comment-thread-board",
+29
View File
@@ -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,
@@ -4,7 +4,10 @@ import {
buildSuggestedTaskTree,
collectSuggestedTaskClientKeys,
countSuggestedTaskNodes,
getCheckboxConfirmationSelectedLabels,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels,
normalizeRequestConfirmationTargetHref,
} from "./issue-thread-interactions";
describe("buildSuggestedTaskTree", () => {
@@ -126,6 +129,110 @@ describe("issue thread interaction helpers", () => {
})).toBe("Answered 1 question");
});
it("summarizes checkbox confirmation interactions by count", () => {
const base = {
id: "interaction-checkbox",
companyId: "company-1",
issueId: "issue-1",
kind: "request_checkbox_confirmation" as const,
continuationPolicy: "wake_assignee" as const,
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:00:00.000Z",
payload: {
version: 1 as const,
prompt: "Pick items",
options: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
{ id: "c", label: "C" },
],
},
};
expect(buildIssueThreadInteractionSummary({ ...base, status: "pending" }))
.toBe("Requested a selection from 3 options");
expect(buildIssueThreadInteractionSummary({
...base,
status: "accepted",
result: { version: 1, outcome: "accepted", selectedOptionIds: ["a", "c"] },
})).toBe("Confirmed 2 of 3 options");
expect(buildIssueThreadInteractionSummary({
...base,
status: "accepted",
result: { version: 1, outcome: "accepted", selectedOptionIds: [] },
})).toBe("Confirmed with no options selected");
expect(buildIssueThreadInteractionSummary({
...base,
status: "expired",
result: { version: 1, outcome: "stale_target" },
})).toBe("Selection expired after target changed");
});
it("maps selected checkbox option ids back to labels", () => {
const labels = getCheckboxConfirmationSelectedLabels({
payload: {
version: 1,
prompt: "Pick items",
options: [
{ id: "a", label: "Alpha" },
{ id: "b", label: "Bravo" },
{ id: "c", label: "Charlie" },
],
},
result: { version: 1, outcome: "accepted", selectedOptionIds: ["c", "a", "missing"] },
});
expect(labels).toEqual(["Charlie", "Alpha"]);
});
it("allows only safe confirmation target hrefs for rendering", () => {
for (const href of [
"https://example.com/checklist",
"http://example.com/checklist",
"/PAP/issues/PAP-123#document-plan",
"#document-plan",
]) {
expect(normalizeRequestConfirmationTargetHref(href)).toBe(href);
}
for (const href of [
"file:///tmp/x",
"mailto:user@example.com",
"slack://channel?id=1",
"vscode://file/tmp/x",
"ftp://example.com/file",
"//evil.example/path",
]) {
expect(normalizeRequestConfirmationTargetHref(href)).toBeNull();
}
});
it("does not return unsafe custom target hrefs from accepted payloads", () => {
expect(getRequestConfirmationTargetHref({
issueId: "issue-1",
target: {
type: "custom",
key: "unsafe-target",
label: "Unsafe target",
href: "file:///tmp/x",
},
})).toBeNull();
expect(getRequestConfirmationTargetHref({
issueId: "issue-1",
target: {
type: "issue_document",
issueId: "issue-2",
key: "plan",
revisionId: "11111111-1111-4111-8111-111111111111",
href: "slack://channel?id=1",
},
})).toBe("/issues/issue-2#document-plan");
});
it("maps stored option ids back to labels for answered summaries", () => {
const labels = getQuestionAnswerLabels({
question: {
+68
View File
@@ -10,6 +10,10 @@ export type {
IssueThreadInteractionBase,
IssueThreadInteractionContinuationPolicy,
IssueThreadInteractionStatus,
RequestCheckboxConfirmationInteraction,
RequestCheckboxConfirmationOption,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationIssueDocumentTarget,
RequestConfirmationPayload,
@@ -26,7 +30,10 @@ import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
IssueThreadInteraction,
RequestCheckboxConfirmationPayload,
RequestCheckboxConfirmationResult,
RequestConfirmationInteraction,
RequestConfirmationTarget,
SuggestedTaskDraft,
SuggestTasksInteraction,
SuggestTasksResultCreatedTask,
@@ -49,9 +56,49 @@ export function isIssueThreadInteraction(
candidate.kind === "suggest_tasks"
|| candidate.kind === "ask_user_questions"
|| candidate.kind === "request_confirmation"
|| candidate.kind === "request_checkbox_confirmation"
);
}
export function getCheckboxConfirmationSelectedLabels(args: {
payload: RequestCheckboxConfirmationPayload;
result?: RequestCheckboxConfirmationResult | null;
}): string[] {
const { payload, result } = args;
const selectedIds = result?.selectedOptionIds ?? [];
const optionLabelById = new Map(
payload.options.map((option) => [option.id, option.label] as const),
);
return selectedIds
.map((optionId) => optionLabelById.get(optionId))
.filter((label): label is string => typeof label === "string");
}
export function normalizeRequestConfirmationTargetHref(href: string) {
const value = href.trim();
if (value.startsWith("#")) return value;
if (value.startsWith("/")) return value.startsWith("//") ? null : value;
return /^https?:\/\//i.test(value) ? value : null;
}
export function getRequestConfirmationTargetHref({
issueId,
target,
}: {
issueId: string;
target: RequestConfirmationTarget;
}) {
if (target.href) {
const safeHref = normalizeRequestConfirmationTargetHref(target.href);
if (safeHref) return safeHref;
}
if (target.type === "issue_document") {
const targetIssueId = target.issueId ?? issueId;
return `/issues/${targetIssueId}#document-${encodeURIComponent(target.key)}`;
}
return null;
}
export function buildIssueThreadInteractionSummary(
interaction: IssueThreadInteraction,
) {
@@ -83,6 +130,27 @@ export function buildIssueThreadInteractionSummary(
return "Requested confirmation";
}
if (interaction.kind === "request_checkbox_confirmation") {
const optionCount = interaction.payload.options.length;
if (interaction.status === "accepted") {
const selectedCount = interaction.result?.selectedOptionIds?.length ?? 0;
if (selectedCount === 0) return "Confirmed with no options selected";
return selectedCount === 1
? `Confirmed 1 of ${optionCount} options`
: `Confirmed ${selectedCount} of ${optionCount} options`;
}
if (interaction.status === "rejected") return "Declined selection";
if (interaction.status === "expired") {
const outcome = interaction.result?.outcome;
if (outcome === "superseded_by_comment") return "Selection expired after comment";
if (outcome === "stale_target") return "Selection expired after target changed";
return "Selection expired";
}
return optionCount === 1
? "Requested a selection from 1 option"
: `Requested a selection from ${optionCount} options`;
}
const count = interaction.payload.questions.length;
if (interaction.status === "answered") {
return count === 1 ? "Answered 1 question" : `Answered ${count} questions`;
+1
View File
@@ -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,
+16
View File
@@ -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");
});
});
+94
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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
+44 -1
View File
@@ -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
View File
@@ -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">&#10003;</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,
+12 -9
View File
@@ -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">
+13 -3
View File
@@ -156,13 +156,17 @@ import {
type IssueWorkProduct,
type IssueWorkMode,
type IssueThreadInteraction,
type RequestCheckboxConfirmationInteraction,
type RequestConfirmationInteraction,
type SuggestTasksInteraction,
type IssueTreeControlMode,
} from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment;
type ActionableIssueThreadInteraction = SuggestTasksInteraction | RequestConfirmationInteraction;
type ActionableIssueThreadInteraction =
| SuggestTasksInteraction
| RequestConfirmationInteraction
| RequestCheckboxConfirmationInteraction;
type ResolveRecoveryActionOutcome = "restored" | "false_positive" | "blocked" | "cancelled";
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
runId?: string | null;
@@ -674,6 +678,7 @@ type IssueDetailChatTabProps = {
onAcceptInteraction: (
interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => Promise<void>;
onRejectInteraction: (interaction: ActionableIssueThreadInteraction, reason?: string) => Promise<void>;
onSubmitInteractionAnswers: (
@@ -2147,10 +2152,12 @@ export function IssueDetail() {
mutationFn: ({
interaction,
selectedClientKeys,
selectedOptionIds,
}: {
interaction: ActionableIssueThreadInteraction;
selectedClientKeys?: string[];
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys }),
selectedOptionIds?: string[];
}) => issuesApi.acceptInteraction(issueId!, interaction.id, { selectedClientKeys, selectedOptionIds }),
onSuccess: (interaction) => {
upsertInteractionInCache(interaction);
if (interaction.kind === "suggest_tasks" && resolvedCompanyId && issue?.id) {
@@ -2167,6 +2174,8 @@ export function IssueDetail() {
pushToast({
title: interaction.kind === "request_confirmation"
? "Request confirmed"
: interaction.kind === "request_checkbox_confirmation"
? "Selection confirmed"
: skippedCount > 0
? `Accepted ${createdCount} draft${createdCount === 1 ? "" : "s"} and skipped ${skippedCount}`
: "Suggested tasks accepted",
@@ -3081,8 +3090,9 @@ export function IssueDetail() {
const handleAcceptInteraction = useCallback(async (
interaction: ActionableIssueThreadInteraction,
selectedClientKeys?: string[],
selectedOptionIds?: string[],
) => {
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys });
await acceptInteraction.mutateAsync({ interaction, selectedClientKeys, selectedOptionIds });
}, [acceptInteraction]);
const handleRejectInteraction = useCallback(async (interaction: ActionableIssueThreadInteraction, reason?: string) => {
await rejectInteraction.mutateAsync({ interaction, reason });
+1
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -76,6 +76,7 @@ function makeProject(overrides: Partial<Project>): Project {
leadAgentId: null,
targetDate: null,
color: "#ef4444",
icon: null,
env: null,
pauseReason: null,
pausedAt: null,
+15 -2
View File
@@ -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>
)}
+1
View File
@@ -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,
@@ -4,9 +4,12 @@ import { IssueChatThread } from "@/components/IssueChatThread";
import { IssueThreadInteractionCard } from "@/components/IssueThreadInteractionCard";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
acceptedManyRequestCheckboxConfirmationInteraction,
acceptedRequestCheckboxConfirmationInteraction,
acceptedSuggestedTasksInteraction,
answeredAskUserQuestionsInteraction,
acceptedRequestConfirmationInteraction,
boundedRequestCheckboxConfirmationInteraction,
commentExpiredRequestConfirmationInteraction,
failedRequestConfirmationInteraction,
genericPendingRequestConfirmationInteraction,
@@ -15,20 +18,25 @@ import {
issueThreadInteractionFixtureMeta,
issueThreadInteractionLiveRuns,
issueThreadInteractionTranscriptsByRunId,
manyOptionsRequestCheckboxConfirmationInteraction,
mixedIssueThreadInteractions,
optionalDeclineRequestConfirmationInteraction,
pendingAskUserQuestionsInteraction,
pendingRequestCheckboxConfirmationInteraction,
pendingRequestConfirmationInteraction,
pendingSuggestedTasksInteraction,
planApprovalAcceptedRequestConfirmationInteraction,
rejectedNoReasonRequestConfirmationInteraction,
rejectedRequestCheckboxConfirmationInteraction,
rejectedRequestConfirmationInteraction,
rejectedSuggestedTasksInteraction,
staleTargetRequestCheckboxConfirmationInteraction,
staleTargetRequestConfirmationInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import type {
AskUserQuestionsAnswer,
AskUserQuestionsInteraction,
RequestCheckboxConfirmationInteraction,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "@/lib/issue-thread-interactions";
@@ -191,6 +199,47 @@ function InteractiveRequestConfirmationCard() {
);
}
function InteractiveRequestCheckboxConfirmationCard({
pending,
accepted,
rejected,
}: {
pending: RequestCheckboxConfirmationInteraction;
accepted: RequestCheckboxConfirmationInteraction;
rejected: RequestCheckboxConfirmationInteraction;
}) {
const [interaction, setInteraction] = useState<RequestCheckboxConfirmationInteraction>(pending);
return (
<IssueThreadInteractionCard
interaction={interaction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
onAcceptInteraction={(_interaction, _selectedClientKeys, selectedOptionIds) =>
setInteraction({
...accepted,
payload: pending.payload,
result: {
version: 1,
outcome: "accepted",
selectedOptionIds: selectedOptionIds ?? [],
},
})}
onRejectInteraction={(_interaction, reason) =>
setInteraction({
...rejected,
payload: pending.payload,
result: {
version: 1,
outcome: "rejected",
reason: reason || rejected.result?.reason || null,
},
})}
/>
);
}
function AutoOpenDeclineRequestConfirmationCard({
interaction,
}: {
@@ -224,7 +273,7 @@ const meta = {
docs: {
description: {
component:
"Interaction cards for `suggest_tasks`, `ask_user_questions`, and `request_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
"Interaction cards for `suggest_tasks`, `ask_user_questions`, `request_confirmation`, and `request_checkbox_confirmation`, shown both in isolation and inside the real `IssueChatThread` feed.",
},
},
},
@@ -524,6 +573,129 @@ export const RequestConfirmationFailed: Story = {
export const RequestConfirmationAccepted = RequestConfirmationConfirmed;
export const RequestConfirmationRejected = RequestConfirmationDeclinedWithReason;
export const CheckboxConfirmationPending: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Pending checkbox confirmation"
description="Board users select any number of options, with frontend-owned select-all and clear controls."
>
<InteractiveRequestCheckboxConfirmationCard
pending={pendingRequestCheckboxConfirmationInteraction}
accepted={acceptedRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationBounded: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Min/max constrained selection"
description="The card enforces minimum and maximum selection counts and requires a decline reason."
>
<InteractiveRequestCheckboxConfirmationCard
pending={boundedRequestCheckboxConfirmationInteraction}
accepted={acceptedRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationAccepted: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Accepted checkbox confirmation"
description="The resolved state leads with a count and lists the selected labels."
>
<IssueThreadInteractionCard
interaction={acceptedRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationAcceptedMany: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Accepted large selection"
description="Large resolved selections summarize by count first and bound the inline chips."
>
<IssueThreadInteractionCard
interaction={acceptedManyRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationRejected: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Declined checkbox confirmation"
description="The decline reason stays attached to the request in the thread."
>
<IssueThreadInteractionCard
interaction={rejectedRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationStaleTarget: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Expired by target change"
description="The watched plan revision moved before the selection was confirmed."
>
<IssueThreadInteractionCard
interaction={staleTargetRequestCheckboxConfirmationInteraction}
agentMap={storybookAgentMap}
currentUserId={issueThreadInteractionFixtureMeta.currentUserId}
userLabelMap={boardUserLabels}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const CheckboxConfirmationManyOptions: Story = {
render: () => (
<StoryFrame>
<ScenarioCard
title="Around 100 options"
description="The list stays compact inside a bounded scroll region even with 100 options."
>
<InteractiveRequestCheckboxConfirmationCard
pending={manyOptionsRequestCheckboxConfirmationInteraction}
accepted={acceptedManyRequestCheckboxConfirmationInteraction}
rejected={rejectedRequestCheckboxConfirmationInteraction}
/>
</ScenarioCard>
</StoryFrame>
),
};
export const ReviewSurface: Story = {
render: () => (
<StoryFrame>