mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
Merge origin/master into PAP-10430 task copy PR
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
9e88e3ba85
86 files changed
+23506
-527
No files matched your search
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface InstanceGeneralSettings {
|
||||
export interface InstanceExperimentalSettings {
|
||||
enableEnvironments: boolean;
|
||||
enableIsolatedWorkspaces: boolean;
|
||||
enableStreamlinedLeftNavigation: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
enableCloudSync: boolean;
|
||||
autoRestartDevServerWhenIdle: boolean;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]> => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
Copy,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { ApiError } from "../api/client";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { agentRouteRef } from "../lib/utils";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import {
|
||||
buildDuplicateAgentPayload,
|
||||
duplicateAgentName,
|
||||
type DuplicateInstructionsBundle,
|
||||
} from "../lib/duplicate-agent-payload";
|
||||
import type {
|
||||
Agent,
|
||||
AgentInstructionsBundle,
|
||||
AgentInstructionsFileSummary,
|
||||
HeartbeatRun,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export function RunButton({
|
||||
onClick,
|
||||
@@ -49,3 +84,262 @@ export function PauseResumeButton({
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function duplicateInstructionFilePath(
|
||||
_bundle: AgentInstructionsBundle,
|
||||
summary: AgentInstructionsFileSummary,
|
||||
): string | null {
|
||||
if (summary.deprecated || summary.virtual) return null;
|
||||
return summary.path;
|
||||
}
|
||||
|
||||
export async function loadDuplicateInstructionsBundle(
|
||||
agentId: string,
|
||||
companyId?: string,
|
||||
): Promise<DuplicateInstructionsBundle | null> {
|
||||
const bundle = await agentsApi.instructionsBundle(agentId, companyId);
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
for (const summary of bundle.files) {
|
||||
const path = duplicateInstructionFilePath(bundle, summary);
|
||||
if (!path) continue;
|
||||
const file = await agentsApi.instructionsFile(agentId, summary.path, companyId);
|
||||
files[path] = file.content;
|
||||
}
|
||||
|
||||
const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile)
|
||||
? bundle.entryFile
|
||||
: Object.keys(files)[0] ?? "AGENTS.md";
|
||||
return Object.keys(files).length > 0 ? { entryFile, files } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared agent action cluster used by both the agent detail header and the
|
||||
* agents list rows. Encapsulates the invoke / pause / resume / terminate /
|
||||
* duplicate / reset-session mutations so callers do not diverge in behavior.
|
||||
*/
|
||||
export function AgentActionButtons({
|
||||
agent,
|
||||
companyId,
|
||||
size = "sm",
|
||||
assignLabel = "Assign Task",
|
||||
runLabel = "Run now",
|
||||
showStatus = true,
|
||||
actionsDisabled = false,
|
||||
navigateToRunOnInvoke = true,
|
||||
onActionError,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
agent: Agent;
|
||||
companyId?: string | null;
|
||||
size?: "sm" | "default";
|
||||
assignLabel?: string;
|
||||
runLabel?: string;
|
||||
showStatus?: boolean;
|
||||
actionsDisabled?: boolean;
|
||||
navigateToRunOnInvoke?: boolean;
|
||||
/**
|
||||
* Optional inline error reporter. When provided it is used instead of a toast
|
||||
* for action failures (preserves the detail page's inline error banner). When
|
||||
* omitted, failures surface as toasts (used by the list view).
|
||||
*/
|
||||
onActionError?: (message: string | null) => void;
|
||||
/** Extra content rendered just before the overflow menu (e.g. live-run link). */
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { pushToast } = useToastActions();
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
const resolvedCompanyId = companyId ?? agent.companyId;
|
||||
const canonicalAgentRef = agentRouteRef(agent);
|
||||
const isPaused = agent.status === "paused";
|
||||
|
||||
const reportError = useCallback(
|
||||
(message: string) => {
|
||||
if (onActionError) {
|
||||
onActionError(message);
|
||||
} else {
|
||||
pushToast({ title: "Action failed", body: message, tone: "error" });
|
||||
}
|
||||
},
|
||||
[onActionError, pushToast],
|
||||
);
|
||||
|
||||
const invalidateAgent = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(canonicalAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
|
||||
if (resolvedCompanyId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.liveRuns(resolvedCompanyId) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
|
||||
}
|
||||
}, [agent.id, canonicalAgentRef, queryClient, resolvedCompanyId]);
|
||||
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
|
||||
switch (action) {
|
||||
case "invoke": return agentsApi.invoke(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "pause": return agentsApi.pause(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "resume": return agentsApi.resume(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "approve": return agentsApi.approve(agent.id, resolvedCompanyId ?? undefined);
|
||||
case "terminate": return agentsApi.terminate(agent.id, resolvedCompanyId ?? undefined);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, action) => {
|
||||
onActionError?.(null);
|
||||
invalidateAgent();
|
||||
if (action === "invoke" && navigateToRunOnInvoke && data && typeof data === "object" && "id" in data) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
reportError(err instanceof Error ? err.message : "Action failed");
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateAgent = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!resolvedCompanyId) {
|
||||
throw new Error("Agent is not ready to duplicate");
|
||||
}
|
||||
const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId);
|
||||
const payload = buildDuplicateAgentPayload(agent, instructionsBundle);
|
||||
try {
|
||||
return await agentsApi.create(resolvedCompanyId, payload);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) {
|
||||
const hire = await agentsApi.hire(resolvedCompanyId, payload);
|
||||
return hire.agent;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: async (createdAgent) => {
|
||||
onActionError?.(null);
|
||||
if (resolvedCompanyId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
}
|
||||
pushToast({ title: "Agent duplicated", body: createdAgent.name, tone: "success" });
|
||||
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to duplicate agent";
|
||||
onActionError?.(message);
|
||||
pushToast({ title: "Could not duplicate agent", body: message, tone: "error" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDuplicateAgent = useCallback(() => {
|
||||
if (duplicateAgent.isPending) return;
|
||||
const nextName = duplicateAgentName(agent.name);
|
||||
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
|
||||
setMoreOpen(false);
|
||||
if (!confirmed) return;
|
||||
duplicateAgent.mutate();
|
||||
}, [agent.name, duplicateAgent]);
|
||||
|
||||
const resetTaskSession = useMutation({
|
||||
mutationFn: () => agentsApi.resetSession(agent.id, null, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
onActionError?.(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agent.id) });
|
||||
},
|
||||
onError: (err) => {
|
||||
reportError(err instanceof Error ? err.message : "Failed to reset session");
|
||||
},
|
||||
});
|
||||
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const disabled = actionsDisabled || agentAction.isPending;
|
||||
|
||||
return (
|
||||
<div className={className ?? "flex items-center gap-1 sm:gap-2 shrink-0"}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={size}
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">{assignLabel}</span>
|
||||
</Button>
|
||||
<RunButton
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
disabled={disabled || isPendingApproval}
|
||||
label={runLabel}
|
||||
size={size}
|
||||
/>
|
||||
<PauseResumeButton
|
||||
isPaused={isPaused}
|
||||
onPause={() => agentAction.mutate("pause")}
|
||||
onResume={() => agentAction.mutate("resume")}
|
||||
disabled={disabled || isPendingApproval}
|
||||
size={size}
|
||||
/>
|
||||
{showStatus && (
|
||||
<span className="hidden sm:inline">
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`Open actions for ${agent.name}`}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
disabled={duplicateAgent.isPending}
|
||||
onClick={handleDuplicateAgent}
|
||||
>
|
||||
{duplicateAgent.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
Duplicate Agent
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(agent.id);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy Agent ID
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
resetTaskSession.mutate();
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset Sessions
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
agentAction.mutate("terminate");
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Terminate
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { agentsApi } from "../api/agents";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { getAdapterLabel } from "../adapters/adapter-display-registry";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
import { AgentStatusBadge } from "./StatusBadge";
|
||||
import { Identity } from "./Identity";
|
||||
import { formatDate, agentUrl } from "../lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -41,7 +41,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<PropertyRow label="Status">
|
||||
<StatusBadge status={agent.status} />
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</PropertyRow>
|
||||
<PropertyRow label="Role">
|
||||
<span className="text-sm">{roleLabels[agent.role] ?? agent.role}</span>
|
||||
|
||||
@@ -23,4 +23,27 @@ describe("EntityRow", () => {
|
||||
expect(markup).toContain("text-foreground/55");
|
||||
expect(markup).not.toContain("text-inherit");
|
||||
});
|
||||
|
||||
it("renders an optional meta slot and stops the title from flex-growing", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<EntityRow
|
||||
title="Alpha"
|
||||
meta={<span data-testid="meta-cell">gpt-5.4</span>}
|
||||
trailing={<span data-testid="trailing-cell">badge</span>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// meta content renders alongside trailing
|
||||
expect(markup).toContain("meta-cell");
|
||||
expect(markup).toContain("trailing-cell");
|
||||
// a flex-1 spacer is inserted (between meta and trailing); the title block
|
||||
// itself no longer flex-grows
|
||||
expect(markup).toContain('class="flex-1"');
|
||||
expect(markup).not.toContain("min-w-0 flex-1");
|
||||
});
|
||||
|
||||
it("keeps the title flex-growing when no meta is provided", () => {
|
||||
const markup = renderToStaticMarkup(<EntityRow title="Alpha" />);
|
||||
expect(markup).toContain("min-w-0 flex-1");
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,18 @@ interface EntityRowProps {
|
||||
identifier?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/**
|
||||
* Optional metadata columns rendered immediately after the title. When set,
|
||||
* the title stops flex-growing and a spacer is inserted between `meta` and
|
||||
* `trailing`, so meta sits next to the name while trailing stays pinned right.
|
||||
*/
|
||||
meta?: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
selected?: boolean;
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
titleClassName?: string;
|
||||
reserveSubtitleSpace?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,11 +27,13 @@ export function EntityRow({
|
||||
identifier,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
trailing,
|
||||
selected,
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
titleClassName,
|
||||
reserveSubtitleSpace,
|
||||
}: EntityRowProps) {
|
||||
const isClickable = !!(to || onClick);
|
||||
@@ -38,14 +47,14 @@ export function EntityRow({
|
||||
const content = (
|
||||
<>
|
||||
{leading && <div className="flex items-center gap-2 shrink-0">{leading}</div>}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn("min-w-0", !meta && "flex-1", titleClassName)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{identifier && (
|
||||
<span className="text-xs text-muted-foreground font-mono shrink-0 relative top-[1px]">
|
||||
{identifier}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{title}</span>
|
||||
<span className="truncate" title={title}>{title}</span>
|
||||
</div>
|
||||
{(subtitle || reserveSubtitleSpace) && (
|
||||
<p
|
||||
@@ -56,6 +65,8 @@ export function EntityRow({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="flex items-center gap-2 shrink-0">{meta}</div>}
|
||||
{meta && <div className="flex-1" />}
|
||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { PROJECT_COLORS } from "@paperclipai/shared";
|
||||
import { cn } from "../lib/utils";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { StatusBadge } from "./StatusBadge";
|
||||
@@ -165,7 +164,7 @@ export function NewProjectDialog() {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
status,
|
||||
color: PROJECT_COLORS[Math.floor(Math.random() * PROJECT_COLORS.length)],
|
||||
// No color is sent — new projects persist color = null (neutral gray). See PAP-68.
|
||||
...(goalIds.length > 0 ? { goalIds } : {}),
|
||||
...(targetDate ? { targetDate } : {}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
|
||||
describe("ProjectTile", () => {
|
||||
it("renders a neutral gray folder tile by default", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile />);
|
||||
// Neutral tile uses the muted token, never an inline background color.
|
||||
expect(markup).toContain("bg-muted");
|
||||
expect(markup).toContain("text-muted-foreground");
|
||||
expect(markup).not.toContain("background-color");
|
||||
// Folder icon is the default when no icon prop is supplied.
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("renders the chosen Lucide icon when an icon prop is provided", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile icon="rocket" />);
|
||||
expect(markup).toContain("lucide-rocket");
|
||||
expect(markup).not.toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("falls back to the folder icon for an unknown icon name", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile icon="not-a-real-icon" />);
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("tints the background when a color is provided", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile color="#22c55e" />);
|
||||
expect(markup).toContain("background-color:#22c55e");
|
||||
expect(markup).toContain("text-white");
|
||||
// Tinted tile drops the muted neutral background.
|
||||
expect(markup).not.toContain("bg-muted");
|
||||
expect(markup).toContain("lucide-folder");
|
||||
});
|
||||
|
||||
it("treats null color as neutral (not tinted)", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile color={null} />);
|
||||
expect(markup).toContain("bg-muted");
|
||||
expect(markup).not.toContain("background-color");
|
||||
});
|
||||
|
||||
it("applies size styles", () => {
|
||||
const markup = renderToStaticMarkup(<ProjectTile size="lg" />);
|
||||
expect(markup).toContain("h-9 w-9");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cn } from "../lib/utils";
|
||||
import { getProjectIcon } from "../lib/project-icons";
|
||||
|
||||
/**
|
||||
* Reusable project tile (IA Phase 3 — PAP-58; icon picker added PAP-68 part 3).
|
||||
*
|
||||
* Default render is a neutral gray rounded rectangle with a folder icon.
|
||||
* An optional `color` tints the background; an optional `icon` selects which
|
||||
* Lucide glyph to render (defaults to `folder`).
|
||||
*
|
||||
* Used by the Projects list rows and the project detail header. Both `color`
|
||||
* and `icon` live on the project itself (`project.color` / `project.icon`).
|
||||
*/
|
||||
|
||||
export type ProjectTileSize = "xs" | "sm" | "md" | "lg";
|
||||
|
||||
const SIZE_STYLES: Record<ProjectTileSize, { box: string; icon: string }> = {
|
||||
xs: { box: "h-4 w-4 rounded-sm", icon: "h-2.5 w-2.5" },
|
||||
sm: { box: "h-6 w-6 rounded-md", icon: "h-3.5 w-3.5" },
|
||||
md: { box: "h-7 w-7 rounded-lg", icon: "h-4 w-4" },
|
||||
lg: { box: "h-9 w-9 rounded-lg", icon: "h-5 w-5" },
|
||||
};
|
||||
|
||||
export interface ProjectTileProps {
|
||||
/** Optional project color. When unset, the tile stays neutral gray. */
|
||||
color?: string | null;
|
||||
/** Optional Lucide icon name. When unset, defaults to `folder`. */
|
||||
icon?: string | null;
|
||||
size?: ProjectTileSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectTile({ color, icon, size = "md", className }: ProjectTileProps) {
|
||||
const dims = SIZE_STYLES[size];
|
||||
const tinted = Boolean(color);
|
||||
const Icon = getProjectIcon(icon);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center",
|
||||
dims.box,
|
||||
tinted ? "text-white" : "bg-muted text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
style={tinted ? { backgroundColor: color ?? undefined } : undefined}
|
||||
>
|
||||
<Icon className={dims.icon} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ function createProject(): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#22c55e",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
@@ -82,12 +82,14 @@ vi.mock("./SidebarCompanyMenu", () => ({
|
||||
SidebarCompanyMenu: () => <div>Company menu</div>,
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => null,
|
||||
vi.mock("./SidebarAgents", () => ({
|
||||
SidebarAgents: ({ streamlined }: { streamlined?: boolean }) => (
|
||||
<div data-testid="sidebar-agents" data-streamlined={String(streamlined)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./SidebarAgents", () => ({
|
||||
SidebarAgents: () => null,
|
||||
vi.mock("./SidebarProjects", () => ({
|
||||
SidebarProjects: () => <div data-testid="sidebar-projects">Projects collapsible</div>,
|
||||
}));
|
||||
|
||||
async function flushReact() {
|
||||
@@ -146,7 +148,10 @@ describe("Sidebar", () => {
|
||||
});
|
||||
|
||||
it("renders plugin sidebar launchers inside the Work section", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
const workSection = [...container.querySelectorAll("nav [data-plugin-launcher-zone]")]
|
||||
@@ -162,6 +167,60 @@ describe("Sidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("streamlined (flag ON): keeps Issue wording, top-level Projects link, no per-project collapsible", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: true,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Issue");
|
||||
expect(container.textContent).not.toContain("New Task");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Issues");
|
||||
expect(navLabels).not.toContain("Tasks");
|
||||
|
||||
const projectsLink = [...container.querySelectorAll("nav a")].find((a) => a.textContent?.trim() === "Projects");
|
||||
expect(projectsLink?.getAttribute("href")).toBe("/projects");
|
||||
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("true");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("classic (flag OFF): New Issue button, Issues label, per-project collapsible, no top-level Projects link", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableIsolatedWorkspaces: false,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
});
|
||||
const root = await renderSidebar();
|
||||
|
||||
expect(container.textContent).toContain("New Issue");
|
||||
expect(container.textContent).not.toContain("New Task");
|
||||
|
||||
const navLabels = [...container.querySelectorAll("nav a")].map((a) => a.textContent?.trim());
|
||||
expect(navLabels).toContain("Issues");
|
||||
expect(navLabels).not.toContain("Tasks");
|
||||
// No top-level Projects nav link in classic mode (D5 option A).
|
||||
expect(navLabels).not.toContain("Projects");
|
||||
|
||||
// Per-project collapsible restored below Work.
|
||||
expect(container.querySelector('[data-testid="sidebar-projects"]')).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-testid="sidebar-agents"]')?.getAttribute("data-streamlined"),
|
||||
).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders plugin sidebar slots in Work below Workspaces", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: true });
|
||||
const root = await renderSidebar();
|
||||
|
||||
@@ -13,13 +13,14 @@ import {
|
||||
GitBranch,
|
||||
Package,
|
||||
Settings,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NavLink } from "@/lib/router";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { SidebarAgents } from "./SidebarAgents";
|
||||
import { SidebarProjects } from "./SidebarProjects";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
@@ -47,6 +48,11 @@ export function Sidebar() {
|
||||
});
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true;
|
||||
// IA flag (PAP-89): branch the sidebar nav presentation. Default OFF = classic
|
||||
// (per-project collapsible, no Projects nav link). ON = streamlined
|
||||
// (top-level Projects link). Issue/Task wording is split to PR #7651.
|
||||
// Gating is navigation-only; all routes stay registered in both modes.
|
||||
const streamlined = experimentalSettings?.enableStreamlinedLeftNavigation === true;
|
||||
|
||||
const pluginContext = {
|
||||
companyId: selectedCompanyId,
|
||||
@@ -78,7 +84,7 @@ export function Sidebar() {
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
data-slot="icon-button"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
className="flex items-center gap-2.5 px-3 py-2 pointer-coarse:py-1.5 text-[13px] font-medium text-foreground/80 hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
@@ -102,6 +108,9 @@ export function Sidebar() {
|
||||
{showWorkspacesLink ? (
|
||||
<SidebarNavItem to="/workspaces" label="Workspaces" icon={GitBranch} />
|
||||
) : null}
|
||||
{streamlined ? (
|
||||
<SidebarNavItem to="/projects" label="Projects" icon={FolderOpen} />
|
||||
) : null}
|
||||
<PluginSlotOutlet
|
||||
slotTypes={["sidebar"]}
|
||||
context={pluginContext}
|
||||
@@ -117,9 +126,10 @@ export function Sidebar() {
|
||||
/>
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
{/* Classic mode restores the per-project collapsible below Work. */}
|
||||
{streamlined ? null : <SidebarProjects />}
|
||||
|
||||
<SidebarAgents />
|
||||
<SidebarAgents streamlined={streamlined} />
|
||||
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/org" label="Org" icon={Network} />
|
||||
|
||||
@@ -186,10 +186,19 @@ async function chooseSortMode(label: string) {
|
||||
|
||||
function agentLinkLabels(container: HTMLElement) {
|
||||
return Array.from(container.querySelectorAll('a[href^="/agents/"]'))
|
||||
.filter((anchor) => anchor.getAttribute("href") !== "/agents/all")
|
||||
.map((anchor) => anchor.textContent?.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function seeAllAgentsLink(container: HTMLElement) {
|
||||
return (
|
||||
Array.from(container.querySelectorAll('a[href="/agents/all"]')).find((anchor) =>
|
||||
anchor.textContent?.includes("See all agents"),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
describe("SidebarAgents", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot> | null;
|
||||
@@ -249,7 +258,21 @@ describe("SidebarAgents", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderSidebarAgents() {
|
||||
async function renderSidebarAgents(streamlined = true) {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents streamlined={streamlined} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
async function renderSidebarAgentsWithDefaultProps() {
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
@@ -467,6 +490,103 @@ describe("SidebarAgents", () => {
|
||||
expect(document.body.textContent).not.toContain("Updating...");
|
||||
});
|
||||
|
||||
it("shows only active agents when any agent has a live run", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
{ id: "run-1", agentId: "agent-b", status: "running" },
|
||||
]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
|
||||
const labels = agentLinkLabels(container);
|
||||
expect(labels).toHaveLength(1);
|
||||
expect(labels[0]).toContain("Bravo");
|
||||
// PAP-76: the full-list entry point stays visible even when only active
|
||||
// agents are shown.
|
||||
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("shows up to 5 recently-active agents plus a See all link when none are running", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgents();
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(5);
|
||||
expect(seeAllAgentsLink(container)?.getAttribute("href")).toBe("/agents/all");
|
||||
});
|
||||
|
||||
it("classic mode (flag OFF) shows all agents and no See all link even when one is running", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-a", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-b", name: "Bravo", urlKey: "bravo" }),
|
||||
makeAgent({ id: "agent-c", name: "Charlie", urlKey: "charlie" }),
|
||||
]);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([
|
||||
{ id: "run-1", agentId: "agent-b", status: "running" },
|
||||
]);
|
||||
|
||||
await renderSidebarAgents(false);
|
||||
|
||||
// Show-all: every agent is listed regardless of live-run state. (Bravo's
|
||||
// label includes its live-run badge text, so match by prefix.)
|
||||
const labels = agentLinkLabels(container);
|
||||
expect(labels).toHaveLength(3);
|
||||
expect(labels[0]).toBe("Alpha");
|
||||
expect(labels[1]).toContain("Bravo");
|
||||
expect(labels[2]).toBe("Charlie");
|
||||
// No recent-5 truncation, so no "See all agents" link in classic mode.
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("classic mode (flag OFF) shows more than 5 agents without truncation", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgents(false);
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(7);
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to classic mode when rendered outside the Sidebar flag path", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue(
|
||||
Array.from({ length: 7 }, (_, index) =>
|
||||
makeAgent({
|
||||
id: `agent-${index}`,
|
||||
name: `Agent ${index}`,
|
||||
urlKey: `agent-${index}`,
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
|
||||
|
||||
await renderSidebarAgentsWithDefaultProps();
|
||||
|
||||
expect(agentLinkLabels(container)).toHaveLength(7);
|
||||
expect(seeAllAgentsLink(container)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not offer sidebar resume for budget-paused agents", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({
|
||||
|
||||
@@ -44,6 +44,12 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* When no agent is running, the sidebar falls back to showing at most this many
|
||||
* recently-active agents plus a "See all agents" link (IA Phase 5).
|
||||
*/
|
||||
const RECENT_AGENT_LIMIT = 5;
|
||||
|
||||
const AGENT_SORT_CHOICES: SidebarSectionRadioChoice[] = [
|
||||
{ value: "top", label: "Top" },
|
||||
{ value: "alphabetical", label: "Alphabetical" },
|
||||
@@ -210,7 +216,7 @@ function SidebarAgentItem({
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarAgents() {
|
||||
export function SidebarAgents({ streamlined = false }: { streamlined?: boolean } = {}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const queryClient = useQueryClient();
|
||||
@@ -277,6 +283,25 @@ export function SidebarAgents() {
|
||||
[orderedAgents, sortMode],
|
||||
);
|
||||
|
||||
// IA Phase 5 (streamlined): if any agent has a live run, show only those
|
||||
// active agents. Otherwise fall back to up to RECENT_AGENT_LIMIT agents. Either
|
||||
// way a "See all agents" link is shown so the full list is always reachable.
|
||||
// Classic mode (PAP-89, flag OFF) restores the show-all behavior.
|
||||
const runningAgents = useMemo(
|
||||
() => sortedAgents.filter((agent: Agent) => (liveCountByAgent.get(agent.id) ?? 0) > 0),
|
||||
[sortedAgents, liveCountByAgent],
|
||||
);
|
||||
const hasActiveAgents = runningAgents.length > 0;
|
||||
const displayedAgents = !streamlined
|
||||
? sortedAgents
|
||||
: hasActiveAgents
|
||||
? runningAgents
|
||||
: sortedAgents.slice(0, RECENT_AGENT_LIMIT);
|
||||
// Always expose "See all agents" whenever the displayed list is a subset of all
|
||||
// agents, so users never lose the entry point to the full list. In classic mode
|
||||
// every agent is already shown, so the link is unnecessary.
|
||||
const showSeeAllLink = streamlined && sortedAgents.length > 0;
|
||||
|
||||
const agentMatch = location.pathname.match(/^\/(?:[^/]+\/)?agents\/([^/]+)(?:\/([^/]+))?/);
|
||||
const activeAgentId = agentMatch?.[1] ?? null;
|
||||
const activeTab = agentMatch?.[2] ?? null;
|
||||
@@ -406,7 +431,7 @@ export function SidebarAgents() {
|
||||
onRadioValueChange: persistSortMode,
|
||||
}}
|
||||
>
|
||||
{sortedAgents.map((agent: Agent) => {
|
||||
{displayedAgents.map((agent: Agent) => {
|
||||
const runCount = liveCountByAgent.get(agent.id) ?? 0;
|
||||
return (
|
||||
<SidebarAgentItem
|
||||
@@ -424,6 +449,19 @@ export function SidebarAgents() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showSeeAllLink && (
|
||||
<Link
|
||||
to="/agents/all"
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 pointer-coarse:py-1 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<Users className="shrink-0 h-3.5 w-3.5" />
|
||||
<span>See all agents</span>
|
||||
</Link>
|
||||
)}
|
||||
</SidebarSection>
|
||||
);
|
||||
}
|
||||
@@ -145,6 +145,7 @@ function makeProject(overrides: Partial<Project>): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#ef4444",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { cn, projectRouteRef } from "../lib/utils";
|
||||
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||
import { resourceMembershipState, useResourceMembershipMutation, useResourceMemberships } from "../hooks/useResourceMemberships";
|
||||
import { BudgetSidebarMarker } from "./BudgetSidebarMarker";
|
||||
import { ProjectTile } from "./ProjectTile";
|
||||
import { SidebarSection, type SidebarSectionRadioChoice } from "./SidebarSection";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -139,10 +140,7 @@ function ProjectItem({
|
||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
||||
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
||||
/>
|
||||
<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="xs" />
|
||||
<span className="flex-1 truncate">{project.name}</span>
|
||||
{project.pauseReason === "budget" ? <BudgetSidebarMarker title="Project paused by budget" /> : null}
|
||||
</NavLink>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { cn } from "../lib/utils";
|
||||
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
|
||||
import {
|
||||
statusBadge,
|
||||
statusBadgeDefault,
|
||||
agentStatusColor,
|
||||
agentStatusColorDefault,
|
||||
agentStatusBadge,
|
||||
agentStatusCapsule,
|
||||
agentStatusMotion,
|
||||
} from "../lib/status-colors";
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
@@ -13,3 +21,39 @@ export function StatusBadge({ status }: { status: string }) {
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent status chip — brand `.task-chip` (1px border, light/dark variants).
|
||||
* Distinct from the shared {@link StatusBadge} so the agents section can carry
|
||||
* the brand state colours without affecting run/issue/goal badges. `active`
|
||||
* renders as "idle" (alias for dead code).
|
||||
*/
|
||||
export function AgentStatusBadge({ status }: { status: string }) {
|
||||
const color = agentStatusColor[status] ?? agentStatusColorDefault;
|
||||
const label = status === "active" ? "idle" : status;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium leading-none whitespace-nowrap shrink-0",
|
||||
agentStatusBadge[color]
|
||||
)}
|
||||
>
|
||||
{label.replace(/_/g, " ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent status indicator — brand heartbeat capsule (vertical 8×16, r4). Running
|
||||
* agents pulse, broken (error) agents blink; both honor `prefers-reduced-motion`.
|
||||
*/
|
||||
export function AgentStatusCapsule({ status }: { status: string }) {
|
||||
const color = agentStatusColor[status] ?? agentStatusColorDefault;
|
||||
const motion = agentStatusMotion[status] ?? "";
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("inline-block h-4 w-2 rounded-[4px] shrink-0", agentStatusCapsule[color], motion)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -82,6 +82,7 @@ describe("issueDetailBreadcrumb", () => {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -594,6 +594,7 @@ describe("optimistic issue comments", () => {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: null,
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatProjectBudget } from "./utils";
|
||||
|
||||
describe("formatProjectBudget", () => {
|
||||
it("renders a /mo suffix for monthly budgets", () => {
|
||||
expect(formatProjectBudget({ amountCents: 120_000, windowKind: "calendar_month_utc" })).toBe("$1,200.00/mo");
|
||||
});
|
||||
|
||||
it("renders the bare amount for lifetime budgets", () => {
|
||||
expect(formatProjectBudget({ amountCents: 50_000, windowKind: "lifetime" })).toBe("$500.00");
|
||||
});
|
||||
|
||||
it("formats sub-dollar amounts with cents", () => {
|
||||
expect(formatProjectBudget({ amountCents: 150, windowKind: "lifetime" })).toBe("$1.50");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Atom,
|
||||
Box,
|
||||
Boxes,
|
||||
Brain,
|
||||
Briefcase,
|
||||
Bug,
|
||||
Code,
|
||||
Cog,
|
||||
Compass,
|
||||
Cpu,
|
||||
Crown,
|
||||
Database,
|
||||
FileCode,
|
||||
Flame,
|
||||
Folder,
|
||||
Gem,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Hammer,
|
||||
Heart,
|
||||
Hexagon,
|
||||
Layers,
|
||||
Lightbulb,
|
||||
Lock,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Puzzle,
|
||||
Radar,
|
||||
Rocket,
|
||||
Search,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Star,
|
||||
Target,
|
||||
Telescope,
|
||||
Terminal,
|
||||
Wrench,
|
||||
Zap,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { PROJECT_ICON_NAMES, type ProjectIconName } from "@paperclipai/shared";
|
||||
|
||||
export const PROJECT_ICONS: Record<ProjectIconName, LucideIcon> = {
|
||||
folder: Folder,
|
||||
rocket: Rocket,
|
||||
code: Code,
|
||||
terminal: Terminal,
|
||||
database: Database,
|
||||
globe: Globe,
|
||||
package: Package,
|
||||
boxes: Boxes,
|
||||
box: Box,
|
||||
layers: Layers,
|
||||
briefcase: Briefcase,
|
||||
compass: Compass,
|
||||
target: Target,
|
||||
flame: Flame,
|
||||
zap: Zap,
|
||||
star: Star,
|
||||
bug: Bug,
|
||||
wrench: Wrench,
|
||||
hammer: Hammer,
|
||||
lightbulb: Lightbulb,
|
||||
sparkles: Sparkles,
|
||||
shield: Shield,
|
||||
lock: Lock,
|
||||
search: Search,
|
||||
cog: Cog,
|
||||
brain: Brain,
|
||||
cpu: Cpu,
|
||||
"git-branch": GitBranch,
|
||||
"file-code": FileCode,
|
||||
puzzle: Puzzle,
|
||||
gem: Gem,
|
||||
atom: Atom,
|
||||
heart: Heart,
|
||||
mail: Mail,
|
||||
"message-square": MessageSquare,
|
||||
crown: Crown,
|
||||
radar: Radar,
|
||||
telescope: Telescope,
|
||||
hexagon: Hexagon,
|
||||
};
|
||||
|
||||
const DEFAULT_ICON: ProjectIconName = "folder";
|
||||
|
||||
export function getProjectIcon(iconName: string | null | undefined): LucideIcon {
|
||||
if (iconName && PROJECT_ICON_NAMES.includes(iconName as ProjectIconName)) {
|
||||
return PROJECT_ICONS[iconName as ProjectIconName];
|
||||
}
|
||||
return PROJECT_ICONS[DEFAULT_ICON];
|
||||
}
|
||||
+40
-10
@@ -83,20 +83,50 @@ export const statusBadge: Record<string, string> = {
|
||||
export const statusBadgeDefault = "bg-muted text-muted-foreground";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent status dot — solid background for small indicator dots
|
||||
// Agent status — brand state system (PAP-75)
|
||||
//
|
||||
// The agents section shows four states only. Each maps to a brand colour name;
|
||||
// the indicator is a brand heartbeat capsule (8×16, r4) and the row chip is a
|
||||
// brand `.task-chip` (1px border, light/dark variants). `active` is dead code
|
||||
// that folds into idle; `running` pulses and `error` blinks. `terminated` and
|
||||
// `pending_approval` are excluded from the list but may still render on detail
|
||||
// pages, where they fall through to the gray default.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const agentStatusDot: Record<string, string> = {
|
||||
running: "bg-cyan-400 animate-pulse",
|
||||
active: "bg-green-400",
|
||||
paused: "bg-yellow-400",
|
||||
idle: "bg-yellow-400",
|
||||
pending_approval: "bg-amber-400",
|
||||
error: "bg-red-400",
|
||||
archived: "bg-neutral-400",
|
||||
export type AgentBadgeColor = "gray" | "blue" | "amber" | "red";
|
||||
|
||||
/** Agent status → brand colour name. `active` aliases idle (never assigned). */
|
||||
export const agentStatusColor: Record<string, AgentBadgeColor> = {
|
||||
idle: "gray",
|
||||
active: "gray",
|
||||
running: "blue",
|
||||
paused: "amber",
|
||||
error: "red",
|
||||
};
|
||||
|
||||
export const agentStatusDotDefault = "bg-neutral-400";
|
||||
export const agentStatusColorDefault: AgentBadgeColor = "gray";
|
||||
|
||||
/** Brand `.task-chip` styles (1px border) per colour name — light + dark. */
|
||||
export const agentStatusBadge: Record<AgentBadgeColor, string> = {
|
||||
gray: "bg-[#F5F3F0] text-[#52585D] border-[#A8AEB2] dark:bg-[#6e696024] dark:text-[#9A958A] dark:border-[#9e958a73]",
|
||||
blue: "bg-[#DBEAFE] text-[#1D4ED8] border-[#2563EB] dark:bg-[#2563eb2e] dark:text-[#2563EB] dark:border-[#2563eb73]",
|
||||
amber: "bg-[#FEF3C7] text-[#B45309] border-[#F59E0B] dark:bg-[#f59e0b24] dark:text-[#F59E0B] dark:border-[#f59e0b73]",
|
||||
red: "bg-[#FEE2E2] text-[#991B1B] border-[#DC2626] dark:bg-[#dc26262e] dark:text-[#DC2626] dark:border-[#dc262673]",
|
||||
};
|
||||
|
||||
/** Heartbeat-capsule fill (solid) per colour name. gray darkens in dark mode. */
|
||||
export const agentStatusCapsule: Record<AgentBadgeColor, string> = {
|
||||
gray: "bg-[#A8AEB2] dark:bg-[#6E6960]",
|
||||
blue: "bg-[#2563EB]",
|
||||
amber: "bg-[#F59E0B]",
|
||||
red: "bg-[#DC2626]",
|
||||
};
|
||||
|
||||
/** Per-status capsule motion (running pulses, error blinks). Honors reduced-motion. */
|
||||
export const agentStatusMotion: Record<string, string> = {
|
||||
running: "hb-pulse",
|
||||
error: "hb-blink",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Priority colors
|
||||
|
||||
@@ -29,6 +29,15 @@ export function formatNumber(n: number): string {
|
||||
return n.toLocaleString("en-US");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a project's budget for the projects list view (IA Phase 4 — PAP-60).
|
||||
* Monthly budgets render a `/mo` suffix; lifetime budgets show the bare amount.
|
||||
*/
|
||||
export function formatProjectBudget(budget: { amountCents: number; windowKind: string }): string {
|
||||
const amount = formatCents(budget.amountCents);
|
||||
return budget.windowKind === "calendar_month_utc" ? `${amount}/mo` : amount;
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
|
||||
+24
-201
@@ -20,7 +20,6 @@ import { usePanel } from "../context/PanelContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
@@ -33,14 +32,13 @@ import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { CopyText } from "../components/CopyText";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { MembershipAction } from "../components/MembershipAction";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
|
||||
import { AgentActionButtons } from "../components/AgentActionButtons";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { TrustPresetSection } from "../components/TrustPresetSection";
|
||||
import { FileTree, buildFileTree } from "../components/FileTree";
|
||||
@@ -52,18 +50,11 @@ import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
|
||||
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
|
||||
import { cn } from "../lib/utils";
|
||||
import { describeRunRetryState } from "../lib/runRetryState";
|
||||
import { buildDuplicateAgentPayload, duplicateAgentName, type DuplicateInstructionsBundle } from "../lib/duplicate-agent-payload";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
@@ -71,7 +62,6 @@ import {
|
||||
Loader2,
|
||||
Slash,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Plus,
|
||||
Key,
|
||||
Eye,
|
||||
@@ -91,8 +81,6 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
|
||||
import {
|
||||
isUuidLike,
|
||||
type Agent,
|
||||
type AgentInstructionsBundle,
|
||||
type AgentInstructionsFileSummary,
|
||||
type AgentSkillEntry,
|
||||
type AgentSkillSnapshot,
|
||||
type AgentDetail as AgentDetailRecord,
|
||||
@@ -117,34 +105,6 @@ import {
|
||||
isReadOnlyUnmanagedSkillEntry,
|
||||
} from "../lib/agent-skills-state";
|
||||
|
||||
async function loadDuplicateInstructionsBundle(
|
||||
agentId: string,
|
||||
companyId?: string,
|
||||
): Promise<DuplicateInstructionsBundle | null> {
|
||||
const bundle = await agentsApi.instructionsBundle(agentId, companyId);
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
for (const summary of bundle.files) {
|
||||
const path = duplicateInstructionFilePath(bundle, summary);
|
||||
if (!path) continue;
|
||||
const file = await agentsApi.instructionsFile(agentId, summary.path, companyId);
|
||||
files[path] = file.content;
|
||||
}
|
||||
|
||||
const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile)
|
||||
? bundle.entryFile
|
||||
: Object.keys(files)[0] ?? "AGENTS.md";
|
||||
return Object.keys(files).length > 0 ? { entryFile, files } : null;
|
||||
}
|
||||
|
||||
function duplicateInstructionFilePath(
|
||||
_bundle: AgentInstructionsBundle,
|
||||
summary: AgentInstructionsFileSummary,
|
||||
): string | null {
|
||||
if (summary.deprecated || summary.virtual) return null;
|
||||
return summary.path;
|
||||
}
|
||||
|
||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
|
||||
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
|
||||
@@ -678,14 +638,11 @@ export function AgentDetail() {
|
||||
}>();
|
||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||
const { closePanel } = usePanel();
|
||||
const { openNewIssue } = useDialogActions();
|
||||
const { pushToast } = useToastActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null);
|
||||
const needsDashboardData = activeView === "dashboard";
|
||||
const needsRunData = activeView === "runs" || Boolean(urlRunId);
|
||||
@@ -824,18 +781,17 @@ export function AgentDetail() {
|
||||
setSelectedCompanyId(agent.companyId, { source: "route_sync" });
|
||||
}, [agent?.companyId, selectedCompanyId, setSelectedCompanyId]);
|
||||
|
||||
// Invoke / pause / resume / terminate / duplicate / reset live in the shared
|
||||
// AgentActionButtons component. The detail header keeps only "approve" here,
|
||||
// which is surfaced via the pending-approval banner below.
|
||||
const agentAction = useMutation({
|
||||
mutationFn: async (action: "invoke" | "pause" | "resume" | "approve" | "terminate") => {
|
||||
mutationFn: async (action: "approve") => {
|
||||
if (!agentLookupRef) return Promise.reject(new Error("No agent reference"));
|
||||
switch (action) {
|
||||
case "invoke": return agentsApi.invoke(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "pause": return agentsApi.pause(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "resume": return agentsApi.resume(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "approve": return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
case "terminate": return agentsApi.terminate(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
if (action === "approve") {
|
||||
return agentsApi.approve(agentLookupRef, resolvedCompanyId ?? undefined);
|
||||
}
|
||||
},
|
||||
onSuccess: (data, action) => {
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(routeAgentRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentLookupRef) });
|
||||
@@ -847,66 +803,12 @@ export function AgentDetail() {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.heartbeats(resolvedCompanyId, agent.id) });
|
||||
}
|
||||
}
|
||||
if (action === "invoke" && data && typeof data === "object" && "id" in data) {
|
||||
navigate(`/agents/${canonicalAgentRef}/runs/${(data as HeartbeatRun).id}`);
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Action failed");
|
||||
},
|
||||
});
|
||||
|
||||
const duplicateAgent = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!agent?.id || !resolvedCompanyId) {
|
||||
throw new Error("Agent is not ready to duplicate");
|
||||
}
|
||||
|
||||
const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId);
|
||||
const payload = buildDuplicateAgentPayload(agent, instructionsBundle);
|
||||
|
||||
try {
|
||||
return await agentsApi.create(resolvedCompanyId, payload);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) {
|
||||
const hire = await agentsApi.hire(resolvedCompanyId, payload);
|
||||
return hire.agent;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: async (createdAgent) => {
|
||||
setActionError(null);
|
||||
if (resolvedCompanyId) {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
|
||||
}
|
||||
pushToast({
|
||||
title: "Agent duplicated",
|
||||
body: createdAgent.name,
|
||||
tone: "success",
|
||||
});
|
||||
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
|
||||
},
|
||||
onError: (err) => {
|
||||
const message = err instanceof Error ? err.message : "Failed to duplicate agent";
|
||||
setActionError(message);
|
||||
pushToast({
|
||||
title: "Could not duplicate agent",
|
||||
body: message,
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleDuplicateAgent = useCallback(() => {
|
||||
if (!agent || duplicateAgent.isPending) return;
|
||||
const nextName = duplicateAgentName(agent.name);
|
||||
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
|
||||
setMoreOpen(false);
|
||||
if (!confirmed) return;
|
||||
duplicateAgent.mutate();
|
||||
}, [agent, duplicateAgent]);
|
||||
|
||||
const budgetMutation = useMutation({
|
||||
mutationFn: (amount: number) =>
|
||||
budgetsApi.upsertPolicy(resolvedCompanyId!, {
|
||||
@@ -936,19 +838,6 @@ export function AgentDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const resetTaskSession = useMutation({
|
||||
mutationFn: (taskKey: string | null) =>
|
||||
agentsApi.resetSession(agentLookupRef, taskKey, resolvedCompanyId ?? undefined),
|
||||
onSuccess: () => {
|
||||
setActionError(null);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.runtimeState(agentLookupRef) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.taskSessions(agentLookupRef) });
|
||||
},
|
||||
onError: (err) => {
|
||||
setActionError(err instanceof Error ? err.message : "Failed to reset session");
|
||||
},
|
||||
});
|
||||
|
||||
const updatePermissions = useMutation({
|
||||
mutationFn: (permissions: AgentPermissionUpdate) =>
|
||||
agentsApi.updatePermissions(agentLookupRef, permissions, resolvedCompanyId ?? undefined),
|
||||
@@ -1086,27 +975,14 @@ export function AgentDetail() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openNewIssue({ assigneeAgentId: agent.id })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:mr-1" />
|
||||
<span className="hidden sm:inline">Assign Task</span>
|
||||
</Button>
|
||||
<RunButton
|
||||
onClick={() => agentAction.mutate("invoke")}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
label="Run Heartbeat"
|
||||
/>
|
||||
<PauseResumeButton
|
||||
isPaused={agent.status === "paused"}
|
||||
onPause={() => agentAction.mutate("pause")}
|
||||
onResume={() => agentAction.mutate("resume")}
|
||||
disabled={agentAction.isPending || isPendingApproval}
|
||||
/>
|
||||
<span className="hidden sm:inline"><StatusBadge status={agent.status} /></span>
|
||||
<AgentActionButtons
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId}
|
||||
assignLabel="Assign Task"
|
||||
runLabel="Run Heartbeat"
|
||||
actionsDisabled={agentAction.isPending}
|
||||
onActionError={setActionError}
|
||||
>
|
||||
{mobileLiveRun && (
|
||||
<Link
|
||||
to={`/agents/${canonicalAgentRef}/runs/${mobileLiveRun.id}`}
|
||||
@@ -1119,60 +995,7 @@ export function AgentDetail() {
|
||||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">Live</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Overflow menu */}
|
||||
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
disabled={duplicateAgent.isPending}
|
||||
onClick={handleDuplicateAgent}
|
||||
>
|
||||
{duplicateAgent.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
Duplicate Agent
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(agent.id);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
Copy Agent ID
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
resetTaskSession.mutate(null);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset Sessions
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-destructive"
|
||||
onClick={() => {
|
||||
agentAction.mutate("terminate");
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Terminate
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</AgentActionButtons>
|
||||
</div>
|
||||
|
||||
{!urlRunId && (
|
||||
@@ -1454,10 +1277,10 @@ function AgentOverview({
|
||||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||
<RunActivityChart runs={runs} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
|
||||
<PriorityChart issues={assignedIssues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Status" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
|
||||
<IssueStatusChart issues={assignedIssues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
||||
@@ -1468,7 +1291,7 @@ function AgentOverview({
|
||||
{/* Recent Issues */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Recent Issues</h3>
|
||||
<h3 className="text-sm font-medium">Recent Tasks</h3>
|
||||
<Link
|
||||
to={`/issues?participantAgentId=${agentId}`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -1477,7 +1300,7 @@ function AgentOverview({
|
||||
</Link>
|
||||
</div>
|
||||
{assignedIssues.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent issues.</p>
|
||||
<p className="text-sm text-muted-foreground">No recent tasks.</p>
|
||||
) : (
|
||||
<div className="border border-border rounded-lg">
|
||||
{assignedIssues.slice(0, 10).map((issue) => (
|
||||
@@ -1491,7 +1314,7 @@ function AgentOverview({
|
||||
))}
|
||||
{assignedIssues.length > 10 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground text-center border-t border-border">
|
||||
+{assignedIssues.length - 10} more issues
|
||||
+{assignedIssues.length - 10} more tasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3651,7 +3474,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
>
|
||||
{clearSessionsForTouchedIssues.isPending
|
||||
? "clearing session..."
|
||||
: "clear session for these issues"}
|
||||
: "clear session for these tasks"}
|
||||
</button>
|
||||
{clearSessionsForTouchedIssues.isError && (
|
||||
<p className="text-[11px] text-destructive mt-1">
|
||||
@@ -3671,7 +3494,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
|
||||
{/* Issues touched by this run */}
|
||||
{touchedIssues && touchedIssues.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">Issues Touched ({touchedIssues.length})</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">Tasks Touched ({touchedIssues.length})</span>
|
||||
<div className="border border-border rounded-lg divide-y divide-border">
|
||||
{touchedIssues.map((issue) => (
|
||||
<Link
|
||||
|
||||
@@ -126,7 +126,11 @@ describe("Agents", () => {
|
||||
});
|
||||
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ adapterConfig: { model: "gpt-5.4" } }),
|
||||
makeAgent({
|
||||
adapterConfig: { model: "gpt-5.4" },
|
||||
// Old enough that relativeTime() falls back to an absolute date string.
|
||||
lastHeartbeatAt: new Date("2026-01-15T00:00:00Z"),
|
||||
}),
|
||||
]);
|
||||
mockAgentsApi.org.mockResolvedValue([
|
||||
{
|
||||
@@ -180,5 +184,44 @@ describe("Agents", () => {
|
||||
|
||||
expect(container.textContent).toContain("codex_local");
|
||||
expect(container.textContent).toContain("gpt-5.4");
|
||||
|
||||
// The heartbeat cell must render on a single line so full dates like
|
||||
// "Apr 30, 2026" never wrap (PAP-85 defect #2).
|
||||
const heartbeatCell = container.querySelector(".whitespace-nowrap.w-24");
|
||||
expect(heartbeatCell).not.toBeNull();
|
||||
expect(heartbeatCell?.textContent).not.toContain("\n");
|
||||
});
|
||||
|
||||
it("gives list-view rows a fixed-width title so meta columns align (PAP-86)", async () => {
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<Agents />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
// Switch from the default org view to the list view.
|
||||
const listToggle = Array.from(container.querySelectorAll("button")).find(
|
||||
(btn) => btn.querySelector("svg.lucide-list"),
|
||||
);
|
||||
expect(listToggle).toBeDefined();
|
||||
await act(async () => {
|
||||
listToggle!.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
// The title cell carries a constant width (`w-56`), not a content-sized
|
||||
// `min-w-[7rem]`, so the `meta` group starts at the same x on every row and
|
||||
// the model + timestamp columns line up vertically.
|
||||
const titleCell = container.querySelector(".w-56");
|
||||
expect(titleCell).not.toBeNull();
|
||||
expect(titleCell?.textContent).toContain("Alpha");
|
||||
expect(container.querySelector(".min-w-\\[7rem\\]")).toBeNull();
|
||||
});
|
||||
});
|
||||
+86
-86
@@ -8,9 +8,9 @@ import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { AgentStatusBadge, AgentStatusCapsule } from "../components/StatusBadge";
|
||||
import { AgentActionButtons } from "../components/AgentActionButtons";
|
||||
import { MembershipAction } from "../components/MembershipAction";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
@@ -18,7 +18,7 @@ import { relativeTime, cn, agentRouteRef, agentUrl } from "../lib/utils";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
|
||||
import { Bot, Plus, List, GitBranch } from "lucide-react";
|
||||
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
|
||||
import {
|
||||
resourceMembershipState,
|
||||
@@ -32,8 +32,12 @@ const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
|
||||
|
||||
type FilterTab = "all" | "active" | "paused" | "error";
|
||||
|
||||
function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean): boolean {
|
||||
if (status === "terminated") return showTerminated;
|
||||
// Agents in these states never appear in the agents list — `terminated` is
|
||||
// hidden like an archived company, and `pending_approval` is a hiring gate that
|
||||
// lives in the task thread, not an agent run state (PAP-75).
|
||||
const HIDDEN_AGENT_STATUSES = new Set(["terminated", "pending_approval"]);
|
||||
|
||||
function matchesFilter(status: string, tab: FilterTab): boolean {
|
||||
if (tab === "all") return true;
|
||||
if (tab === "active") return status === "active" || status === "running" || status === "idle";
|
||||
if (tab === "paused") return status === "paused";
|
||||
@@ -41,9 +45,9 @@ function matchesFilter(status: string, tab: FilterTab, showTerminated: boolean):
|
||||
return true;
|
||||
}
|
||||
|
||||
function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean): Agent[] {
|
||||
function filterAgents(agents: Agent[], tab: FilterTab): Agent[] {
|
||||
return agents
|
||||
.filter((a) => matchesFilter(a.status, tab, showTerminated))
|
||||
.filter((a) => !HIDDEN_AGENT_STATUSES.has(a.status) && matchesFilter(a.status, tab))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
@@ -54,11 +58,17 @@ function getConfiguredModel(agent: Agent): string | null {
|
||||
return model.length > 0 ? model : null;
|
||||
}
|
||||
|
||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
|
||||
function filterOrgTree(nodes: OrgNode[], tab: FilterTab): OrgNode[] {
|
||||
return nodes
|
||||
.reduce<OrgNode[]>((acc, node) => {
|
||||
const filteredReports = filterOrgTree(node.reports, tab, showTerminated);
|
||||
if (matchesFilter(node.status, tab, showTerminated) || filteredReports.length > 0) {
|
||||
const filteredReports = filterOrgTree(node.reports, tab);
|
||||
// Hidden agents (terminated / pending_approval) never render as a row, but
|
||||
// any visible reports are promoted so the tree doesn't lose live agents.
|
||||
if (HIDDEN_AGENT_STATUSES.has(node.status)) {
|
||||
acc.push(...filteredReports);
|
||||
return acc;
|
||||
}
|
||||
if (matchesFilter(node.status, tab) || filteredReports.length > 0) {
|
||||
acc.push({ ...node, reports: filteredReports });
|
||||
}
|
||||
return acc;
|
||||
@@ -78,8 +88,6 @@ export function Agents() {
|
||||
const [view, setView] = useState<"list" | "org">("org");
|
||||
const forceListView = isMobile;
|
||||
const effectiveView: "list" | "org" = forceListView ? "list" : view;
|
||||
const [showTerminated, setShowTerminated] = useState(false);
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
const { data: agents, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
@@ -135,8 +143,8 @@ export function Agents() {
|
||||
return <PageSkeleton variant="list" />;
|
||||
}
|
||||
|
||||
const filtered = filterAgents(agents ?? [], tab, showTerminated);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab, showTerminated);
|
||||
const filtered = filterAgents(agents ?? [], tab);
|
||||
const filteredOrg = filterOrgTree(orgTree ?? [], tab);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -154,36 +162,6 @@ export function Agents() {
|
||||
/>
|
||||
</Tabs>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filters */}
|
||||
<div className="relative">
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-1.5 text-xs transition-colors border border-border",
|
||||
filtersOpen || showTerminated ? "text-foreground bg-accent" : "text-muted-foreground hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
Filters
|
||||
{showTerminated && <span className="ml-0.5 px-1 bg-foreground/10 rounded text-[10px]">1</span>}
|
||||
</button>
|
||||
{filtersOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-48 border border-border bg-popover shadow-md p-1">
|
||||
<button
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent/50 transition-colors"
|
||||
onClick={() => setShowTerminated(!showTerminated)}
|
||||
>
|
||||
<span className={cn(
|
||||
"flex items-center justify-center h-3.5 w-3.5 border border-border rounded-sm",
|
||||
showTerminated && "bg-foreground"
|
||||
)}>
|
||||
{showTerminated && <span className="text-background text-[10px] leading-none">✓</span>}
|
||||
</span>
|
||||
Show terminated
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* View toggle */}
|
||||
{!forceListView && (
|
||||
<div className="flex items-center border border-border">
|
||||
@@ -237,6 +215,11 @@ export function Agents() {
|
||||
<EntityRow
|
||||
key={agent.id}
|
||||
title={agent.name}
|
||||
// Fixed (truncating) title width so the `meta` group starts at a
|
||||
// constant x on every row — that's what makes the model + timestamp
|
||||
// columns line up vertically (PAP-86). Agent names vary in width, so
|
||||
// a content-sized title (`min-w-[7rem]`) shifted meta's start per row.
|
||||
titleClassName="w-56"
|
||||
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
|
||||
to={agentUrl(agent)}
|
||||
className={cn(
|
||||
@@ -244,12 +227,11 @@ export function Agents() {
|
||||
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
|
||||
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
|
||||
)}
|
||||
leading={
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span
|
||||
className={`absolute inline-flex h-full w-full rounded-full ${agentStatusDot[agent.status] ?? agentStatusDotDefault}`}
|
||||
/>
|
||||
</span>
|
||||
leading={<AgentStatusCapsule status={agent.status} />}
|
||||
meta={
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<AgentMetaColumns agent={agent} />
|
||||
</div>
|
||||
}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -261,7 +243,7 @@ export function Agents() {
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={agent.status} />
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
)}
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
@@ -272,22 +254,25 @@ export function Agents() {
|
||||
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
|
||||
/>
|
||||
)}
|
||||
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
<span
|
||||
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
|
||||
title={getConfiguredModel(agent) ?? undefined}
|
||||
>
|
||||
{getConfiguredModel(agent) ?? "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={agent.status} />
|
||||
<AgentStatusBadge status={agent.status} />
|
||||
</span>
|
||||
</div>
|
||||
{/* Row actions mirror the agent detail page; stop the click
|
||||
from bubbling to the row link so buttons don't navigate. */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<AgentActionButtons
|
||||
agent={agent}
|
||||
companyId={selectedCompanyId}
|
||||
runLabel="Run Heartbeat"
|
||||
showStatus={false}
|
||||
/>
|
||||
</div>
|
||||
<MembershipAction
|
||||
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
|
||||
pending={
|
||||
@@ -386,8 +371,6 @@ function OrgTreeNode({
|
||||
membershipMutation.variables?.resourceType === "agent" &&
|
||||
membershipMutation.variables.resourceId === node.id;
|
||||
|
||||
const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault;
|
||||
|
||||
return (
|
||||
<div style={{ paddingLeft: depth * 24 }}>
|
||||
<Link
|
||||
@@ -398,10 +381,8 @@ function OrgTreeNode({
|
||||
membershipState === "left" && "text-foreground/55",
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<AgentStatusCapsule status={node.status} />
|
||||
<div className="flex-1 min-w-[7rem]">
|
||||
<span className="text-sm font-medium">{node.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{roleLabels[node.role] ?? node.role}
|
||||
@@ -417,7 +398,7 @@ function OrgTreeNode({
|
||||
liveCount={liveRunByAgent.get(node.id)!.liveCount}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge status={node.status} />
|
||||
<AgentStatusBadge status={node.status} />
|
||||
)}
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
@@ -429,23 +410,12 @@ function OrgTreeNode({
|
||||
/>
|
||||
)}
|
||||
{agent && (
|
||||
<>
|
||||
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
|
||||
{getAdapterLabel(agent.adapterType)}
|
||||
</span>
|
||||
<span
|
||||
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
|
||||
title={getConfiguredModel(agent) ?? undefined}
|
||||
>
|
||||
{getConfiguredModel(agent) ?? "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground w-16 text-right">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
</>
|
||||
<div className="hidden xl:flex items-center gap-3">
|
||||
<AgentMetaColumns agent={agent} />
|
||||
</div>
|
||||
)}
|
||||
<span className="w-20 flex justify-end">
|
||||
<StatusBadge status={node.status} />
|
||||
<AgentStatusBadge status={node.status} />
|
||||
</span>
|
||||
</div>
|
||||
<MembershipAction
|
||||
@@ -488,6 +458,36 @@ function OrgTreeNode({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider/model + heartbeat columns shared by the list and org views. The
|
||||
* model and adapter label share one fixed-width cell, each line truncating with
|
||||
* an ellipsis so a long model id can never overlap the heartbeat column. The
|
||||
* heartbeat is single-line (`whitespace-nowrap`) and wide enough for a full
|
||||
* date like "Apr 30, 2026".
|
||||
*/
|
||||
function AgentMetaColumns({ agent }: { agent: Agent }) {
|
||||
const model = getConfiguredModel(agent);
|
||||
const adapterLabel = getAdapterLabel(agent.adapterType);
|
||||
return (
|
||||
<>
|
||||
<div className="w-44 min-w-0 leading-tight">
|
||||
<div
|
||||
className="truncate font-mono text-xs text-muted-foreground"
|
||||
title={model ?? undefined}
|
||||
>
|
||||
{model ?? "—"}
|
||||
</div>
|
||||
<div className="truncate font-mono text-[11px] text-muted-foreground/70" title={adapterLabel}>
|
||||
{adapterLabel}
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-24 whitespace-nowrap text-right text-xs text-muted-foreground">
|
||||
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveRunIndicator({
|
||||
agentRef,
|
||||
runId,
|
||||
|
||||
@@ -112,10 +112,9 @@ import {
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
} from "@/components/ui/avatar";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusBadge, AgentStatusBadge, AgentStatusCapsule } from "@/components/StatusBadge";
|
||||
import { StatusIcon } from "@/components/StatusIcon";
|
||||
import { PriorityIcon } from "@/components/PriorityIcon";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "@/lib/status-colors";
|
||||
import { EntityRow } from "@/components/EntityRow";
|
||||
import { EmptyState } from "@/components/EmptyState";
|
||||
import { MetricCard } from "@/components/MetricCard";
|
||||
@@ -551,14 +550,18 @@ export function DesignGuide() {
|
||||
</div>
|
||||
</SubSection>
|
||||
|
||||
<SubSection title="Agent status dots">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
{(["running", "active", "paused", "error", "archived"] as const).map((label) => (
|
||||
<SubSection title="Agent status (capsule + chip)">
|
||||
<p className="text-xs text-muted-foreground mb-3 max-w-prose">
|
||||
The agents section uses a brand heartbeat capsule (8×16) plus a brand
|
||||
<code className="mx-1">.task-chip</code>. Four states only: idle (gray),
|
||||
running (blue, pulses), paused (amber), error (red, blinks). Motion
|
||||
honors <code>prefers-reduced-motion</code>.
|
||||
</p>
|
||||
<div className="flex items-center gap-6 flex-wrap">
|
||||
{(["idle", "running", "paused", "error"] as const).map((label) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<span className="relative flex h-2.5 w-2.5">
|
||||
<span className={`inline-flex h-full w-full rounded-full ${agentStatusDot[label] ?? agentStatusDotDefault}`} />
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<AgentStatusCapsule status={label} />
|
||||
<AgentStatusBadge status={label} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -155,6 +155,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -205,6 +205,8 @@ export function InstanceExperimentalSettings() {
|
||||
|
||||
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
|
||||
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
|
||||
const enableStreamlinedLeftNavigation =
|
||||
experimentalQuery.data?.enableStreamlinedLeftNavigation === true;
|
||||
const enableIssuePlanDecompositions =
|
||||
experimentalQuery.data?.enableIssuePlanDecompositions === true;
|
||||
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
|
||||
@@ -301,6 +303,28 @@ export function InstanceExperimentalSettings() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-sm font-semibold">Streamlined Left Navigation Bar</h2>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
Reduces the maximum number of items in the left navigation bar — nests Projects under Work with a
|
||||
dedicated Projects page, and shows only active agents (max 5 recently-active) in the sidebar.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={enableStreamlinedLeftNavigation}
|
||||
onCheckedChange={() =>
|
||||
toggleMutation.mutate({
|
||||
enableStreamlinedLeftNavigation: !enableStreamlinedLeftNavigation,
|
||||
})
|
||||
}
|
||||
disabled={toggleMutation.isPending}
|
||||
aria-label="Toggle streamlined left navigation experimental setting"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -116,6 +116,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
+104
-41
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
import { Link, useParams, useNavigate, useLocation, Navigate } from "@/lib/router";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { PROJECT_COLORS, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { PROJECT_COLORS, PROJECT_ICON_NAMES, isUuidLike, type BudgetPolicySummary } from "@paperclipai/shared";
|
||||
import { budgetsApi } from "../api/budgets";
|
||||
import { executionWorkspacesApi } from "../api/execution-workspaces";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
@@ -18,6 +18,7 @@ import { queryKeys } from "../lib/queryKeys";
|
||||
import { ProjectProperties, type ProjectConfigFieldKey, type ProjectFieldSaveState } from "../components/ProjectProperties";
|
||||
import { InlineEditor } from "../components/InlineEditor";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { ProjectTile } from "../components/ProjectTile";
|
||||
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
@@ -27,7 +28,11 @@ import { MembershipAction } from "../components/MembershipAction";
|
||||
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
|
||||
import { collectLiveIssueIds } from "../lib/liveIssueIds";
|
||||
import { projectRouteRef } from "../lib/utils";
|
||||
import { PROJECT_ICONS } from "../lib/project-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import { PluginLauncherOutlet } from "@/plugins/launchers";
|
||||
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
@@ -103,60 +108,116 @@ function OverviewContent({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Color picker popover ── */
|
||||
/* ── Combined icon + color picker popover (PAP-72 / PAP-68 part 4) ── */
|
||||
|
||||
function ColorPicker({
|
||||
currentColor,
|
||||
onSelect,
|
||||
const DEFAULT_PROJECT_ICON = "folder";
|
||||
|
||||
function ProjectTilePicker({
|
||||
color,
|
||||
icon,
|
||||
onSelectIcon,
|
||||
onSelectColor,
|
||||
}: {
|
||||
currentColor: string;
|
||||
onSelect: (color: string) => void;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
onSelectIcon: (icon: string) => void;
|
||||
onSelectColor: (color: string | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
const filteredIcons = useMemo(() => {
|
||||
const entries = PROJECT_ICON_NAMES.map((name) => [name, PROJECT_ICONS[name]] as const);
|
||||
if (!search) return entries;
|
||||
const q = search.toLowerCase();
|
||||
return entries.filter(([name]) => name.includes(q));
|
||||
}, [search]);
|
||||
|
||||
// Keep the popover open across selections so the user can pick both an icon
|
||||
// and a color in one pass; reset the search when it closes.
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="shrink-0 h-5 w-5 rounded-md cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
|
||||
style={{ backgroundColor: currentColor }}
|
||||
aria-label="Change project color"
|
||||
/>
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 mt-2 p-2 bg-popover border border-border rounded-lg shadow-lg z-50 w-max">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearch("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-lg cursor-pointer hover:ring-2 hover:ring-foreground/20 transition-[box-shadow]"
|
||||
aria-label="Change project icon and color"
|
||||
>
|
||||
<ProjectTile color={color} icon={icon} size="md" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-3" align="start">
|
||||
{/* Icon search + grid */}
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Icon</p>
|
||||
<Input
|
||||
placeholder="Search icons..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="mb-2 h-8 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="grid grid-cols-7 gap-1 max-h-40 overflow-y-auto">
|
||||
{filteredIcons.map(([name, Icon]) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => onSelectIcon(name)}
|
||||
className={cn(
|
||||
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
|
||||
(icon ?? DEFAULT_PROJECT_ICON) === name && "bg-accent ring-1 ring-primary",
|
||||
)}
|
||||
title={name}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
{filteredIcons.length === 0 && (
|
||||
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Color swatches */}
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-2">Color</p>
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{PROJECT_COLORS.map((color) => (
|
||||
{/* Neutral / reset-to-gray option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectColor(null)}
|
||||
className={`h-6 w-6 cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
|
||||
color === null
|
||||
? "ring-2 ring-foreground ring-offset-1 ring-offset-background rounded-md"
|
||||
: ""
|
||||
}`}
|
||||
aria-label="Reset to neutral gray"
|
||||
title="Neutral (default)"
|
||||
>
|
||||
<ProjectTile color={null} size="sm" />
|
||||
</button>
|
||||
{PROJECT_COLORS.map((swatch) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => {
|
||||
onSelect(color);
|
||||
setOpen(false);
|
||||
}}
|
||||
key={swatch}
|
||||
type="button"
|
||||
onClick={() => onSelectColor(swatch)}
|
||||
className={`h-6 w-6 rounded-md cursor-pointer transition-[transform,box-shadow] duration-150 hover:scale-110 ${
|
||||
color === currentColor
|
||||
swatch === color
|
||||
? "ring-2 ring-foreground ring-offset-1 ring-offset-background"
|
||||
: "hover:ring-2 hover:ring-foreground/30"
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-label={`Select color ${color}`}
|
||||
style={{ backgroundColor: swatch }}
|
||||
aria-label={`Select color ${swatch}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -698,9 +759,11 @@ export function ProjectDetail() {
|
||||
) : null}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="h-7 flex items-center">
|
||||
<ColorPicker
|
||||
currentColor={project.color ?? "#6366f1"}
|
||||
onSelect={(color) => updateProject.mutate({ color })}
|
||||
<ProjectTilePicker
|
||||
color={project.color ?? null}
|
||||
icon={project.icon ?? null}
|
||||
onSelectIcon={(icon) => updateProject.mutate({ icon })}
|
||||
onSelectColor={(color) => updateProject.mutate({ color })}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-2">
|
||||
|
||||
@@ -137,6 +137,7 @@ function project(overrides: Partial<Project> = {}): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -76,6 +76,7 @@ function makeProject(overrides: Partial<Project>): Project {
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#ef4444",
|
||||
icon: null,
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
|
||||
@@ -7,11 +7,12 @@ import { useDialogActions } from "../context/DialogContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { EntityRow } from "../components/EntityRow";
|
||||
import { ProjectTile } from "../components/ProjectTile";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { MembershipAction } from "../components/MembershipAction";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { formatDate, projectUrl } from "../lib/utils";
|
||||
import { formatDate, formatNumber, formatProjectBudget, projectUrl } from "../lib/utils";
|
||||
import {
|
||||
resourceMembershipState,
|
||||
useResourceMembershipMutation,
|
||||
@@ -206,6 +207,7 @@ export function Projects() {
|
||||
return (
|
||||
<EntityRow
|
||||
key={project.id}
|
||||
leading={<ProjectTile color={project.color ?? null} icon={project.icon ?? null} size="sm" />}
|
||||
title={project.name}
|
||||
subtitle={project.description ?? undefined}
|
||||
reserveSubtitleSpace
|
||||
@@ -213,8 +215,19 @@ export function Projects() {
|
||||
className={state === "left" ? "group text-foreground/55" : "group"}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className="hidden text-xs text-muted-foreground tabular-nums sm:inline"
|
||||
title={`${formatNumber(project.taskCount ?? 0)} task${(project.taskCount ?? 0) === 1 ? "" : "s"}`}
|
||||
>
|
||||
{formatNumber(project.taskCount ?? 0)} task{(project.taskCount ?? 0) === 1 ? "" : "s"}
|
||||
</span>
|
||||
{project.budget && (
|
||||
<span className="hidden text-xs text-muted-foreground tabular-nums sm:inline">
|
||||
{formatProjectBudget(project.budget)}
|
||||
</span>
|
||||
)}
|
||||
{project.targetDate && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="hidden text-xs text-muted-foreground md:inline">
|
||||
{formatDate(project.targetDate)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user