feat(onboarding): first-task onboarding experience

Land onboarding on a real first task and make the opening exchange a
conversation the user drives, rather than dropping them on the dashboard.

- Land the wizard on a seeded first task instead of the dashboard; stop
  seeding default helper agents
- Reframe the first-task prompt as welcome -> ask -> propose -> confirm ->
  act, and drop the boardroom/CEO framing
- Seed a first-task greeting that addresses the user by the agent's chosen
  name and suppress the raw task-description bubble
- Verify the first-task marker server-side: honor it only for a human (board)
  session creating a company's very first issue, so a client flag alone cannot
  make an ordinary issue author a comment as another agent or hide its
  description
- Stop page-memory from bouncing the launch back to the dashboard
- Task chat: bring back copy / thumbs-up / thumbs-down on the agent bubble
  footer, and anchor those actions to the summary line when a turn expands
- Settle resolved interaction cards to their resolution time, hide withdrawn
  confirmations, and stabilize thread-card ordering
- Collapse plan approval to a single checkbox card, surface the plan in the
  right sidebar, and auto-switch to the Plan tab
- Unify plan-approval CTAs to Approve / Revise / Reject on one surface and
  remove the duplicate sticky action bar
- Add a first-class free-text option to structured ask-user questions
- Standardize the bare reject label to "Reject"
- Hide the Properties sidebar on the first task until a plan exists
- Skip drawing only unanswerable ask-user-question cards (no options and no
  free-text); answerable ones, including single-option, always render
- Refine onboarding wizard copy and spacing; rename the org dropdown action
  to "Create new organization"
This commit is contained in:
scotttong committed 2026-08-07 10:28:45 -07:00
1 parent f5e9ca3e89
commit 4ad5520092
42 files changed
+2108 -776

No files matched your search

+5
View File
@@ -293,6 +293,10 @@ export type IssueThreadInteractionContinuationPolicy =
export const TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND = "task_watchdog_product_bug";
// Marks the single onboarding "first task" so surfaces can special-case it
// (e.g. suppress the seeded-description bubble and rely on a seeded greeting).
export const ONBOARDING_FIRST_TASK_ORIGIN_KIND = "onboarding_first_task";
export const ISSUE_ORIGIN_KINDS = [
"manual",
"routine_execution",
@@ -302,6 +306,7 @@ export const ISSUE_ORIGIN_KINDS = [
"stranded_issue_recovery",
"task_watchdog",
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
] as const;
export type BuiltInIssueOriginKind = (typeof ISSUE_ORIGIN_KINDS)[number];
export type PluginIssueOriginKind = `plugin:${string}`;
+1
View File
@@ -288,6 +288,7 @@ export {
ISSUE_THREAD_INTERACTION_CONTINUATION_POLICIES,
ISSUE_ORIGIN_KINDS,
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
ISSUE_WATCHDOG_DISCOVERY_KINDS,
ISSUE_SURFACE_VISIBILITIES,
ISSUE_RECOVERY_ACTION_KINDS,
+8
View File
@@ -1072,6 +1072,14 @@ export interface AskUserQuestionsQuestionOption {
id: string;
label: string;
description?: string | null;
/**
* When true, selecting this option reveals an inline free-text field instead
* of acting as an inert choice. The typed answer is submitted as the
* question's `otherText`. Author at most one free-text option per question and
* do not add dead "I'll describe it" options that only duplicate the built-in
* free-text affordance.
*/
freeText?: boolean;
}
export interface AskUserQuestionsQuestion {
+29 -1
View File
@@ -489,13 +489,24 @@ const createIssueDuplicateGuardSchema = {
.default(false),
};
// Narrow intent flag set by the onboarding wizard on the single first task. The
// server owns the resulting origin kind (clients cannot set arbitrary origin
// kinds) and uses it to seed the agent greeting instead of an LLM welcome step.
const onboardingFirstTaskMarkerSchema = {
onboardingFirstTask: z.boolean().optional(),
};
export const createIssueInputSchema = createIssueBaseSchema.extend({
status: createIssueBaseSchema.shape.status.optional(),
...createIssueDuplicateGuardSchema,
...onboardingFirstTaskMarkerSchema,
});
export const createIssueSchema = withCreateIssueStatusDefault(
createIssueBaseSchema.extend(createIssueDuplicateGuardSchema),
createIssueBaseSchema.extend({
...createIssueDuplicateGuardSchema,
...onboardingFirstTaskMarkerSchema,
}),
).superRefine(requireBlockedStatusForUnblockDescriptor);
export type CreateIssue = z.infer<typeof createIssueSchema>;
@@ -755,6 +766,12 @@ export const askUserQuestionsQuestionOptionSchema = 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(),
freeText: z
.boolean()
.optional()
.describe(
"When true, selecting this option reveals an inline text field; the typed value is returned as the question's otherText. Use this for a real \"I'll describe it\" choice instead of authoring a dead option that does nothing. At most one free-text option per question.",
),
});
export const askUserQuestionsQuestionSchema = z.object({
@@ -785,6 +802,7 @@ export const askUserQuestionsPayloadSchema = z.object({
seenQuestionIds.add(question.id);
const seenOptionIds = new Set<string>();
let freeTextOptionCount = 0;
for (const [optionIndex, option] of question.options.entries()) {
if (seenOptionIds.has(option.id)) {
ctx.addIssue({
@@ -794,6 +812,16 @@ export const askUserQuestionsPayloadSchema = z.object({
});
}
seenOptionIds.add(option.id);
if (option.freeText) {
freeTextOptionCount += 1;
if (freeTextOptionCount > 1) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "A question may declare at most one free-text option",
path: ["questions", questionIndex, "options", optionIndex, "freeText"],
});
}
}
}
}
});
+38 -18
View File
@@ -569,7 +569,7 @@ describeEmbeddedPostgres("built-in agents", () => {
});
});
it("auto-provisions a paused Reflection Coach bundle with skill sync and a disabled routine", async () => {
it("reconciles an enabled Reflection Coach bundle with skill sync and a disabled routine", async () => {
const companyId = await seedCompany({ requireApproval: false });
const root = await agentService(db).create(companyId, {
name: "CEO",
@@ -581,6 +581,17 @@ describeEmbeddedPostgres("built-in agents", () => {
permissions: {},
});
// The Reflection Coach is opt-in (not auto-created). Enabling it on demand
// materializes its managed bundle in a single pass.
const enabled = await builtInAgentService(db).ensure(companyId, "reflection-coach");
expect(enabled.agent?.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
});
expect(enabled.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" });
// Startup reconcile keeps the enabled bundle tracking stock and re-grants
// the root/company default permissions.
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result.autoEnsured).toBeGreaterThanOrEqual(1);
expect(result.defaultGrantsEnsured).toBeGreaterThanOrEqual(4);
@@ -606,11 +617,6 @@ describeEmbeddedPostgres("built-in agents", () => {
},
},
});
expect(state.agent?.adapterConfig).toMatchObject({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
});
expect(state.agent?.adapterConfig).not.toMatchObject({ model: "gpt-5.4", apiKey: "do-not-copy" });
expect(state.resources.map((resource) => [resource.resourceKind, resource.stockStatus])).toEqual([
["instructions", "stock_current"],
["skill", "stock_current"],
@@ -693,7 +699,7 @@ describeEmbeddedPostgres("built-in agents", () => {
)).size).toBe(3);
});
it("preserves new-agent approval gates during automatic Reflection Coach provisioning", async () => {
it("preserves new-agent approval gates during on-demand Reflection Coach provisioning", async () => {
const companyId = await seedCompany({ requireApproval: true });
const root = await agentService(db).create(companyId, {
name: "CEO",
@@ -710,12 +716,11 @@ describeEmbeddedPostgres("built-in agents", () => {
applyInSeparateFollowUpRun: true,
};
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result).toMatchObject({
autoEnsured: 2,
pendingApprovals: 2,
});
// The Reflection Coach is opt-in, so it's enabled on demand. With board
// approval required, provisioning it must leave a pending agent + a
// hire_agent approval rather than an active agent.
const provisioned = await builtInAgentService(db).provision(companyId, "reflection-coach");
expect(provisioned.approval).not.toBeNull();
const state = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(state).toMatchObject({
status: "pending_approval",
@@ -751,7 +756,7 @@ describeEmbeddedPostgres("built-in agents", () => {
});
const pendingReconcile = await reconcileBuiltInAgentsOnStartup(db);
expect(pendingReconcile.pendingApprovals).toBe(2);
expect(pendingReconcile.pendingApprovals).toBe(1);
const stillPending = await builtInAgentService(db).get(companyId, "reflection-coach");
expect(stillPending).toMatchObject({
status: "pending_approval",
@@ -787,7 +792,7 @@ describeEmbeddedPostgres("built-in agents", () => {
const agentRows = await db.select().from(agents).where(eq(agents.companyId, companyId));
expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
const approvalRows = await db.select().from(approvals).where(eq(approvals.companyId, companyId));
expect(approvalRows).toHaveLength(2);
expect(approvalRows).toHaveLength(1);
});
it("preserves Reflection Coach instruction drift on reconcile and restores it on reset", async () => {
@@ -1062,7 +1067,21 @@ describeEmbeddedPostgres("built-in agents", () => {
const { olderId, newerId } = await seedLegacyDuplicateBriefs(affectedCompanyId);
// A second company created after the affected one — previously skipped
// entirely because the duplicate error escaped the reconciliation loop.
// Give it a drifted built-in row so we can prove reconcile still reached it.
const healthyCompanyId = await seedCompany({ requireApproval: false });
const healthyBriefsId = randomUUID();
await db.insert(agents).values({
id: healthyBriefsId,
companyId: healthyCompanyId,
name: "Stale Briefs",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: { model: "gpt-5.4" },
runtimeConfig: {},
permissions: {},
metadata: withBuiltInAgentMarker({}, { key: "briefs", featureKeys: ["briefs"] }),
});
const result = await reconcileBuiltInAgentsOnStartup(db);
expect(result.companyFailures).toBe(0);
@@ -1077,9 +1096,10 @@ describeEmbeddedPostgres("built-in agents", () => {
),
).toHaveLength(1);
// The company after the affected one still had its bundled agents provisioned.
const healthyCoach = await builtInAgentService(db).get(healthyCompanyId, "reflection-coach");
expect(healthyCoach.agentId).toBeTruthy();
// The company after the affected one was still reconciled (its drifted
// built-in row was repaired to stock) rather than skipped.
const [healthyBriefs] = await db.select().from(agents).where(eq(agents.id, healthyBriefsId));
expect(healthyBriefs?.name).toBe("Briefs Agent");
});
it("automatically materializes the Reflection Coach bundle without enabling background work", async () => {
+17 -12
View File
@@ -24,7 +24,7 @@ import {
} from "./helpers/embedded-postgres.js";
import { companyService } from "../services/companies.js";
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
import { reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js";
import { builtInAgentService, reconcileBuiltInAgentsOnStartup } from "../services/built-in-agents.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
@@ -81,19 +81,28 @@ describeEmbeddedPostgres("companyService", () => {
expect(rows.map((row) => row.issuePrefix).sort()).toEqual(["ARO", "AROA"]);
});
it("auto-provisions one paused Reflection Coach bundle for a freshly created company", async () => {
it("does not auto-provision bundled built-in agents for a freshly created company", async () => {
const created = await companyService(db).create({
name: "Fresh Company",
});
// A new company starts clean: the Reflection Coach and Summarizer are
// opt-in, not seeded by default for a new user.
const agentRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
const reflectionRows = agentRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach");
expect(reflectionRows).toHaveLength(1);
expect(reflectionRows[0]).toMatchObject({
expect(agentRows.filter((row) => readBuiltInAgentMarker(row.metadata))).toHaveLength(0);
// Startup reconcile leaves a fresh company untouched — nothing is created.
await reconcileBuiltInAgentsOnStartup(db);
const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata))).toHaveLength(0);
// The Reflection Coach remains available to enable on demand, and enabling
// it materializes its bundled skill + paused routine.
const enabled = await builtInAgentService(db).ensure(created.id, "reflection-coach");
expect(enabled.agent).toMatchObject({
name: "Reflection Coach",
status: "paused",
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
});
const [skill] = await db
@@ -110,10 +119,10 @@ describeEmbeddedPostgres("companyService", () => {
const [routine] = await db
.select()
.from(routines)
.where(and(eq(routines.companyId, created.id), eq(routines.assigneeAgentId, reflectionRows[0]!.id)));
.where(and(eq(routines.companyId, created.id), eq(routines.assigneeAgentId, enabled.agentId!)));
expect(routine).toMatchObject({
status: "paused",
assigneeAgentId: reflectionRows[0]!.id,
assigneeAgentId: enabled.agentId,
originKind: "built_in_agent_bundle",
originId: "reflection-coach:recent-agent-reflection",
});
@@ -122,10 +131,6 @@ describeEmbeddedPostgres("companyService", () => {
kind: "schedule",
enabled: false,
});
await reconcileBuiltInAgentsOnStartup(db);
const afterReconcileRows = await db.select().from(agents).where(eq(agents.companyId, created.id));
expect(afterReconcileRows.filter((row) => readBuiltInAgentMarker(row.metadata)?.key === "reflection-coach")).toHaveLength(1);
});
it("archives companies by pausing runnable agents and cancelling active runs", async () => {
+65 -1
View File
@@ -56,6 +56,7 @@ import {
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
ISSUE_WATCHDOG_DISCOVERY_KINDS,
TASK_WATCHDOG_PRODUCT_BUG_ORIGIN_KIND,
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
rejectIssueThreadInteractionSchema,
restoreIssueDocumentRevisionSchema,
respondIssueThreadInteractionSchema,
@@ -164,6 +165,10 @@ import {
SVG_CONTENT_TYPE,
} from "../attachment-types.js";
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
import {
buildOnboardingGreeting,
ONBOARDING_GREETING_AUTHORIZATION_REASON,
} from "../services/onboarding-greeting.js";
import {
ISSUE_BLOCKERS_RESOLVED_WAKE_REASON,
buildIssueBlockersResolvedWakeIdempotencyKey,
@@ -7520,7 +7525,30 @@ export function issueRoutes(
surface: "issues.create",
});
if (!sanitizedBody) return;
const { watchdogDiscovery: rawWatchdogDiscovery, ...rawCreateBody } = sanitizedBody;
const {
watchdogDiscovery: rawWatchdogDiscovery,
onboardingFirstTask: rawOnboardingFirstTask,
...rawCreateBody
} = sanitizedBody;
// The onboarding first-task marker grants privileged, server-owned behavior:
// it stamps the onboarding origin (which suppresses the seeded description in
// the UI) and seeds a comment authored *as the assigned agent*. Honor it only
// when the request is genuinely the onboarding wizard creating a company's
// very first task, verified server-side so a client marker alone cannot
// trigger it:
// 1. the caller is a human board/user session (the wizard never runs as an
// agent), and
// 2. the company has no existing issues yet — i.e. this really is the first
// task. An established company creating an ordinary issue can never reach
// the greeting/description-suppression path, so no board caller can
// fabricate a statement attributed to an assigned agent on a normal task.
// Fails closed: if it is not verifiably the first task, the flag is ignored
// and an ordinary issue is created.
const onboardingFirstTaskRequested =
rawOnboardingFirstTask === true && req.actor.type === "board";
const isOnboardingFirstTask = onboardingFirstTaskRequested
? (await svc.count(companyId)) === 0
: false;
const watchdogDiscovery = normalizeWatchdogDiscovery(rawWatchdogDiscovery);
const watchdogProductBugFollowUp = await resolveTaskWatchdogProductBugFollowUp(
req,
@@ -7575,6 +7603,9 @@ export function issueRoutes(
...(runWorkspaceInheritanceSourceIssueId
? { inheritExecutionWorkspaceFromIssueId: runWorkspaceInheritanceSourceIssueId }
: {}),
...(isOnboardingFirstTask && !watchdogProductBugFollowUp
? { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }
: {}),
...(watchdogProductBugFollowUp
? {
description: appendWatchdogDiscoveryContext({
@@ -7744,6 +7775,39 @@ export function issueRoutes(
});
}
// Seed the onboarding first-task greeting as an agent-authored comment so the
// user lands on a waiting greeting (instead of a right-aligned "user" bubble
// showing the seeded description). Deterministic template — no LLM call — and
// best-effort: a greeting failure must not fail issue creation.
if (isOnboardingFirstTask && issue.assigneeAgentId) {
try {
const [company, goal, assigneeAgent] = await Promise.all([
companiesSvc.getById(companyId),
createBody.goalId ? goalsSvc.getById(createBody.goalId) : Promise.resolve(null),
agentsSvc.getById(issue.assigneeAgentId),
]);
const greetingBody = buildOnboardingGreeting({
agentName: assigneeAgent?.name ?? null,
teamName: company?.name ?? null,
goals: goal?.description ?? goal?.title ?? null,
});
await svc.addComment(
issue.id,
greetingBody,
{ agentId: issue.assigneeAgentId },
{
authorType: "agent",
authorizationReason: ONBOARDING_GREETING_AUTHORIZATION_REASON,
},
);
} catch (err) {
logger.warn(
{ err, issueId: issue.id, companyId },
"failed to seed onboarding first-task greeting",
);
}
}
void queueIssueAssignmentWakeup({
heartbeat,
issue,
+16
View File
@@ -470,6 +470,12 @@ const DEFINITIONS = validateBuiltInAgentDefinitions([
const DEFINITIONS_BY_KEY = new Map(DEFINITIONS.map((definition) => [definition.key, definition]));
// Bundled built-in agents that should be provisioned automatically when a
// company is created (and re-ensured on startup reconcile). Empty by default so
// a new user starts clean — the Reflection Coach and Summarizer are opt-in, not
// seeded. Add a definition key here to restore automatic provisioning.
const AUTO_PROVISION_ON_COMPANY_CREATE_KEYS = new Set<string>([]);
const ROOT_AGENT_DEFAULT_CHANGE_GRANTS: PermissionKey[] = ["agents:configure", "skills:create"];
const BUILT_IN_AGENT_DEFAULT_GRANTS: Record<string, PermissionKey[]> = {
"reflection-coach": ["agents:suggest-changes", "skills:suggest-changes"],
@@ -1945,7 +1951,17 @@ export function builtInAgentService(db: Db) {
const company = await ensureCompany(companyId);
let autoEnsured = 0;
let pendingApprovals = 0;
// A fresh company starts with only its own lead agent — the Reflection
// Coach and Summarizer are no longer auto-created for new users. They stay
// available to enable on demand (via ensure / provision / the built-in
// bundle panel). We still reconcile any bundled agent that already exists
// (e.g. one an operator enabled) so its instructions/skill/routine keep
// tracking stock. Add a key to AUTO_PROVISION_ON_COMPANY_CREATE_KEYS to
// restore automatic creation for that definition.
for (const definition of DEFINITIONS.filter((entry) => entry.bundle)) {
const existing = await findSingleAgent(companyId, definition);
const shouldProvision = existing !== null || AUTO_PROVISION_ON_COMPANY_CREATE_KEYS.has(definition.key);
if (!shouldProvision) continue;
if (company.requireBoardApprovalForNewAgents) {
const result = await provision(companyId, definition.key);
if (result.approval) pendingApprovals += 1;
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { buildOnboardingGreeting } from "./onboarding-greeting.js";
describe("buildOnboardingGreeting", () => {
it("introduces the agent by name as the user's first teammate and reflects the goals", () => {
const greeting = buildOnboardingGreeting({
agentName: "Nova",
teamName: "Acme",
goals: "Launch a marketplace for local makers.",
});
expect(greeting).toContain(
"Welcome! I'm Nova, your first agent teammate on Paperclip.",
);
expect(greeting).toContain("Here's what I understand you're aiming for:");
expect(greeting).toContain("> Launch a marketplace for local makers.");
expect(greeting).toContain("propose a team of agents");
expect(greeting).toContain("few focused questions");
});
it("falls back to a generic teammate intro when no agent name is set", () => {
const greeting = buildOnboardingGreeting({ agentName: null, goals: null });
expect(greeting).toContain(
"Welcome! I'm your first agent teammate on Paperclip.",
);
});
it("collapses whitespace in the reflected goals", () => {
const greeting = buildOnboardingGreeting({
goals: " Build\n\n a SaaS product. ",
});
expect(greeting).toContain("> Build a SaaS product.");
});
it("omits the reflect-back block when no goals are provided", () => {
const greeting = buildOnboardingGreeting({ agentName: "Nova", goals: null });
expect(greeting).not.toContain("aiming for");
expect(greeting).toContain("propose a team of agents");
});
});
@@ -0,0 +1,39 @@
// Deterministic, template-driven greeting seeded as an agent-authored comment on
// the onboarding first task. No LLM call: it reflects back the onboarding context
// (team name + goals) so the user lands on a waiting greeting instead of a
// right-aligned "user" bubble showing the agent's own seeded instructions.
export const ONBOARDING_GREETING_AUTHORIZATION_REASON = "onboarding first-task greeting";
export function buildOnboardingGreeting(input: {
agentName?: string | null;
teamName?: string | null;
goals?: string | null;
}): string {
const agentName = input.agentName?.trim();
const goals = input.goals?.replace(/\s+/g, " ").trim();
// Introduce the agent by the name the user chose in onboarding when we have
// it, so the first message reads as coming from *their* first teammate rather
// than a generic agent. Fall back to the generic phrasing otherwise.
const identity = agentName
? `Welcome! I'm ${agentName}, your first agent teammate on Paperclip.`
: "Welcome! I'm your first agent teammate on Paperclip.";
const lines: string[] = [];
lines.push(identity);
if (goals) {
lines.push("");
lines.push("Here's what I understand you're aiming for:");
lines.push("");
lines.push(`> ${goals}`);
}
lines.push("");
lines.push(
"I want to gather more context so I can come up with a plan and propose a team of agents to help execute it. I'm putting together a few focused questions so we can settle on a concrete goal to tackle first. Please give me one moment...",
);
return lines.join("\n");
}
+140 -88
View File
@@ -1,105 +1,157 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import {
expectLandsOnFirstTaskWithoutDashboardBounce,
instrumentNavLog,
} from "./helpers/onboarding-landing";
/**
* E2E: post-wizard onboarding launch.
*
* Completing the onboarding wizard now creates the first assigned task and
* lands the user on the company dashboard. The chat intro still has unit
* coverage in BoardChat tests; the wizard handoff no longer routes there.
* drops the user straight onto that task's detail page (not the dashboard),
* so they land in the conversation the agent will start in. The chat intro
* still has unit coverage in BoardChat tests.
*
* PAP-404: onboarding used to intermittently bounce to the company dashboard.
* The bounce only reproduces when the instance already has ≥1 company (the
* board's test ports), so the second test seeds a company first to exercise
* exactly that failing condition.
*/
const COMPANY_NAME = `E2E-TypingIntro-${Date.now()}`;
const MISSION = "Verify the dashboard launch survives the wizard handoff.";
const FIRST_TASK_TITLE = "Hire your first engineer and create a hiring plan";
const MISSION = "Verify the first-task launch survives the wizard handoff.";
const FIRST_TASK_TITLE = "Paperclip onboarding";
test.describe("Dashboard launch after onboarding wizard", () => {
test("creates the first task and opens the dashboard", async ({
/**
* Intercept the two side-effecting calls the wizard makes so no real CLI check
* runs and no real agent process spawns (the hire still happens server-side
* with an inert http adapter).
*/
async function installLaunchIntercepts(page: Page, baseURL?: string) {
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ status: "pass", checks: [] }),
}),
);
await page.route("**/agent-hires", async (route) => {
const req = route.request();
const body = JSON.parse(req.postData() || "{}");
const auth = req.headers().authorization;
const real = await fetch(new URL(req.url(), baseURL).toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(auth ? { Authorization: auth } : {}),
},
body: JSON.stringify({
name: body.name,
role: body.role,
adapterType: "http",
adapterConfig: { url: "http://127.0.0.1:1/dead" },
runtimeConfig: { heartbeat: { enabled: false } },
}),
});
await route.fulfill({
status: real.status,
contentType: "application/json",
body: await real.text(),
});
});
}
/** Drive the wizard from the /onboarding route through to "Get started". */
async function runOnboardingWizard(page: Page, companyName: string) {
await page.goto("/onboarding");
// Launcher card path (existing companies) — enter the wizard if the
// route shows a launcher instead of opening the wizard directly.
const startBtn = page.getByRole("button", { name: /Start Onboarding/i });
if (await startBtn.count()) await startBtn.first().click();
// Step 0: front door (skipped when the wizard opens on the create path).
const frontDoor = page.getByText("Build a new company");
if (await frontDoor.count()) await frontDoor.first().click();
// Step 1: company name.
await page.getByPlaceholder("Acme Corp").fill(companyName);
await page.getByRole("button", { name: /^Next/ }).click();
// Step 2: mission (direct path default).
await page.getByPlaceholder("What is your team trying to achieve?").fill(MISSION);
await page.getByRole("button", { name: /Confirm mission/ }).click();
// Step 3: lead name (prefilled) → Next.
await page.waitForSelector('input[placeholder="Chief of staff"]', {
timeout: 15_000,
});
await page.getByRole("button", { name: /^Next/ }).click();
// Step 4: adapter (claude_local default); heartbeat is intercepted.
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
// Step 5: review → Get started creates the first task and opens its
// detail page.
const getStarted = page.getByRole("button", { name: /Get started/ });
await getStarted.waitFor({ timeout: 20_000 });
await getStarted.click();
}
async function assertFirstTaskExists(page: Page, companyName: string) {
const companiesRes = await page.request.get("/api/companies");
expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json();
const company = companies.find(
(candidate: { name: string }) => candidate.name === companyName,
);
expect(company).toBeTruthy();
const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
expect(issuesRes.ok()).toBe(true);
const issues = await issuesRes.json();
const firstTask = issues.find(
(candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE,
);
expect(firstTask).toBeTruthy();
await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({
timeout: 15_000,
});
}
test.describe("First-task launch after onboarding wizard", () => {
test("creates the first task and opens its detail page", async ({ page, baseURL }) => {
await instrumentNavLog(page);
await installLaunchIntercepts(page, baseURL);
const companyName = `E2E-TypingIntro-${Date.now()}`;
await runOnboardingWizard(page, companyName);
await expectLandsOnFirstTaskWithoutDashboardBounce(page);
await assertFirstTaskExists(page, companyName);
});
// PAP-404 regression: the dashboard bounce only fires when the instance
// already has a company for the route-sync effect to reset selection to.
// Seed one first, then onboard a brand-new company and assert we still land
// on the first task without a dashboard bounce.
test("lands on the first task even when a company already exists", async ({
page,
baseURL,
}) => {
// Intercept env-test → instant pass (avoid running a real CLI check).
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ status: "pass", checks: [] }),
}),
);
await instrumentNavLog(page);
await installLaunchIntercepts(page, baseURL);
// Intercept hire → perform a REAL hire server-side with an inert http
// adapter so no real agent process spawns.
await page.route("**/agent-hires", async (route) => {
const req = route.request();
const body = JSON.parse(req.postData() || "{}");
const auth = req.headers().authorization;
const real = await fetch(new URL(req.url(), baseURL).toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(auth ? { Authorization: auth } : {}),
},
body: JSON.stringify({
name: body.name,
role: body.role,
adapterType: "http",
adapterConfig: { url: "http://127.0.0.1:1/dead" },
runtimeConfig: { heartbeat: { enabled: false } },
}),
});
await route.fulfill({
status: real.status,
contentType: "application/json",
body: await real.text(),
});
// Seed a pre-existing company so the companies list is non-empty when the
// wizard launches — the exact condition that reproduced the bounce.
const seedRes = await page.request.post("/api/companies", {
data: { name: `E2E-Seed-${Date.now()}` },
});
expect(seedRes.ok()).toBe(true);
await page.goto("/onboarding");
const companyName = `E2E-TypingIntro-Existing-${Date.now()}`;
await runOnboardingWizard(page, companyName);
// Launcher card path (existing companies) — enter the wizard if the
// route shows a launcher instead of opening the wizard directly.
const startBtn = page.getByRole("button", { name: /Start Onboarding/i });
if (await startBtn.count()) await startBtn.first().click();
// Step 0: front door (skipped when the wizard opens on the create path).
const frontDoor = page.getByText("Build a new company");
if (await frontDoor.count()) await frontDoor.first().click();
// Step 1: company name.
await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
// Step 2: mission (direct path default).
await page
.getByPlaceholder("What is your team trying to achieve?")
.fill(MISSION);
await page.getByRole("button", { name: /Confirm mission/ }).click();
// Step 3: lead name (prefilled) → Next.
await page.waitForSelector('input[placeholder="Chief of staff"]', {
timeout: 15_000,
});
await page.getByRole("button", { name: /^Next/ }).click();
// Step 4: adapter (claude_local default); heartbeat is intercepted.
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
// Step 5: review → Get started creates the first task and opens dashboard.
const getStarted = page.getByRole("button", { name: /Get started/ });
await getStarted.waitFor({ timeout: 20_000 });
await getStarted.click();
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
const companiesRes = await page.request.get("/api/companies");
expect(companiesRes.ok()).toBe(true);
const companies = await companiesRes.json();
const company = companies.find((candidate: { name: string }) => candidate.name === COMPANY_NAME);
expect(company).toBeTruthy();
const issuesRes = await page.request.get(`/api/companies/${company.id}/issues`);
expect(issuesRes.ok()).toBe(true);
const issues = await issuesRes.json();
const firstTask = issues.find((candidate: { title: string }) => candidate.title === FIRST_TASK_TITLE);
expect(firstTask).toBeTruthy();
await expect(page.getByText(FIRST_TASK_TITLE).first()).toBeVisible({ timeout: 15_000 });
await expectLandsOnFirstTaskWithoutDashboardBounce(page);
await assertFirstTaskExists(page, companyName);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { expect, type Page } from "@playwright/test";
/**
* Regression guard for PAP-404: onboarding must land the user on the newly
* created first-task detail page and must NOT bounce to the company dashboard.
*
* The bounce is a navigation race: the wizard `navigate(/…/issues/…)` used to
* be clobbered by `useCompanyPageMemory`, which restores a company's remembered
* page (falling back to `/dashboard`) on a non-`route_sync` selection change.
*
* react-router-dom drives client-side navigation through the History API, so a
* Playwright `framenavigated` listener never fires for these SPA transitions.
* Instead we hook `history.pushState`/`replaceState` before the app boots and
* record every path the router visits — a `/dashboard` bounce (even a transient
* one that later self-corrects) leaves a trace in the log.
*/
const ISSUE_URL = /\/issues\/[^/]+$/;
const DASHBOARD_PATH = /\/dashboard(\/|$)/;
/**
* Install a History-API tap that records every client-side path change into
* `window.__navLog`. Must be called BEFORE the first `page.goto` so the init
* script is present when the SPA boots.
*/
export async function instrumentNavLog(page: Page): Promise<void> {
await page.addInitScript(() => {
const w = window as unknown as { __navLog?: string[] };
if (w.__navLog) return;
const log: string[] = [];
w.__navLog = log;
const record = () => log.push(window.location.pathname);
const wrap = <T extends (...args: never[]) => unknown>(fn: T): T =>
function (this: unknown, ...args: never[]) {
const result = fn.apply(this, args);
record();
return result;
} as unknown as T;
history.pushState = wrap(history.pushState.bind(history));
history.replaceState = wrap(history.replaceState.bind(history));
window.addEventListener("popstate", record);
record();
});
}
async function readNavLog(page: Page): Promise<string[]> {
return page.evaluate(
() => (window as unknown as { __navLog?: string[] }).__navLog ?? [],
);
}
/**
* Assert the wizard settled on the first-task detail page and never rested on
* (or bounced through) the dashboard.
*
* Fails if a dashboard bounce is reintroduced: the History-API log will contain
* a `/dashboard` entry and/or the settled URL will not be the issue page.
*/
export async function expectLandsOnFirstTaskWithoutDashboardBounce(
page: Page,
): Promise<void> {
// The wizard's launch handler does async work (hire + create issue) before
// navigating, so give reaching the issue page a generous budget.
await expect(page).toHaveURL(ISSUE_URL, { timeout: 30_000 });
const settledUrl = page.url();
// Short settle window: the page-memory effect fires on the selection change
// that accompanies the launch navigate, so any bounce lands within ~1s. If
// the URL is still the issue after this window it has genuinely settled.
await page.waitForTimeout(1_500);
expect(page.url(), "onboarding bounced away from the first task").toBe(settledUrl);
await expect(page).toHaveURL(ISSUE_URL);
const navLog = await readNavLog(page);
const bounced = navLog.filter((path) => DASHBOARD_PATH.test(path));
expect(
bounced,
`onboarding navigated to the dashboard (nav log: ${navLog.join(" -> ")})`,
).toEqual([]);
}
@@ -1,13 +1,19 @@
import { expect, test } from "@playwright/test";
import {
expectLandsOnFirstTaskWithoutDashboardBounce,
instrumentNavLog,
} from "./helpers/onboarding-landing";
const AGENT_NAME = "Chief of staff";
const TASK_TITLE = "Hire your first engineer and create a hiring plan";
const TASK_TITLE = "Paperclip onboarding";
test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const timestamp = Date.now();
const companyName = `PAP-3413-${timestamp}`;
const screenshotDir = "test-results/planning-mode";
await instrumentNavLog(page);
await page.route("**/test-environment", (route) =>
route.fulfill({
contentType: "application/json",
@@ -66,7 +72,9 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await expect(page.getByRole("heading", { name: "Review" })).toBeVisible({ timeout: 30_000 });
await page.getByRole("button", { name: /Get started/ }).click();
await expect(page).toHaveURL(/\/dashboard$/, { timeout: 30_000 });
// The wizard now drops the user straight onto the first task's detail page,
// and must not bounce through the dashboard (PAP-404).
await expectLandsOnFirstTaskWithoutDashboardBounce(page);
const baseOrigin = new URL(page.url()).origin;
const companyRes = await page.request.get(`${baseOrigin}/api/companies`);
@@ -9,6 +9,7 @@ import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip";
import {
pendingAskUserQuestionsInteraction,
pendingAskUserQuestionsWithFreeTextOption,
commentExpiredAskUserQuestionsInteraction,
commentExpiredRequestConfirmationInteraction,
declinedToolActionInteraction,
@@ -185,6 +186,104 @@ describe("IssueThreadInteractionCard", () => {
);
});
it("reveals an inline field when a free-text option is selected and hides the standalone Other link", async () => {
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingAskUserQuestionsWithFreeTextOption,
onSubmitInteractionAnswers,
});
// A first-class free-text option suppresses the built-in "Other" link.
const otherLink = Array.from(host.querySelectorAll("button")).find(
(button) => button.textContent === "Other",
);
expect(otherLink).toBeUndefined();
// No text field until the free-text option is selected.
expect(host.querySelector("textarea")).toBeNull();
const describeOption = Array.from(host.querySelectorAll('[role="radio"]')).find(
(button) => button.textContent?.includes("I'll describe it"),
) as HTMLButtonElement | undefined;
expect(describeOption).toBeTruthy();
await act(async () => {
describeOption?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(describeOption?.getAttribute("aria-checked")).toBe("true");
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
valueSetter?.call(textarea, "Call it Threads");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const submitButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Send answers"),
);
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(
expect.objectContaining({ kind: "ask_user_questions" }),
[
{
questionId: "surface-name",
optionIds: [],
otherText: "Call it Threads",
},
],
);
});
it("renders nothing for a degenerate ask_user_questions card", () => {
// A truly unanswerable question: a prompt with no options and no free-text
// field, so there is nothing for the user to select or type. Hiding it
// strands nothing.
const degenerate = {
...pendingAskUserQuestionsInteraction,
id: "interaction-questions-degenerate",
payload: {
version: 1 as const,
title: "Placeholder",
questions: [
{
id: "q1",
prompt: "Anything?",
selectionMode: "single" as const,
options: [],
},
],
},
};
const host = renderCard({
interaction: degenerate,
onSubmitInteractionAnswers: vi.fn(),
});
// No card wrapper, no title, no controls — the component returns null.
expect(host.childElementCount).toBe(0);
expect(host.textContent).toBe("");
});
it("still renders a legitimate ask_user_questions card", () => {
const host = renderCard({
interaction: pendingAskUserQuestionsInteraction,
onSubmitInteractionAnswers: vi.fn(),
});
expect(host.childElementCount).toBeGreaterThan(0);
expect(host.querySelectorAll('[role="radio"]').length).toBeGreaterThan(0);
});
it("only shows question cancellation when a cancel handler is wired", () => {
const withoutHandler = renderCard({
interaction: pendingAskUserQuestionsInteraction,
@@ -329,32 +428,37 @@ describe("IssueThreadInteractionCard", () => {
expect(host.textContent).toContain("No reason provided.");
});
it("requires a decline reason when the request confirmation payload asks for one", async () => {
it("requires a revision note when the request confirmation payload asks for one", async () => {
const onRejectInteraction = vi.fn(async () => undefined);
const host = renderCard({
interaction: pendingRequestConfirmationInteraction,
onRejectInteraction,
});
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Request revisions"),
// rejectRequiresReason drops the bare Reject: the only send-back path is Revise…
expect(Array.from(host.querySelectorAll("button")).some((button) =>
button.textContent?.trim() === "Reject",
)).toBe(false);
const reviseButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Revise"),
);
expect(declineButton).toBeTruthy();
expect(reviseButton).toBeTruthy();
await act(async () => {
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
reviseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Request revisions"),
).at(-1);
expect(saveButton?.hasAttribute("disabled")).toBe(false);
const sendButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Send revision"),
);
expect(sendButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(host.textContent).toContain("A decline reason is required.");
expect(host.textContent).toContain("Add a note describing the changes you want.");
expect(onRejectInteraction).not.toHaveBeenCalled();
const textarea = host.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
@@ -368,12 +472,12 @@ describe("IssueThreadInteractionCard", () => {
valueSetter?.call(textarea, "Needs a smaller phase split");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const enabledSaveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Request revisions"),
).at(-1);
expect(enabledSaveButton?.hasAttribute("disabled")).toBe(false);
const enabledSendButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Send revision"),
);
expect(enabledSendButton?.hasAttribute("disabled")).toBe(false);
await act(async () => {
enabledSaveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
enabledSendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onRejectInteraction).toHaveBeenCalledWith(
@@ -403,6 +507,35 @@ describe("IssueThreadInteractionCard", () => {
);
});
it("standardizes the bare-reject button to Reject even when the payload carries a legacy rejectLabel", () => {
const host = renderCard({
interaction: {
...pendingRequestConfirmationInteraction,
payload: {
...pendingRequestConfirmationInteraction.payload,
// Onboarding/plan-approval interactions are still seeded with the
// legacy "Request changes" reject label; it must not leak into the CTA.
rejectLabel: "Request changes",
rejectRequiresReason: false,
},
},
onAcceptInteraction: vi.fn(async () => undefined),
onRejectInteraction: vi.fn(async () => undefined),
});
const labels = Array.from(host.querySelectorAll("button")).map((button) =>
button.textContent?.trim(),
);
// Canonical plan-approval grammar, right→left: Approve · Revise… · Reject.
// "Revise…" already carries the send-back-with-notes path, so a distinct
// "Request changes" word is redundant and must not render.
expect(labels).toContain("Reject");
expect(labels).toContain("Revise…");
expect(labels.some((label) => label?.includes("Approve"))).toBe(true);
expect(host.textContent).not.toContain("Request changes");
});
it("does not expose continuation wake policy labels in the card header", () => {
const host = renderCard({
interaction: {
@@ -450,8 +583,10 @@ describe("IssueThreadInteractionCard", () => {
onRejectInteraction,
});
// The bare-reject button always renders the canonical "Reject", not the
// payload's "Keep it" — ConfirmationActionRow no longer honors the override.
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Keep it"),
button.textContent?.trim() === "Reject",
);
expect(declineButton).toBeTruthy();
@@ -543,11 +678,11 @@ describe("IssueThreadInteractionCard", () => {
onUploadImage,
});
const declineButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Request revisions"),
const reviseButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Revise"),
);
await act(async () => {
declineButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
reviseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const attachButton = Array.from(host.querySelectorAll("button")).find((button) =>
@@ -570,11 +705,11 @@ describe("IssueThreadInteractionCard", () => {
});
expect(onUploadImage).toHaveBeenCalledTimes(1);
const saveButton = Array.from(host.querySelectorAll("button")).filter((button) =>
button.textContent?.includes("Request revisions"),
).at(-1);
const sendButton = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Send revision"),
);
await act(async () => {
saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
sendButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onRejectInteraction).toHaveBeenCalledWith(
+399 -309
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { Agent } from "@paperclipai/shared";
import { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Users, Wrench, X, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
@@ -10,6 +10,7 @@ import {
getCheckboxConfirmationSelectedLabels,
getItemVerdictProgress,
getQuestionAnswerLabels,
isDegenerateAskUserQuestions,
normalizeRequestConfirmationTargetHref,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
@@ -987,8 +988,16 @@ function AskUserQuestionsCard({
),
);
function toggleOption(questionId: string, optionId: string, selectionMode: "single" | "multi") {
if (optionId === OTHER_ANSWER_ID) {
function toggleOption(
questionId: string,
optionId: string,
selectionMode: "single" | "multi",
isFreeText = false,
) {
// A free-text option is a first-class version of the built-in "Other"
// affordance: selecting it reveals the inline text field and its typed
// value is submitted as the question's `otherText`.
if (optionId === OTHER_ANSWER_ID || isFreeText) {
setOtherActiveQuestions((current) => ({
...current,
[questionId]: !current[questionId],
@@ -1062,7 +1071,11 @@ function AskUserQuestionsCard({
{interaction.status === "pending" ? (
<div className="space-y-4">
{questions.map((question, index) => (
{questions.map((question, index) => {
const hasFreeTextOption = question.options.some(
(option) => option.freeText === true,
);
return (
<div
key={question.id}
className="rounded-2xl border border-border/70 bg-background/82 p-4 shadow-(--shadow-extract-9)"
@@ -1097,50 +1110,82 @@ function AskUserQuestionsCard({
role={question.selectionMode === "single" ? "radiogroup" : "group"}
aria-labelledby={`${interaction.id}-${question.id}-prompt`}
>
{question.options.map((option) => (
<QuestionOptionButton
key={option.id}
id={`${interaction.id}-${question.id}-${option.id}`}
label={option.label}
description={option.description}
selected={(draftAnswers[question.id] ?? []).includes(option.id)}
selectionMode={question.selectionMode}
onClick={() =>
toggleOption(question.id, option.id, question.selectionMode)}
/>
))}
{question.options.map((option) => {
const isFreeText = option.freeText === true;
const optionSelected = isFreeText
? otherActiveQuestions[question.id] === true
: (draftAnswers[question.id] ?? []).includes(option.id);
return (
<div key={option.id} className="space-y-2">
<QuestionOptionButton
id={`${interaction.id}-${question.id}-${option.id}`}
label={option.label}
description={option.description}
selected={optionSelected}
selectionMode={question.selectionMode}
onClick={() =>
toggleOption(question.id, option.id, question.selectionMode, isFreeText)}
/>
{isFreeText && optionSelected ? (
<Textarea
aria-label={`Describe your answer for ${question.prompt}`}
value={draftOtherAnswers[question.id] ?? ""}
onChange={(event) =>
setDraftOtherAnswers((current) => ({
...current,
[question.id]: event.target.value,
}))}
placeholder="Type your answer"
className="min-h-24 bg-background text-sm"
autoFocus
/>
) : null}
</div>
);
})}
</div>
<button
type="button"
id={`${interaction.id}-${question.id}-other`}
aria-expanded={otherActiveQuestions[question.id] === true}
className={cn(
"text-sm font-medium underline underline-offset-4 transition-colors outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
otherActiveQuestions[question.id]
? "text-sky-700 hover:text-sky-800 dark:text-sky-300 dark:hover:text-sky-200"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() =>
toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)}
>
Other
</button>
{otherActiveQuestions[question.id] ? (
<Textarea
aria-label={`Other answer for ${question.prompt}`}
value={draftOtherAnswers[question.id] ?? ""}
onChange={(event) =>
setDraftOtherAnswers((current) => ({
...current,
[question.id]: event.target.value,
}))}
placeholder="Type your answer"
className="min-h-24 bg-background text-sm"
/>
) : null}
{/*
* The built-in "Other" link is the fallback free-text affordance.
* Suppress it when the agent already authored a first-class
* free-text option so the card never shows two ways to type an
* answer (PAP-419).
*/}
{hasFreeTextOption ? null : (
<>
<button
type="button"
id={`${interaction.id}-${question.id}-other`}
aria-expanded={otherActiveQuestions[question.id] === true}
className={cn(
"text-sm font-medium underline underline-offset-4 transition-colors outline-none focus-visible:ring-(length:--rad-3) focus-visible:ring-ring/50",
otherActiveQuestions[question.id]
? "text-sky-700 hover:text-sky-800 dark:text-sky-300 dark:hover:text-sky-200"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() =>
toggleOption(question.id, OTHER_ANSWER_ID, question.selectionMode)}
>
Other
</button>
{otherActiveQuestions[question.id] ? (
<Textarea
aria-label={`Other answer for ${question.prompt}`}
value={draftOtherAnswers[question.id] ?? ""}
onChange={(event) =>
setDraftOtherAnswers((current) => ({
...current,
[question.id]: event.target.value,
}))}
placeholder="Type your answer"
className="min-h-24 bg-background text-sm"
/>
) : null}
</>
)}
</div>
</div>
))}
);
})}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-border/70 bg-background/75 p-4">
<div className="text-sm text-muted-foreground">
@@ -1876,6 +1921,204 @@ function RequestToolActionCard({
);
}
/**
* The single approval grammar shared by every plan / task-approval card
* (PAP-418): **Approve · Revise… · Reject**. "Revise…" reveals an attached text
* field for the changes you want (the former "decline with a reason" path);
* bare "Reject" sends the work back with no note. These are the default words —
* producers may still override the accept/reject labels for domain-specific
* confirmations (e.g. "Delete selected"), but the shape stays consistent.
*/
const CONFIRMATION_APPROVE_LABEL = "Approve";
const CONFIRMATION_REVISE_LABEL = "Revise…";
const CONFIRMATION_REJECT_LABEL = "Reject";
/**
* The one action control every confirmation card renders (PAP-418), collapsing
* what used to be a two-button card plus a separate sticky Plan-pane bar into a
* single consistent surface. Producer flags tune which affordances appear:
* `allowDeclineReason: false` drops the Revise… path so only Approve/Reject
* remain; `rejectRequiresReason: true` drops the bare Reject so every rejection
* carries a note. The revise text stays attached to the card.
*/
function ConfirmationActionRow({
resetKey,
approveLabel,
reviseLabel = CONFIRMATION_REVISE_LABEL,
rejectLabel,
approveVariant = "default",
primaryActionOnRight = false,
allowRevise,
rejectRequiresReason,
reasonPlaceholder,
working,
actionError,
approveDisabled = false,
canApprove,
canReject,
onApprove,
onReject,
composeReason,
extraReasonSatisfied = false,
revisePanelChildren,
}: {
/** Changing this (interaction id + status) collapses the revise panel and
* clears its draft text — the row is reused across interaction updates. */
resetKey: string;
approveLabel: string;
reviseLabel?: string;
rejectLabel: string;
approveVariant?: React.ComponentProps<typeof Button>["variant"];
primaryActionOnRight?: boolean;
allowRevise: boolean;
rejectRequiresReason: boolean;
reasonPlaceholder: string;
working: "accept" | "reject" | null;
actionError: string | null;
approveDisabled?: boolean;
canApprove: boolean;
canReject: boolean;
onApprove: () => void;
onReject: (reason: string | undefined) => void;
/** Compose the final reject reason from the typed text (plan cards append
* screenshot markdown here). */
composeReason?: (text: string) => string | undefined;
/** A required reason is already satisfied by an attachment (e.g. screenshots),
* so an empty text box should not block sending the revision. */
extraReasonSatisfied?: boolean;
/** Extra affordances rendered inside the revise panel (e.g. screenshot attach). */
revisePanelChildren?: ReactNode;
}) {
const [revising, setRevising] = useState(false);
const [reason, setReason] = useState("");
const [attempted, setAttempted] = useState(false);
useEffect(() => {
setRevising(false);
setReason("");
setAttempted(false);
}, [resetKey]);
const trimmed = reason.trim();
const reasonMissing = rejectRequiresReason && trimmed.length === 0 && !extraReasonSatisfied;
function submitRevision() {
setAttempted(true);
if (!canReject || reasonMissing) return;
onReject(composeReason ? composeReason(reason) : trimmed || undefined);
}
return (
<div className="space-y-3">
<div
className={cn(
"flex flex-wrap items-center justify-end gap-2",
primaryActionOnRight && "flex-row-reverse justify-start",
)}
>
<Button
size="sm"
variant={revising ? "outline" : approveVariant}
disabled={!canApprove || working !== null || approveDisabled}
onClick={onApprove}
>
{working === "accept" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Approving…
</>
) : (
approveLabel
)}
</Button>
{allowRevise ? (
<Button
size="sm"
variant="outline"
disabled={!canReject || working !== null}
onClick={() => {
setAttempted(false);
setRevising((current) => !current);
}}
>
{reviseLabel}
</Button>
) : null}
{!rejectRequiresReason ? (
<Button
size="sm"
variant="ghost"
disabled={!canReject || working !== null}
onClick={() => onReject(undefined)}
>
{working === "reject" && !revising ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Rejecting…
</>
) : (
rejectLabel
)}
</Button>
) : null}
</div>
{revising ? (
<div className="space-y-3 rounded-sm border border-border/70 bg-background/75 p-3">
<Textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder={reasonPlaceholder}
aria-invalid={attempted && reasonMissing}
className={cn(
"min-h-24 bg-background text-sm",
attempted && reasonMissing && "border-rose-500 focus-visible:ring-rose-500/25",
)}
/>
{attempted && reasonMissing ? (
<p className="text-xs text-destructive">Add a note describing the changes you want.</p>
) : null}
{revisePanelChildren}
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => {
setRevising(false);
setAttempted(false);
}}
>
Cancel
</Button>
<Button
size="sm"
variant="outline"
disabled={!canReject || working !== null}
onClick={submitRevision}
>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending…
</>
) : (
"Send revision"
)}
</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>
);
}
function RequestConfirmationCard({
interaction,
isPlan = false,
@@ -1898,37 +2141,28 @@ function RequestConfirmationCard({
onUploadImage?: (file: File) => Promise<string>;
externalReferences?: MarkdownExternalReferenceMap;
}) {
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 [actionError, setActionError] = useState<string | null>(null);
const [shots, setShots] = useState<{ name: string; url: string }[]>([]);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Screenshots ride along in the decline reason as markdown image refs so the
// Screenshots ride along in the revise note as markdown image refs so the
// board can attach images when sending a plan back — no schema change needed.
const allowScreenshots = isPlan && Boolean(onUploadImage);
const rejectRequiresReason = interaction.payload.rejectRequiresReason === true;
const allowDeclineReason = interaction.payload.allowDeclineReason !== false;
const trimmedRejectReason = rejectReason.trim();
const canReject = !rejectRequiresReason || trimmedRejectReason.length > 0 || shots.length > 0;
const declineReasonInvalid = rejectRequiresReason && !canReject;
const declineReasonPlaceholder =
const allowRevise = interaction.payload.allowDeclineReason !== false;
const reasonPlaceholder =
interaction.payload.declineReasonPlaceholder
?? (interaction.payload.acceptLabel === "Approve plan"
? "Optional: what would you like revised?"
: "Optional: tell the agent what you'd change.");
useEffect(() => {
setRejectReason(interaction.result?.reason ?? "");
setRejectAttempted(false);
setActionError(null);
setShots([]);
setUploadError(null);
if (interaction.status !== "pending") {
setRejecting(false);
setWorking(null);
}
}, [interaction.id, interaction.result?.reason, interaction.status]);
@@ -1952,11 +2186,11 @@ function RequestConfirmationCard({
}
}
function composeReason() {
const text = trimmedRejectReason;
if (shots.length === 0) return text || undefined;
function composeReason(text: string) {
const trimmed = text.trim();
if (shots.length === 0) return trimmed || undefined;
const images = shots.map((s) => `![${s.name}](${s.url})`).join("\n");
return [text, images].filter(Boolean).join("\n\n");
return [trimmed, images].filter(Boolean).join("\n\n");
}
async function handleAccept() {
@@ -1972,14 +2206,12 @@ function RequestConfirmationCard({
}
}
async function handleReject() {
setRejectAttempted(true);
if (!onRejectInteraction || !canReject) return;
async function handleReject(reason: string | undefined) {
if (!onRejectInteraction) return;
setWorking("reject");
setActionError(null);
try {
await onRejectInteraction(interaction, composeReason());
setRejecting(false);
await onRejectInteraction(interaction, reason);
} catch {
setActionError("Try again");
} finally {
@@ -2007,161 +2239,89 @@ function RequestConfirmationCard({
) : null}
{interaction.status === "pending" ? (
<div className="space-y-3">
<div
className={cn(
"flex flex-wrap items-center justify-end gap-2",
primaryActionOnRight && "flex-row-reverse justify-start",
)}
>
<Button
size="sm"
variant={rejecting ? "outline" : isPlan ? "cta" : "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"
)}
</Button>
<Button
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => {
if (!allowDeclineReason) {
void handleReject();
return;
}
setRejectAttempted(false);
setRejecting((current) => !current);
}}
>
{interaction.payload.rejectLabel ?? "Decline"}
</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 decline reason is required.</p>
) : null}
{allowScreenshots ? (
<div className="space-y-2">
{shots.length > 0 ? (
<div className="flex flex-wrap gap-2">
{shots.map((shot, index) => (
<div
key={`${shot.url}-${index}`}
className="group relative h-16 w-16 overflow-hidden rounded-sm border border-border/70"
<ConfirmationActionRow
resetKey={`${interaction.id}:${interaction.status}`}
approveLabel={interaction.payload.acceptLabel ?? CONFIRMATION_APPROVE_LABEL}
rejectLabel={CONFIRMATION_REJECT_LABEL}
approveVariant={isPlan ? "cta" : "default"}
primaryActionOnRight={primaryActionOnRight}
allowRevise={allowRevise}
rejectRequiresReason={rejectRequiresReason}
reasonPlaceholder={reasonPlaceholder}
working={working}
actionError={actionError}
canApprove={Boolean(onAcceptInteraction)}
canReject={Boolean(onRejectInteraction)}
onApprove={() => void handleAccept()}
onReject={(reason) => void handleReject(reason)}
composeReason={composeReason}
extraReasonSatisfied={shots.length > 0}
revisePanelChildren={
allowScreenshots ? (
<div className="space-y-2">
{shots.length > 0 ? (
<div className="flex flex-wrap gap-2">
{shots.map((shot, index) => (
<div
key={`${shot.url}-${index}`}
className="group relative h-16 w-16 overflow-hidden rounded-sm border border-border/70"
>
<img
src={shot.url}
alt={shot.name}
className="h-full w-full object-cover"
/>
<button
type="button"
aria-label={`Remove ${shot.name}`}
className="absolute right-0.5 top-0.5 rounded-full bg-background/90 p-0.5 text-foreground opacity-0 transition-opacity group-hover:opacity-100"
onClick={() =>
setShots((current) => current.filter((_, i) => i !== index))
}
>
<img
src={shot.url}
alt={shot.name}
className="h-full w-full object-cover"
/>
<button
type="button"
aria-label={`Remove ${shot.name}`}
className="absolute right-0.5 top-0.5 rounded-full bg-background/90 p-0.5 text-foreground opacity-0 transition-opacity group-hover:opacity-100"
onClick={() =>
setShots((current) => current.filter((_, i) => i !== index))
}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
) : null}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleAddScreenshots(event.target.value ? event.target.files : null);
event.target.value = "";
}}
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={working !== null || uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Uploading...
</>
) : (
<>
<ImagePlus className="mr-2 h-3.5 w-3.5" />
Attach screenshots
</>
)}
</Button>
{uploadError ? (
<p className="text-xs text-destructive">{uploadError}</p>
) : null}
</div>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => {
setRejecting(false);
setRejectAttempted(false);
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
) : null}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
void handleAddScreenshots(event.target.value ? event.target.files : null);
event.target.value = "";
}}
>
Cancel decline
</Button>
/>
<Button
type="button"
size="sm"
variant="outline"
disabled={!onRejectInteraction || working !== null}
onClick={() => void handleReject()}
disabled={working !== null || uploading}
onClick={() => fileInputRef.current?.click()}
>
{working === "reject" ? (
{uploading ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Saving...
Uploading...
</>
) : (
interaction.payload.rejectLabel ?? "Decline"
<>
<ImagePlus className="mr-2 h-3.5 w-3.5" />
Attach screenshots
</>
)}
</Button>
{uploadError ? (
<p className="text-xs text-destructive">{uploadError}</p>
) : null}
</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>
) : null
}
/>
) : (
<RequestConfirmationResolution interaction={interaction} />
)}
@@ -2291,11 +2451,13 @@ function CheckboxOptionRow({
function RequestCheckboxConfirmationCard({
interaction,
primaryActionOnRight = false,
onAcceptInteraction,
onRejectInteraction,
externalReferences,
}: {
interaction: RequestCheckboxConfirmationInteraction;
primaryActionOnRight?: boolean;
onAcceptInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
selectedClientKeys: undefined,
@@ -2322,10 +2484,7 @@ function RequestCheckboxConfirmationCard({
);
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);
@@ -2333,22 +2492,16 @@ function RequestCheckboxConfirmationCard({
useEffect(() => {
setSelectedOptionIds(new Set(defaultSelected));
setRejectReason(interaction.result?.reason ?? "");
setRejectAttempted(false);
setAcceptAttempted(false);
setActionError(null);
if (interaction.status !== "pending") {
setRejecting(false);
setWorking(null);
}
}, [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 =
const allowRevise = interaction.payload.allowDeclineReason !== false;
const reasonPlaceholder =
interaction.payload.declineReasonPlaceholder ?? "Optional: tell the agent what you'd change.";
const selectedCount = selectedOptionIds.size;
@@ -2403,14 +2556,12 @@ function RequestCheckboxConfirmationCard({
}
}
async function handleReject() {
setRejectAttempted(true);
if (!onRejectInteraction || !canReject) return;
async function handleReject(reason: string | undefined) {
if (!onRejectInteraction) return;
setWorking("reject");
setActionError(null);
try {
await onRejectInteraction(interaction, trimmedRejectReason || undefined);
setRejecting(false);
await onRejectInteraction(interaction, reason);
} catch {
setActionError("Try again");
} finally {
@@ -2501,91 +2652,21 @@ function RequestCheckboxConfirmationCard({
<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}
<ConfirmationActionRow
resetKey={`${interaction.id}:${interaction.status}`}
approveLabel={interaction.payload.acceptLabel ?? CONFIRMATION_APPROVE_LABEL}
rejectLabel={CONFIRMATION_REJECT_LABEL}
primaryActionOnRight={primaryActionOnRight}
allowRevise={allowRevise}
rejectRequiresReason={rejectRequiresReason}
reasonPlaceholder={reasonPlaceholder}
working={working}
actionError={actionError}
canApprove={Boolean(onAcceptInteraction)}
canReject={Boolean(onRejectInteraction)}
onApprove={() => void handleAccept()}
onReject={(reason) => void handleReject(reason)}
/>
</div>
</div>
);
@@ -3094,6 +3175,14 @@ export function IssueThreadInteractionCard({
onUploadImage,
externalReferences,
}: IssueThreadInteractionCardProps) {
// Single enforcement point (PAP-424, plan from PAP-420): a degenerate
// `ask_user_questions` card — meaningless placeholder junk like the onboarding
// `Test / A` card — offers no genuine question, so it is never drawn. Every
// render site (both thread backbones + the attention resolver) routes through
// this component, so suppressing here suppresses it everywhere at once. The
// interaction is still created and stored server-side; only the render is
// suppressed. Composition sites additionally filter it so no empty slot lingers.
if (isDegenerateAskUserQuestions(interaction)) return null;
const isPlan = isPlanConfirmation(interaction);
const isToolAction =
interaction.kind === "request_confirmation" && isToolActionConfirmation(interaction);
@@ -3273,6 +3362,7 @@ export function IssueThreadInteractionCard({
) : interaction.kind === "request_checkbox_confirmation" ? (
<RequestCheckboxConfirmationCard
interaction={interaction}
primaryActionOnRight={primaryActionOnRight}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}
+70 -49
View File
@@ -82,12 +82,34 @@ function buildMissionFromQuestionnaire(q1: string, q2: string, q3: string, q4: s
}
const ONBOARDING_STORAGE_KEY = "paperclip-onboarding-state";
const DEFAULT_TASK_TITLE = "Hire your first engineer and create a hiring plan";
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
const DEFAULT_TASK_TITLE = "Paperclip onboarding";
const DEFAULT_TASK_DESCRIPTION = `You are the Paperclip agent. This is your first task. Your job here is to
understand what the user wants and turn it into a concrete plan — not to
start building yet.
- hire a founding engineer
- write a hiring plan
- break the roadmap into concrete tasks and start delegating work`;
A greeting has already been posted to the user on your behalf, so don't
re-introduce yourself — go straight to the questions.
This is a user-facing chat. Everything you post here is read by the user, so
keep your messages terse and written for them. Only surface things meant for
the user: the questions, the plan, the team, next-step options, and short
status ("Got your answers — here's the plan."). Never narrate how you work.
Don't post your internal steps or thinking into the chat — no "let me probe
the schema", "schema learned", "building the questions payload", "orienting
myself with the API", or similar play-by-play of your API/tool calls. Do that
work silently and post only the result.
Work in this order:
1. Ask a few focused, clarifying questions. Use an ask_user_questions interaction to settle on one concrete goal to tackle first— scope, priorities, constraints, and what "done" looks like. Don't guess; ask.
2. Propose one plan. Once you understand the goal, write a short approach plan to the \`plan\` document. At the bottom, list the agents you'd hire (with their roles) and any follow-up tasks you'd create. Then present the whole thing as a SINGLE request_checkbox_confirmation that targets the \`plan\` document, with each proposed hire and follow-up task as its own checkable option, checked by default. Give each option a stable id you can act on later. Do NOT use suggest_tasks or a separate request_confirmation — one checkbox card is the plan and its approval. In the card's message keep the summary to a line or two and point the user to the full write-up in the plan on the right sidebar (it opens to the Plan there automatically) — don't paste the whole plan into the card, and never say the write-up is "above" or "in the plan doc above"; it lives in the right sidebar.
3. Wait for approval. Don't hire anyone or create work until the user approves the plan. They can uncheck anything they don't want before approving, and unchecking simply drops it. If they ask for changes, revise the plan document and re-confirm.
4. On approval, execute only what they kept. Create exactly the checked options — hire the checked agents and create + delegate the checked follow-up tasks, each in its own task. Skip anything the user unchecked.
Propose, don't decide. Keep it conversational.`;
const INCOMPLETE_ONBOARDING_STATE_MESSAGE =
"Onboarding state is incomplete. Please restart onboarding and try again.";
@@ -440,7 +462,8 @@ export function OnboardingWizard() {
setCreatedProjectId(projectId);
}
if (!createdIssueRef) {
let issueRef = createdIssueRef;
if (!issueRef) {
const issue = await issuesApi.create(
createdCompanyId,
buildOnboardingIssuePayload({
@@ -451,17 +474,24 @@ export function OnboardingWizard() {
goalId
})
);
setCreatedIssueRef(issue.identifier ?? issue.id);
issueRef = issue.identifier ?? issue.id;
setCreatedIssueRef(issueRef);
queryClient.invalidateQueries({
queryKey: queryKeys.issues.list(createdCompanyId)
});
}
const prefix = createdCompanyPrefix;
setSelectedCompanyId(createdCompanyId);
// Select the new company as a route sync, not a manual switch: the
// explicit navigate below is the intended destination, so page-memory's
// "restore last page" (which falls back to /dashboard) must not fire and
// clobber the first-task URL. See PAP-404.
setSelectedCompanyId(createdCompanyId, { source: "route_sync" });
reset();
closeOnboarding();
navigate(prefix ? `/${prefix}/dashboard` : "/dashboard");
// Drop the user straight into the first task's detail page (not the
// dashboard) so they land on the conversation the agent will start in.
navigate(prefix ? `/${prefix}/issues/${issueRef}` : `/issues/${issueRef}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to launch first task");
} finally {
@@ -820,7 +850,7 @@ export function OnboardingWizard() {
morph reads as one capsule coming to life — dashed slot →
solid (configured) → liquid fill + blue glow (online). */}
{step >= 3 && step <= 5 && (
<div className="space-y-4 mb-6">
<div className="mb-6 space-y-4">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
{step === 5 ? (
@@ -832,7 +862,7 @@ export function OnboardingWizard() {
<div>
<h3 className="font-medium">
{step === 3
? "Create your team lead"
? "Create your first agent"
: step === 4
? "Connect a model"
: "Review"}
@@ -840,40 +870,47 @@ export function OnboardingWizard() {
<p className="text-xs text-muted-foreground">
{step === 3 ? (
<>
Name your lead. They'll help drive{" "}
They'll help drive{" "}
<span className="font-medium text-foreground">{companyName}</span>{" "}
toward its mission. We default to{" "}
<span className="font-medium text-foreground">Chief of staff</span> —
rename it to anything you like.
<span className="font-medium text-foreground">Chief of staff</span>.
Rename it to anything you like.
</>
) : step === 4 ? (
<>Pick the adapter and model your lead will run on, then check the environment.</>
) : (
<>Everything's set up — your team lead is online and ready to work.</>
<>Your first agent is online and ready to work.</>
)}
</p>
</div>
</div>
<div className="flex flex-col items-center gap-1.5 py-1 text-center">
<div
className={cn(
"flex flex-col items-center py-1 text-center",
step === 5 ? "mt-8 gap-2.5" : "gap-1.5"
)}
>
<AgentCapsule
state={step === 3 ? "slot" : step === 4 ? "configured" : "online"}
gradient={5}
glow="blue"
size="md"
/>
<p className="text-(length:--text-micro) text-muted-foreground">
{step === 3 ? (
"an empty slot for an agent"
) : step === 4 ? (
"your team lead, taking shape"
) : (
<>
<span className="font-medium text-foreground">{agentName}</span>{" "}
is online and ready to work!
</>
)}
</p>
{step !== 3 && (
<p
className={cn(
"text-muted-foreground",
step === 5 ? "text-sm" : "text-(length:--text-micro)"
)}
>
{step === 4 ? (
"your team lead, taking shape"
) : (
<span className="font-medium text-foreground">{agentName}</span>
)}
</p>
)}
</div>
</div>
)}
@@ -973,9 +1010,9 @@ export function OnboardingWizard() {
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Name your company</h3>
<h3 className="font-medium">Name your organization</h3>
<p className="text-xs text-muted-foreground">
What should we call your company?
What should we call your team or company?
</p>
</div>
</div>
@@ -1030,7 +1067,7 @@ export function OnboardingWizard() {
</div>
{/* Mission path selector */}
<div className="space-y-3">
<div className="space-y-3 pt-3">
<label className="text-xs text-foreground block">
How would you like to define your mission?
</label>
@@ -1202,13 +1239,6 @@ export function OnboardingWizard() {
You can always change your mission later in settings.
</p>
)}
<button
className="text-(length:--text-micro) text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setStep(1)}
>
← Change company name
</button>
</div>
)}
@@ -1590,7 +1620,7 @@ export function OnboardingWizard() {
{/* Review checklist — everything that's now set up */}
<div className="space-y-1.5">
{[
{ label: "Company name", done: Boolean(companyName.trim()) },
{ label: "Organization name", done: Boolean(companyName.trim()) },
{ label: "Mission", done: Boolean(companyGoal.trim()) },
{ label: "Agent created", done: Boolean(createdAgentId) },
{ label: "Model connected", done: Boolean(createdAgentId) },
@@ -1612,15 +1642,6 @@ export function OnboardingWizard() {
</div>
))}
</div>
{companyGoal.trim() && (
<p className="text-sm text-muted-foreground italic text-center">
"{companyGoal}"
</p>
)}
<p className="text-xs text-muted-foreground text-center">
We'll create the first task for {agentName} and take you to the dashboard.
</p>
</div>
)}
@@ -1695,7 +1716,7 @@ export function OnboardingWizard() {
) : (
<ArrowRight className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Bringing to life..." : "Give it a heartbeat"}
{loading ? "Connecting..." : "Connect"}
</Button>
)}
{step === 5 && (
@@ -242,7 +242,7 @@ describe("SidebarCompanyMenu", () => {
});
await flushReact();
expect(document.body.textContent).toContain("Create new company...");
expect(document.body.textContent).toContain("Create new organization...");
expect(document.body.textContent).not.toContain("Add company...");
act(() => {
@@ -281,7 +281,7 @@ describe("SidebarCompanyMenu", () => {
expect(document.body.textContent).toContain("Edit");
expect(document.body.textContent).toContain("Strata");
expect(document.body.textContent).toContain("ANA");
expect(document.body.textContent).toContain("Create new company...");
expect(document.body.textContent).toContain("Create new organization...");
expect(document.body.textContent).toContain("Invite people to Acme Labs");
expect(document.body.textContent).toContain("Company settings");
expect(document.body.textContent).toContain("Sign out");
@@ -409,7 +409,7 @@ describe("SidebarCompanyMenu", () => {
await openMenu("Open Acme Labs company switcher");
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
.find((element) => element.textContent?.includes("Create new company..."));
.find((element) => element.textContent?.includes("Create new organization..."));
expect(createItem).toBeTruthy();
act(() => {
+5 -2
View File
@@ -317,7 +317,10 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
if (createStackUrl) navigateTopLevel(createStackUrl);
return;
}
openOnboarding();
// Skip the front-door "how would you like to get started?" choice and land
// directly on "Name your organization" — this entry point is unambiguously
// "create a new company" (PAP-431).
openOnboarding({ initialStep: 1 });
}
const handleDragEnd = useCallback(
@@ -459,7 +462,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
disabled={isEditingOrder}
>
<Plus className="size-4" />
<span>{isCloud ? "Create new organization..." : "Create new company..."}</span>
<span>Create new organization...</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
+62 -1
View File
@@ -19,9 +19,17 @@ import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescri
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatMessageItem,
TaskChatTurnItem,
} from "@/components/task-chat/task-chat-model";
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
import {
interactionThreadAnchorMs,
isSuppressedThreadInteraction,
} from "@/components/task-chat/interaction-thread-order";
import { isDegenerateAskUserQuestions } from "@/lib/issue-thread-interactions";
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
import type { FeedbackVoteValue } from "@paperclipai/shared";
import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
@@ -108,6 +116,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
threadHeader,
workModeChanges,
issueBrief,
feedbackVotes,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
} = props;
const linkedRunMetaById = useMemo(() => {
@@ -219,6 +231,14 @@ export function TaskChatThread(props: TaskChatThreadProps) {
entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item });
});
for (const interaction of interactions ?? []) {
// Withdrawn/superseded confirmations are retracted calls to action — drop
// them so a dead card never stacks above the one that replaced it
// (PAP-416).
if (isSuppressedThreadInteraction(interaction)) continue;
// A degenerate `ask_user_questions` card (e.g. the onboarding `Test / A`
// placeholder) is never rendered — filter it here so it leaves no empty
// slot or gap in the ordered backbone (PAP-424, plan from PAP-420).
if (isDegenerateAskUserQuestions(interaction)) continue;
const createdAtMs = toMs(interaction.createdAt);
const handoffAtMs =
interaction.kind === "request_confirmation" && interaction.sourceRunId
@@ -233,7 +253,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
: null;
const id = `interaction:${interaction.id}`;
entries.push({
ms: handoffAtMs ?? createdAtMs,
// A resolved card settles at its resolution time so it never reads
// above a message written before the user answered (PAP-416); pending
// cards keep the request-time (handoff/createdAt) slot.
ms: interactionThreadAnchorMs(interaction, handoffAtMs ?? createdAtMs),
order: 2,
id,
item: { id, kind: "interaction", interaction },
@@ -388,6 +411,43 @@ export function TaskChatThread(props: TaskChatThreadProps) {
);
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
// Feedback votes keyed by the comment they target (targetType
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
// 👍/👎 state to each agent bubble by its comment id (PAP-413).
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes ?? []) {
if (feedbackVote.targetType !== "issue_comment") continue;
map.set(feedbackVote.targetId, feedbackVote.vote);
}
return map;
}, [feedbackVotes]);
// copy · 👍 · 👎 cluster for an agent bubble's footer line (PAP-413). Human
// and system bubbles get nothing; copy is always available, and the feedback
// buttons render only when the host wired a vote handler.
const renderMessageActions = useCallback(
(item: TaskChatMessageItem) => {
if (item.author !== "agent" || item.optimistic) return null;
return (
<TaskChatBubbleActions
copyText={item.text}
feedback={
onVote
? {
activeVote: feedbackVoteByTargetId.get(item.id) ?? null,
sharingPreference: feedbackDataSharingPreference,
termsUrl: feedbackTermsUrl,
onVote: (vote, options) => onVote(item.id, vote, options),
}
: null
}
/>
);
},
[onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl],
);
const renderInteraction = useCallback(
(item: TaskChatInteractionItem) => (
<TaskChatInteractionCard
@@ -450,6 +510,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
header={threadHeader}
renderInteraction={renderInteraction}
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
scroll={!isMobile}
/>
)}
@@ -1,204 +0,0 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import type { Issue, IssueThreadInteraction, RequestConfirmationInteraction } from "@paperclipai/shared";
import { issuesApi } from "@/api/issues";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { PROPERTIES_PANE_FOOTER_SLOT_ID } from "../PropertiesPanel";
/** The pending confirmation that targets the issue's `plan` document, if any. */
function findPendingPlanConfirmation(
interactions: IssueThreadInteraction[] | undefined,
): RequestConfirmationInteraction | null {
for (const interaction of interactions ?? []) {
if (interaction.kind !== "request_confirmation") continue;
if (interaction.status !== "pending") continue;
const target = interaction.payload.target;
if (target?.type === "issue_document" && target.key === "plan") return interaction;
}
return null;
}
interface IssuePlanConfirmationActionBarProps {
issue: Issue;
/** Inline hosts (mobile sheet) render the bar in place instead of portaling
* it into the pane's pinned footer slot. */
inline?: boolean;
}
/**
* Sticky action bar for the Plan pane (flag: enableTaskChatRedesign): while a
* plan confirmation is pending, its CTAs stay pinned below the pane's scroll
* area so the board can approve or send back the plan without hunting for the
* card in the thread. Mirrors the thread card's semantics (accept/reject labels
* and the optional decline reason) against the same interactions API.
*/
export function IssuePlanConfirmationActionBar({ issue, inline }: IssuePlanConfirmationActionBarProps) {
const queryClient = useQueryClient();
const { data: interactions } = useQuery({
queryKey: queryKeys.issues.interactions(issue.id),
queryFn: () => issuesApi.listInteractions(issue.id),
});
const confirmation = findPendingPlanConfirmation(interactions);
const [footerSlot, setFooterSlot] = useState<HTMLElement | null>(null);
useEffect(() => {
if (inline) {
setFooterSlot(null);
return;
}
setFooterSlot(document.getElementById(PROPERTIES_PANE_FOOTER_SLOT_ID));
}, [inline]);
const [rejecting, setRejecting] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [rejectAttempted, setRejectAttempted] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issue.id) });
};
const accept = useMutation({
mutationFn: (interactionId: string) => issuesApi.acceptInteraction(issue.id, interactionId),
onSuccess: invalidate,
onError: () => setActionError("Couldn't confirm — try again."),
});
const reject = useMutation({
mutationFn: ({ interactionId, reason }: { interactionId: string; reason?: string }) =>
issuesApi.rejectInteraction(issue.id, interactionId, reason),
onSuccess: () => {
setRejecting(false);
setRejectReason("");
invalidate();
},
onError: () => setActionError("Couldn't send that back — try again."),
});
// Interaction changed under us (resolved elsewhere, superseded): reset.
useEffect(() => {
setRejecting(false);
setRejectReason("");
setRejectAttempted(false);
setActionError(null);
}, [confirmation?.id, confirmation?.status]);
if (!confirmation) return null;
const working = accept.isPending ? "accept" : reject.isPending ? "reject" : null;
const rejectRequiresReason = confirmation.payload.rejectRequiresReason === true;
const allowDeclineReason = confirmation.payload.allowDeclineReason !== false;
const trimmedReason = rejectReason.trim();
const reasonInvalid = rejectRequiresReason && trimmedReason.length === 0;
const handleReject = () => {
setRejectAttempted(true);
if (reasonInvalid) return;
setActionError(null);
reject.mutate({ interactionId: confirmation.id, reason: trimmedReason || undefined });
};
const bar = (
<div
data-testid="plan-pane-action-bar"
className="space-y-2 border-t border-border bg-card p-3"
>
{rejecting ? (
<div className="space-y-2">
<Textarea
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
placeholder={
confirmation.payload.declineReasonPlaceholder
?? "Optional: what would you like revised?"
}
aria-invalid={rejectAttempted && reasonInvalid}
className={cn(
"min-h-20 bg-background text-sm",
rejectAttempted && reasonInvalid && "border-rose-500 focus-visible:ring-rose-500/25",
)}
/>
{rejectAttempted && reasonInvalid ? (
<p className="text-xs text-destructive">A decline reason is required.</p>
) : null}
</div>
) : null}
{actionError ? (
<p className="text-xs text-destructive" role="alert">{actionError}</p>
) : null}
<div className="flex flex-wrap items-center justify-end gap-2">
{rejecting ? (
<>
<Button
size="sm"
variant="ghost"
disabled={working !== null}
onClick={() => {
setRejecting(false);
setRejectAttempted(false);
}}
>
Cancel
</Button>
<Button size="sm" variant="outline" disabled={working !== null} onClick={handleReject}>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending back...
</>
) : (
confirmation.payload.rejectLabel ?? "Decline"
)}
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
disabled={working !== null}
onClick={() => {
if (!allowDeclineReason) {
handleReject();
return;
}
setRejectAttempted(false);
setRejecting(true);
}}
>
{working === "reject" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Sending back...
</>
) : (
confirmation.payload.rejectLabel ?? "Decline"
)}
</Button>
)}
<Button
size="sm"
variant="cta"
disabled={working !== null}
onClick={() => {
setActionError(null);
accept.mutate(confirmation.id);
}}
>
{working === "accept" ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Confirming...
</>
) : (
confirmation.payload.acceptLabel ?? "Confirm"
)}
</Button>
</div>
</div>
);
return footerSlot ? createPortal(bar, footerSlot) : bar;
}
@@ -205,6 +205,20 @@ export function IssueProperties({
const hasPlanTab = Boolean(paneTabPlanDocument) || (paneTabAcceptedPlans?.length ?? 0) > 0;
const hasArtifactsTab = (paneTabAttachments?.length ?? 0) > 0;
const [paneTab, setPaneTab] = useState("properties");
// Once a plan document exists, surface it: switch the pane to the Plan tab so
// the write-up is exposed alongside the plan-approval card, instead of leaving
// the user on Properties. Only auto-switch until the user picks a tab by hand —
// after that their choice wins. Ref-guarded so it fires once per mount.
const paneTabUserChosenRef = useRef(false);
const handlePaneTabChange = useCallback((value: string) => {
paneTabUserChosenRef.current = true;
setPaneTab(value);
}, []);
useEffect(() => {
if (hasPlanTab && !paneTabUserChosenRef.current) {
setPaneTab("plans");
}
}, [hasPlanTab]);
const [assigneeOpen, setAssigneeOpen] = useState(false);
const [assigneeSearch, setAssigneeSearch] = useState("");
/** When a run is live, a selection is staged here until the operator confirms
@@ -2544,7 +2558,7 @@ export function IssueProperties({
</TabsList>
);
return (
<Tabs value={activePaneTab} onValueChange={setPaneTab} className="flex min-h-0 flex-col gap-3">
<Tabs value={activePaneTab} onValueChange={handlePaneTabChange} className="flex min-h-0 flex-col gap-3">
{paneHeaderSlot
? createPortal(
// Portals keep React context but break the DOM tree the Tailwind
@@ -5,12 +5,11 @@ import { queryKeys } from "@/lib/queryKeys";
import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection";
import { MarkdownBody } from "@/components/MarkdownBody";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar";
interface IssuePropertiesPlansTabProps {
issue: Issue;
/** True when hosted outside the properties panel (mobile sheet) — the plan
* confirmation bar then renders in place instead of the pane footer slot. */
/** Retained for host parity with the other tabs; the Plans tab no longer
* renders its own approval control (PAP-418). */
inline?: boolean;
}
@@ -23,7 +22,7 @@ interface IssuePropertiesPlansTabProps {
* PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in
* the /dev/task-chat-lab harness).
*/
export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) {
export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) {
const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id);
const { data } = useQuery({
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
@@ -43,9 +42,10 @@ export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansT
return (
<div className="space-y-4 py-2">
{/* Pending plan confirmation: its CTAs pin to the pane's footer slot so
they stay visible while the plan scrolls. */}
{planDocument ? <IssuePlanConfirmationActionBar issue={issue} inline={inline} /> : null}
{/* Plan approval lives in ONE place — the plan confirmation card in the
conversation thread (PAP-418). This tab now only shows the plan itself
and its accepted-revision history, so there is no second surface to
approve from. */}
{planDocument ? (
<section data-testid="issue-plan-document" className="space-y-2">
<div className="text-xs text-muted-foreground">
@@ -167,3 +167,62 @@ describe("TaskChatBubble footer line (round 9)", () => {
expect(plain).toHaveLength(0);
});
});
describe("TaskChatBubble footer actions (PAP-413)", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
});
function render(item: TaskChatMessageItem, opts?: { attachedTurn?: ReactNode; actions?: ReactNode }) {
flushSync(() =>
root!.render(
<ThemeProvider>
<TaskChatBubble item={item} attachedTurn={opts?.attachedTurn} actions={opts?.actions} />
</ThemeProvider>,
),
);
}
const actions = <div data-testid="fake-actions">actions</div>;
it("hands actions to the attached turn (not this wrapper) so they ride the summary row", () => {
render(
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
{ attachedTurn: <div data-testid="fake-attached-turn">2:34 PM · Worked</div>, actions },
);
const slot = container.querySelector('[data-testid="task-chat-bubble-attached-turn"]');
expect(slot).not.toBeNull();
// The turn owns the footer line; the bubble no longer places actions beside
// it (they ride the turn's `leading` slot, anchored to the summary line).
expect(slot?.querySelector('[data-testid="fake-attached-turn"]')).not.toBeNull();
expect(slot?.querySelector('[data-testid="fake-actions"]')).toBeNull();
});
it("leads a runless agent reply with actions, timestamp trailing", () => {
render(
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
{ actions },
);
expect(container.querySelector('[data-testid="fake-actions"]')).not.toBeNull();
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
expect(stamp).not.toBeNull();
});
it("omits the actions row entirely when none are supplied (human bubble)", () => {
render({ id: "m1", kind: "message", author: "human", text: "Hi", timestamp: "2:34 PM" });
expect(container.querySelector('[data-testid="fake-actions"]')).toBeNull();
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
expect(stamp).not.toBeNull();
});
});
+23 -2
View File
@@ -25,6 +25,13 @@ interface TaskChatBubbleProps {
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
*/
attachedTurn?: ReactNode;
/**
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
* Rendered here only for a runless reply (leading the bare timestamp); when
* an attached turn is present it owns these via its `leading` slot instead,
* so this bubble skips them. Human/system bubbles pass nothing.
*/
actions?: ReactNode;
}
function initialsForName(name: string) {
@@ -41,7 +48,7 @@ function initialsForName(name: string) {
* bubble with an avatar author header (the agent's assigned icon + name · mode
* chip); system notices are centered and recede.
*/
export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) {
export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) {
if (item.interstitial) {
// Interstitial updates are ephemeral (PAP-361): while streaming the text
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
@@ -136,10 +143,24 @@ export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) {
</span>
) : attachedTurn ? (
// The settled turn takes over the footer line: timestamp + "✓ Worked"
// summary, always visible; expanding stretches beneath the bubble.
// summary, always visible; expanding stretches beneath the bubble. The
// copy/👍/👎 actions (PAP-413) ride the turn's summary row via its
// `leading` slot — not this wrapper — so they stay anchored to the
// summary line when the tool history expands beneath it.
<div className="self-stretch" data-testid="task-chat-bubble-attached-turn">
{attachedTurn}
</div>
) : actions ? (
// Agent reply without run activity: the actions still lead the footer,
// with the always-visible timestamp trailing (PAP-413).
<div className="flex items-center gap-1">
{actions}
{item.timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.timestamp}
</span>
) : null}
</div>
) : item.timestamp ? (
// Timestamps are always visible (round 9) — no longer hover-revealed.
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
@@ -0,0 +1,66 @@
import { useState } from "react";
import type {
FeedbackDataSharingPreference,
FeedbackVoteValue,
} from "@paperclipai/shared";
import { copyTextToClipboard } from "@/lib/clipboard";
import { IssueChatFeedbackButtons } from "@/components/AgentBubbleActionRow";
import { Check, Copy } from "lucide-react";
/** Feedback-vote wiring for an agent bubble, resolved per comment by the host. */
export interface TaskChatBubbleFeedback {
activeVote: FeedbackVoteValue | null;
sharingPreference: FeedbackDataSharingPreference;
termsUrl: string | null;
onVote: (
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
}
/**
* Compact copy · 👍 · 👎 cluster prepended to an agent bubble's footer line
* (PAP-413), leading the "✓ Worked · …" turn summary (or the bare timestamp
* when the reply had no run activity). It reuses the shared
* {@link IssueChatFeedbackButtons} so the redesigned task thread speaks the
* same feedback language as the conference room's {@link AgentBubbleActionRow};
* the timestamp stays owned by the summary/bubble, so it is not duplicated here.
*/
export function TaskChatBubbleActions({
copyText,
feedback,
}: {
copyText: string;
feedback?: TaskChatBubbleFeedback | null;
}) {
const [copied, setCopied] = useState(false);
return (
<div className="flex items-center gap-0.5" data-testid="task-chat-bubble-actions">
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
void copyTextToClipboard(copyText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
{feedback ? (
<IssueChatFeedbackButtons
activeVote={feedback.activeVote}
sharingPreference={feedback.sharingPreference}
termsUrl={feedback.termsUrl}
onVote={feedback.onVote}
/>
) : null}
</div>
);
}
@@ -89,12 +89,12 @@ describe("TaskChatInteractionCard", () => {
);
});
const buttons = Array.from(container.querySelectorAll("button"));
const confirm = buttons.find((button) => button.textContent === "Confirm");
const decline = buttons.find((button) => button.textContent === "Decline");
expect(confirm).not.toBeUndefined();
expect(decline).not.toBeUndefined();
const row = confirm?.parentElement;
expect(row).toBe(decline?.parentElement);
const approve = buttons.find((button) => button.textContent === "Approve");
const reject = buttons.find((button) => button.textContent === "Reject");
expect(approve).not.toBeUndefined();
expect(reject).not.toBeUndefined();
const row = approve?.parentElement;
expect(row).toBe(reject?.parentElement);
expect(row?.className).toContain("flex-row-reverse");
});
@@ -1,6 +1,10 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { TaskChatInteractionItem, TaskChatItem } from "./task-chat-model";
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatMessageItem,
} from "./task-chat-model";
import { TaskChatTurn } from "./TaskChatTurn";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatMarker } from "./TaskChatMarker";
@@ -30,6 +34,12 @@ interface TaskChatThreadViewProps {
* nothing without it.
*/
renderBrief?: () => ReactNode;
/**
* Renders the copy/👍/👎 action cluster prepended to an agent bubble's footer
* line (PAP-413). The live thread binds it to the feedback-vote API; harness
* fixtures omit it and the bubbles render actionless.
*/
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
@@ -40,23 +50,33 @@ function renderItem(
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
renderBrief?: () => ReactNode,
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode,
) {
switch (item.kind) {
case "message":
case "message": {
// Compute the actions once: the bubble renders them for a runless reply
// (footer = actions + timestamp), while an attached turn hands them to
// TaskChatTurn's `leading` slot so they ride the summary line and stay
// put when the tool history expands (PAP-413). The two paths are mutually
// exclusive at runtime, so only one host ever mounts the node.
const actions = renderMessageActions?.(item);
return (
<TaskChatBubble
item={item}
actions={actions}
attachedTurn={
item.attachedTurn ? (
<TaskChatTurn
item={item.attachedTurn}
timestampPrefix={item.timestamp}
leading={actions}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
) : undefined
}
/>
);
}
case "marker":
return <TaskChatMarker item={item} />;
case "thinking":
@@ -107,6 +127,7 @@ export function TaskChatThreadView({
onApprovalDecision,
renderInteraction,
renderBrief,
renderMessageActions,
className,
scroll = true,
}: TaskChatThreadViewProps) {
@@ -118,7 +139,9 @@ export function TaskChatThreadView({
</div>
) : null}
{items.map((item) => (
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction, renderBrief)}</div>
<div key={item.id}>
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)}
</div>
))}
</div>
);
@@ -226,6 +226,28 @@ describe("TaskChatTurn", () => {
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("rides the `leading` slot on the header row, anchored above the fold (PAP-413)", () => {
flushSync(() => {
root.render(
<TaskChatTurn
item={SETTLED}
leading={<div data-testid="turn-lead">actions</div>}
renderChild={(c) => <span>{c.id}</span>}
/>,
);
});
const lead = container.querySelector('[data-testid="turn-lead"]');
expect(lead).not.toBeNull();
// It sits beside the summary button on the header row — NOT inside the
// expandable fold — so expanding the tool history can't drag it down.
expect(fold()?.contains(lead!)).toBe(false);
expect(summaryBtn()).not.toBeNull();
// Expanding still works and the leading slot stays put outside the fold.
flushSync(() => summaryBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(fold()?.contains(lead!)).toBe(false);
});
it("leads the settled summary with the bubble timestamp when attached (round 9)", () => {
flushSync(() => {
root.render(
+52 -33
View File
@@ -13,6 +13,13 @@ interface TaskChatTurnProps {
* always visible, in the slot the hover-only timestamp used to occupy.
*/
timestampPrefix?: string;
/**
* Content rendered on the header row, leading the summary line (PAP-413: the
* copy/👍/👎 action cluster). It sits beside the summary button — NOT inside
* the expandable fold — so it stays anchored to the summary line when the
* tool history expands beneath it, instead of drifting to the fold's center.
*/
leading?: ReactNode;
}
/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
@@ -50,7 +57,7 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
* `liveStatus` (harness fixtures) renders its children expanded with no header
* and folds when it settles.
*/
export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTurnProps) {
export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: TaskChatTurnProps) {
const parentRow = !item.settled && item.liveStatus != null;
// Parent-row live turns and settled turns start as their one-line header;
// only the headerless legacy live turn starts expanded.
@@ -73,40 +80,52 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTur
const folded = (item.settled || parentRow) && !open;
const SummaryIcon = item.summary.failed ? X : Check;
const header = item.settled ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="task-chat-turn-summary"
>
{timestampPrefix ? (
<>
<span className="text-(length:--text-micro)">{timestampPrefix}</span>
<span aria-hidden className="text-(length:--text-micro)">·</span>
</>
) : null}
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
<span className="font-mono text-(length:--text-micro)">{turnSummaryMetrics(item.summary)}</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
</button>
) : parentRow ? (
// The pill renders the expand button itself, wrapped around only the
// gerund status line — the interstitial row above stays outside the
// hover/click target (PAP-376). Without activity there is no
// chevron/button.
<TaskChatStatusPill
item={item.liveStatus!}
chevronOpen={expandable ? open : undefined}
onToggle={expandable ? () => setOpen((o) => !o) : undefined}
/>
) : null;
return (
<div data-testid="task-chat-turn" data-settled={item.settled ? "true" : "false"}>
{item.settled ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="task-chat-turn-summary"
>
{timestampPrefix ? (
<>
<span className="text-(length:--text-micro)">{timestampPrefix}</span>
<span aria-hidden className="text-(length:--text-micro)">·</span>
</>
) : null}
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
<span className="font-mono text-(length:--text-micro)">{turnSummaryMetrics(item.summary)}</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
</button>
) : parentRow ? (
// The pill renders the expand button itself, wrapped around only the
// gerund status line — the interstitial row above stays outside the
// hover/click target (PAP-376). Without activity there is no
// chevron/button.
<TaskChatStatusPill
item={item.liveStatus!}
chevronOpen={expandable ? open : undefined}
onToggle={expandable ? () => setOpen((o) => !o) : undefined}
/>
) : null}
{leading ? (
// Actions ride the header row (items-center matches them to the summary
// line), and the fold below is a separate sibling — so expanding the
// tool history never moves the actions off the summary line (PAP-413).
<div className="flex items-center gap-1">
{leading}
{header}
</div>
) : (
header
)}
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
<div>
<div className="flex flex-col gap-2 pt-1">
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import type {
RequestConfirmationInteraction,
RequestConfirmationResult,
} from "@/lib/issue-thread-interactions";
import {
interactionThreadAnchorMs,
isSuppressedThreadInteraction,
} from "./interaction-thread-order";
function confirmation(
overrides: Partial<RequestConfirmationInteraction> = {},
): RequestConfirmationInteraction {
return {
id: "confirmation-1",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
title: "Approve the plan",
summary: null,
status: "pending",
continuationPolicy: "wake_assignee",
resolverPolicy: "board_only",
requestedResolverPolicy: "board_only",
effectiveResolverPolicy: "board_only",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:00:00.000Z"),
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
resolvedAt: null,
payload: { version: 1, prompt: "Approve?" },
result: null,
...overrides,
};
}
function result(outcome: RequestConfirmationResult["outcome"]): RequestConfirmationResult {
return { version: 1, outcome };
}
const CREATED_MS = new Date("2026-04-06T12:00:00.000Z").getTime();
const RESOLVED_MS = new Date("2026-04-06T12:05:00.000Z").getTime();
describe("isSuppressedThreadInteraction", () => {
it("hides withdrawn confirmations", () => {
expect(
isSuppressedThreadInteraction(
confirmation({ status: "cancelled", result: result("withdrawn") }),
),
).toBe(true);
});
it("hides confirmations superseded by a comment or a newer request", () => {
expect(
isSuppressedThreadInteraction(
confirmation({ status: "expired", result: result("superseded_by_comment") }),
),
).toBe(true);
expect(
isSuppressedThreadInteraction(
confirmation({ status: "expired", result: result("superseded_by_newer_request") }),
),
).toBe(true);
});
it("keeps accepted, rejected, and still-pending confirmations", () => {
expect(isSuppressedThreadInteraction(confirmation())).toBe(false);
expect(
isSuppressedThreadInteraction(
confirmation({ status: "accepted", result: result("accepted") }),
),
).toBe(false);
expect(
isSuppressedThreadInteraction(
confirmation({ status: "rejected", result: result("rejected") }),
),
).toBe(false);
});
it("only suppresses confirmation-family kinds, not questions or suggestions", () => {
const superseded = { version: 1, outcome: "superseded_by_comment" } as never;
expect(
isSuppressedThreadInteraction(
confirmation({ kind: "ask_user_questions", result: superseded } as never),
),
).toBe(false);
expect(
isSuppressedThreadInteraction(
confirmation({ kind: "suggest_tasks", result: superseded } as never),
),
).toBe(false);
});
});
describe("interactionThreadAnchorMs", () => {
it("keeps a pending card at its request-time fallback", () => {
expect(interactionThreadAnchorMs(confirmation(), CREATED_MS)).toBe(CREATED_MS);
});
it("re-anchors a resolved card to its resolution time", () => {
const resolved = confirmation({
status: "accepted",
result: result("accepted"),
resolvedAt: new Date(RESOLVED_MS),
});
expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(RESOLVED_MS);
});
it("never drags a resolved card above its own request slot", () => {
// Clock skew: resolvedAt reads earlier than the handoff/created fallback.
const resolved = confirmation({
status: "accepted",
result: result("accepted"),
resolvedAt: new Date(CREATED_MS - 60_000),
});
expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(CREATED_MS);
});
it("falls back when a resolved card has no resolvedAt yet", () => {
const resolved = confirmation({ status: "accepted", result: result("accepted") });
expect(interactionThreadAnchorMs(resolved, CREATED_MS)).toBe(CREATED_MS);
});
});
@@ -0,0 +1,79 @@
import type { IssueThreadInteraction } from "@/lib/issue-thread-interactions";
/**
* Thread-ordering + visibility rules for issue-thread interaction cards
* (PAP-416, Phase A of PAP-412).
*
* Two problems this fixes:
*
* 1. Cards were pinned to `createdAt`, but the user answers them later and the
* agent's follow-up lands later still — so a card you just resolved could
* sit ABOVE a message written before you answered, reading backwards. We
* settle a RESOLVED card at its resolution time (`resolvedAt`) so it never
* floats above an earlier message. Pending cards stay where they were asked.
*
* 2. Withdrawn / superseded confirmation cards lingered in the thread, stacking
* a dead card above the accepted one. We suppress those from the backbone
* entirely — a retracted or superseded confirmation is not a call to action
* and reads as noise next to the outcome that replaced it.
*/
// The confirmation-family kinds: cards that are a call to act on a proposal.
// ask_user_questions / suggest_tasks keep their superseded notices (legacy
// parity) — only confirmations get fully hidden when withdrawn/superseded.
const CONFIRMATION_KINDS = new Set([
"request_confirmation",
"request_checkbox_confirmation",
"request_item_verdicts",
]);
// Terminal outcomes that mean "this confirmation was retracted or replaced": it
// never got an accept/reject decision the reader needs to see. `withdrawn` is an
// agent/board retraction; the two `superseded_by_*` outcomes fire when a later
// comment or a fresh request took its place.
const SUPPRESSED_CONFIRMATION_OUTCOMES = new Set([
"withdrawn",
"superseded_by_comment",
"superseded_by_newer_request",
]);
function interactionOutcome(interaction: IssueThreadInteraction): string | null {
const result = interaction.result;
return result && "outcome" in result && typeof result.outcome === "string"
? result.outcome
: null;
}
/**
* A confirmation card that was withdrawn or superseded — hide it from the thread
* so it never stacks above the confirmation that replaced it.
*/
export function isSuppressedThreadInteraction(interaction: IssueThreadInteraction): boolean {
if (!CONFIRMATION_KINDS.has(interaction.kind)) return false;
const outcome = interactionOutcome(interaction);
return outcome != null && SUPPRESSED_CONFIRMATION_OUTCOMES.has(outcome);
}
function toMs(value: Date | string | null | undefined): number {
if (!value) return 0;
const ms = new Date(value).getTime();
return Number.isNaN(ms) ? 0 : ms;
}
/**
* The chronological slot for an interaction card.
*
* `fallbackMs` is the caller's existing anchor (the same-run handoff shift when
* present, else `createdAt`). A resolved card re-anchors to its resolution time
* so it lands next to the answer, never above an earlier message; we take the
* later of the two so clock skew can't drag it back above its own request. A
* still-pending card keeps the request-time slot.
*/
export function interactionThreadAnchorMs(
interaction: IssueThreadInteraction,
fallbackMs: number,
): number {
if (interaction.status === "pending") return fallbackMs;
const resolvedMs = toMs(interaction.resolvedAt);
return resolvedMs > 0 ? Math.max(resolvedMs, fallbackMs) : fallbackMs;
}
@@ -287,7 +287,7 @@ function createRequestCheckboxConfirmationInteraction(
minSelected: 0,
maxSelected: null,
acceptLabel: "Delete selected",
rejectLabel: "Request changes",
rejectLabel: "Reject",
rejectRequiresReason: false,
},
result: null,
@@ -358,6 +358,45 @@ export const rejectedSuggestedTasksInteraction = createSuggestTasksInteraction({
export const pendingAskUserQuestionsInteraction = createAskUserQuestionsInteraction({});
/**
* A pending question whose last option is a first-class free-text choice
* (`freeText: true`). Selecting it reveals an inline text field instead of
* acting as a dead radio, and the built-in "Other" link is suppressed
* (PAP-419).
*/
export const pendingAskUserQuestionsWithFreeTextOption = createAskUserQuestionsInteraction({
id: "interaction-questions-freetext",
payload: {
version: 1,
title: "How should we name the new surface?",
submitLabel: "Send answers",
questions: [
{
id: "surface-name",
prompt: "What should we call the new surface?",
selectionMode: "single",
required: true,
options: [
{
id: "keep-tasks",
label: "Keep calling it Tasks",
},
{
id: "rename-work",
label: "Rename it Work",
},
{
id: "describe-it",
label: "I'll describe it",
description: "Tell us the exact name you have in mind.",
freeText: true,
},
],
},
],
},
});
export const answeredAskUserQuestionsInteraction = createAskUserQuestionsInteraction({
id: "interaction-questions-answered",
status: "answered",
@@ -459,7 +498,7 @@ export const planApprovalAcceptedRequestConfirmationInteraction = createRequestC
version: 1,
prompt: "Approve the plan and let the responsible start implementation?",
acceptLabel: "Approve plan",
rejectLabel: "Request changes",
rejectLabel: "Reject",
rejectRequiresReason: true,
declineReasonPlaceholder: "Optional: what would you like revised?",
target: {
@@ -882,7 +921,7 @@ export const manyOptionsRequestCheckboxConfirmationInteraction =
minSelected: 0,
maxSelected: null,
acceptLabel: "Archive selected",
rejectLabel: "Request changes",
rejectLabel: "Reject",
rejectRequiresReason: false,
},
});
@@ -928,7 +967,7 @@ export const staleTargetRequestCheckboxConfirmationInteraction =
version: 1,
prompt: "Check the draft documents you want me to delete.",
acceptLabel: "Delete selected",
rejectLabel: "Request changes",
rejectLabel: "Reject",
options: [
{ id: "draft-march-report", label: "Old draft report" },
{ id: "draft-spec-v1", label: "Spec v1 (superseded)" },
+1 -1
View File
@@ -42,7 +42,7 @@ export function composeCeoInstructions(input: ComposeCeoInstructionsInput): stri
return `# Role
You are the lead agent for ${companyName}. You report to the person who set up this team — they may be a solo founder, a manager inside a larger org, or one of several people each running their own team of agents. Most people call this role CEO — that's fine, and it's your default name.
You are the Paperclip agent for ${companyName}. You report to the person who set up this team — they may be a solo founder, a manager inside a larger org, or one of several people each running their own team of agents. Work as their lead agent: understand what they're trying to accomplish, propose a plan, and coordinate the work.
Work with the user conversationally. Propose, don't decide. When the user asks for something concrete (a brief, a hiring plan, a roadmap, a pitch), produce a real artifact — save it as a document on the relevant task so they can review and approve.
+70
View File
@@ -10,6 +10,8 @@ import {
type IssueChatLinkedRun,
} from "./issue-chat-messages";
import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "./issue-thread-interactions";
@@ -681,6 +683,74 @@ describe("buildIssueChatMessages", () => {
});
});
it("drops degenerate ask_user_questions interactions so they leave no empty slot (PAP-424)", () => {
function askInteraction(
id: string,
questions: AskUserQuestionsQuestion[],
): AskUserQuestionsInteraction {
return {
id,
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
title: null,
summary: null,
status: "pending",
continuationPolicy: "wake_assignee",
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:02:00.000Z"),
updatedAt: new Date("2026-04-06T12:02:00.000Z"),
resolvedAt: null,
resolverPolicy: "board_only",
requestedResolverPolicy: "board_only",
effectiveResolverPolicy: "board_only",
payload: { version: 1, questions },
result: null,
} as AskUserQuestionsInteraction;
}
const messages = buildIssueChatMessages({
comments: [
createComment({
id: "comment-1",
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
}),
],
interactions: [
// A truly unanswerable card (no options, no free-text) — must be
// filtered out entirely.
askInteraction("interaction-degenerate", [
{ id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
]),
// A legitimate yes/no question survives.
askInteraction("interaction-legit", [
{
id: "q1",
prompt: "Ship it?",
selectionMode: "single",
options: [
{ id: "yes", label: "Yes" },
{ id: "no", label: "No" },
],
},
]),
],
timelineEvents: [],
linkedRuns: [],
liveRuns: [],
currentUserId: "user-1",
});
const ids = messages.map((message) => `${message.role}:${message.id}`);
// The legit card is present; the degenerate one leaves no message at all.
expect(ids).toEqual(["user:comment-1", "system:interaction:interaction-legit"]);
expect(ids).not.toContain("system:interaction:interaction-degenerate");
});
it("preserves ephemeral active-run status metadata for rendering", () => {
const activeRun: ActiveRunForIssue = {
id: "run-active-1",
+6
View File
@@ -13,6 +13,7 @@ import { formatAssigneeUserLabel } from "./assignees";
import { isOperatorInterruptedRun } from "./interrupt-handoff";
import {
buildIssueThreadInteractionSummary,
isDegenerateAskUserQuestions,
type IssueThreadInteraction,
} from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events";
@@ -1086,6 +1087,11 @@ export function buildIssueChatMessages(args: {
}
for (const interaction of sortByCreated(interactions)) {
// A degenerate `ask_user_questions` card (e.g. the onboarding `Test / A`
// placeholder) is never rendered by IssueThreadInteractionCard — skip it here
// so it leaves no empty message slot in the thread (PAP-424, plan from
// PAP-420).
if (isDegenerateAskUserQuestions(interaction)) continue;
const createdAtMs = toTimestamp(interaction.createdAt);
const handoffAtMs = interaction.kind === "request_confirmation" && interaction.sourceRunId
? latestSameRunHandoffTimestamp({
+137 -1
View File
@@ -8,9 +8,15 @@ import {
getItemVerdictProgress,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels,
isDegenerateAskUserQuestions,
normalizeRequestConfirmationTargetHref,
} from "./issue-thread-interactions";
import type { RequestItemVerdictsInteraction } from "./issue-thread-interactions";
import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
IssueThreadInteraction,
RequestItemVerdictsInteraction,
} from "./issue-thread-interactions";
const resolverPolicyFields = {
resolverPolicy: "board_only",
@@ -373,3 +379,133 @@ describe("per-item verdict helpers", () => {
}))).toBe("Verdicts expired after comment");
});
});
describe("isDegenerateAskUserQuestions", () => {
function askInteraction(
questions: AskUserQuestionsQuestion[],
): AskUserQuestionsInteraction {
return {
id: "interaction-ask",
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
status: "pending",
continuationPolicy: "wake_assignee",
...resolverPolicyFields,
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:00:00.000Z",
payload: { version: 1, questions },
} as AskUserQuestionsInteraction;
}
it("flags a card with zero questions", () => {
expect(isDegenerateAskUserQuestions(askInteraction([]))).toBe(true);
});
it("flags a question with a blank / whitespace-only prompt", () => {
expect(isDegenerateAskUserQuestions(askInteraction([
{
id: "q1",
prompt: " ",
selectionMode: "single",
options: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
],
},
]))).toBe(true);
});
it("keeps a single fixed-option question: it is answerable (select + submit)", () => {
// A lone fixed option is still resolvable — the user selects it and submits —
// so it must render. Hiding it would strand a pending interaction the
// assignee is waiting on.
expect(isDegenerateAskUserQuestions(askInteraction([
{
id: "q1",
prompt: "Test",
selectionMode: "single",
options: [{ id: "a", label: "A" }],
},
]))).toBe(false);
});
it("flags a question with no options at all", () => {
expect(isDegenerateAskUserQuestions(askInteraction([
{ id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
]))).toBe(true);
});
it("keeps a legitimate yes/no question (2 options)", () => {
expect(isDegenerateAskUserQuestions(askInteraction([
{
id: "q1",
prompt: "Ship it?",
selectionMode: "single",
options: [
{ id: "yes", label: "Yes" },
{ id: "no", label: "No" },
],
},
]))).toBe(false);
});
it("keeps a multi-select question (>= 2 options)", () => {
expect(isDegenerateAskUserQuestions(askInteraction([
{
id: "q1",
prompt: "Pick platforms",
selectionMode: "multi",
options: [
{ id: "web", label: "Web" },
{ id: "ios", label: "iOS" },
{ id: "android", label: "Android" },
],
},
]))).toBe(false);
});
it("keeps a question whose only choice is a first-class free-text option (PAP-419)", () => {
expect(isDegenerateAskUserQuestions(askInteraction([
{
id: "q1",
prompt: "Describe your goal",
selectionMode: "single",
options: [{ id: "other", label: "I'll describe it", freeText: true }],
},
]))).toBe(false);
});
it("is degenerate only when EVERY question is degenerate", () => {
// One real question keeps the whole card, even next to a degenerate one
// (here: a question with no options and no free-text).
expect(isDegenerateAskUserQuestions(askInteraction([
{ id: "q1", prompt: "Anything?", selectionMode: "single", options: [] },
{
id: "q2",
prompt: "Ship it?",
selectionMode: "single",
options: [
{ id: "yes", label: "Yes" },
{ id: "no", label: "No" },
],
},
]))).toBe(false);
});
it("ignores non-ask_user_questions interactions", () => {
const confirmation = {
id: "interaction-confirm",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
...resolverPolicyFields,
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:00:00.000Z",
payload: { version: 1 },
} as unknown as IssueThreadInteraction;
expect(isDegenerateAskUserQuestions(confirmation)).toBe(false);
});
});
+47
View File
@@ -304,3 +304,50 @@ export function getQuestionAnswerLabels(args: {
if (otherText) labels.push(`Other: ${otherText}`);
return labels;
}
/**
* A single `ask_user_questions` question is degenerate when it offers *no way at
* all* to answer — hiding it therefore strands nothing the user could have
* resolved. Structural only, no semantic guessing about the wording:
*
* - its `prompt` is empty / whitespace-only, OR
* - it presents nothing to respond to: no first-class free-text option (the
* PAP-419 `freeText` flag) AND no selectable fixed option.
*
* A question with even a single fixed option is answerable (the user selects it
* and submits), so it is NOT degenerate and must keep rendering — otherwise a
* hidden-but-pending interaction would strand the assignee waiting on a response
* that can never arrive. Legitimate shapes all pass: yes/no, multi-select,
* free-text, and single-option acknowledgements.
*/
function isDegenerateAskUserQuestion(question: AskUserQuestionsQuestion): boolean {
if (question.prompt.trim().length === 0) return true;
const hasFreeTextOption = question.options.some((option) => option.freeText === true);
if (hasFreeTextOption) return false;
const selectableOptionCount = question.options.filter(
(option) => option.freeText !== true,
).length;
return selectableOptionCount === 0;
}
/**
* Structural render guard for `ask_user_questions` cards. A card is degenerate —
* safe to never draw because it strands nothing the user could resolve — when it
* offers no answerable question: it has zero questions, OR every question is
* degenerate (see {@link isDegenerateAskUserQuestion}: blank prompt, or no
* option and no free-text). A card with any answerable question — including a
* single fixed option — always renders.
*
* UI-only: the interaction is still created and stored server-side (audit
* intact); callers use this purely to decide whether to draw the card. Returns
* false for any other interaction kind — the guard is scoped to
* `ask_user_questions`.
*/
export function isDegenerateAskUserQuestions(
interaction: IssueThreadInteraction,
): boolean {
if (interaction.kind !== "ask_user_questions") return false;
const questions = interaction.payload.questions;
if (questions.length === 0) return true;
return questions.every(isDegenerateAskUserQuestion);
}
+2
View File
@@ -121,6 +121,7 @@ describe("onboarding launch payloads", () => {
projectId: "project-1",
goalId: "goal-1",
status: "todo",
onboardingFirstTask: true,
});
});
@@ -143,6 +144,7 @@ describe("onboarding launch payloads", () => {
assigneeAgentId: "agent-1",
projectId: "project-1",
status: "todo",
onboardingFirstTask: true,
});
});
});
+3
View File
@@ -61,5 +61,8 @@ export function buildOnboardingIssuePayload(input: {
projectId: input.projectId,
...(input.goalId ? { goalId: input.goalId } : {}),
status: "todo" as const,
// Marks the single onboarding first task so the server seeds an agent
// greeting and the task-detail view suppresses the seeded-description bubble.
onboardingFirstTask: true,
};
}
+82
View File
@@ -2,6 +2,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent, Issue, IssueAttachment, IssueComment, IssueTreeControlPreview, IssueTreeHold, IssueWorkProduct } from "@paperclipai/shared";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import type { AnchorHTMLAttributes, ButtonHTMLAttributes, ReactNode } from "react";
import { NavigationType } from "react-router-dom";
import { flushSync } from "react-dom";
@@ -39,6 +40,7 @@ const mockIssuesApi = vi.hoisted(() => ({
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
upsertDocument: vi.fn(),
getDocument: vi.fn(),
}));
const mockActivityApi = vi.hoisted(() => ({
@@ -1029,6 +1031,9 @@ describe("IssueDetail", () => {
enableExternalObjects: false,
});
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
mockIssuesApi.getDocument.mockResolvedValue(null);
mockOpenPanel.mockClear();
mockClosePanel.mockClear();
mockIssuesListRender.mockClear();
mockIssueChatThreadRender.mockClear();
mockImageGalleryRender.mockClear();
@@ -1654,6 +1659,83 @@ describe("IssueDetail", () => {
expect(mockIssuesApi.listAcceptedPlanDecompositions).toHaveBeenCalledWith("issue-1");
});
it("hides the properties sidebar on the first onboarding task until a plan document exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableTaskChatRedesign: true,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }),
);
// No plan yet: the hook's 404 resolves to null.
mockIssuesApi.getDocument.mockResolvedValue(null);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
// Panel content is withheld — openPanel is never invoked, so the sidebar
// stays hidden without touching the persisted panelVisible preference.
expect(mockOpenPanel).not.toHaveBeenCalled();
expect(mockClosePanel).toHaveBeenCalled();
});
it("reveals the properties sidebar on the first onboarding task once a plan document exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableTaskChatRedesign: true,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }),
);
mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
});
it("shows the properties sidebar immediately on a non-first task", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableTaskChatRedesign: true,
});
mockIssuesApi.get.mockResolvedValue(createIssue({ originKind: "manual" }));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
});
it("renders sibling previous and next navigation at the chat footer", async () => {
const issue = createIssue({
id: "issue-2",
+22 -2
View File
@@ -125,6 +125,7 @@ import { IssueProperties } from "../components/IssueProperties";
import { PauseAffectsSummaryView } from "../components/interrupt-handoff/InterruptHandoffViews";
import { computePauseAffectsSummary } from "../lib/interrupt-handoff";
import { useIssueExternalObjects } from "../hooks/useIssueExternalObjects";
import { useIssuePlanDocument } from "../hooks/useIssuePlanDocument";
import { IssueRunLedger } from "../components/IssueRunLedger";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import type { MentionOption } from "../components/MarkdownEditor";
@@ -200,6 +201,7 @@ import {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type ActivityEvent,
@@ -1991,6 +1993,19 @@ export function IssueDetail() {
() => childIssues,
[issuePanelKey],
);
// Onboarding first task only: hide the Properties sidebar until a plan exists,
// then reveal it already on the Plan tab. We gate the panel *mount* (withhold
// the panel content) rather than flipping the global `panelVisible` preference
// — that preference persists to localStorage and would leak "hidden" into every
// other task. Every non-first task has originKind !== onboarding_first_task, so
// `suppressPanelForFirstTask` stays false and behavior is unchanged.
const isOnboardingFirstTask =
taskChatShellEnabled &&
issue?.originKind === ONBOARDING_FIRST_TASK_ORIGIN_KIND;
const { data: firstTaskPlanDoc } = useIssuePlanDocument(
isOnboardingFirstTask ? issue?.id : null,
);
const suppressPanelForFirstTask = isOnboardingFirstTask && !firstTaskPlanDoc;
const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length);
const siblingNavigation = useMemo(
() => issue && !childIssuesLoading && !siblingIssuesLoading && !siblingIssuesError
@@ -3265,7 +3280,7 @@ export function IssueDetail() {
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!panelIssue) {
if (!panelIssue || suppressPanelForFirstTask) {
closePanel();
return;
}
@@ -3293,6 +3308,7 @@ export function IssueDetail() {
openPanel,
panelChildIssues,
panelIssue,
suppressPanelForFirstTask,
resolvedHasActiveRun,
checkIssueMonitorNow.isPending,
checkIssueMonitorNow.mutate,
@@ -4918,7 +4934,11 @@ export function IssueDetail() {
<IssueDetailChatTab
threadHeader={taskChatThreadHeader}
issueBrief={
taskChatShellEnabled
// Suppress the seeded-description bubble for the onboarding first
// task: its description is agent instructions, not something the
// user typed. The user lands on a seeded agent greeting instead.
taskChatShellEnabled &&
issue.originKind !== ONBOARDING_FIRST_TASK_ORIGIN_KIND
? {
description: issue.description ?? "",
author: issue.createdByAgentId ? "agent" : "human",
+26 -2
View File
@@ -8,8 +8,32 @@ import {
import { buildScenario } from "@/components/task-chat/task-chat-fixtures";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView";
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
import { TweakPanel } from "@/components/task-chat/TweakPanel";
import type { TaskChatItem } from "@/components/task-chat/task-chat-model";
import type {
TaskChatItem,
TaskChatMessageItem,
} from "@/components/task-chat/task-chat-model";
/**
* Demo binding for the agent-bubble copy · 👍 · 👎 cluster (PAP-413). The live
* thread wires these to the feedback-vote API; here the votes no-op so the
* harness can show the footer without a control plane.
*/
function labMessageActions(item: TaskChatMessageItem) {
if (item.author !== "agent" || item.optimistic) return null;
return (
<TaskChatBubbleActions
copyText={item.text}
feedback={{
activeVote: null,
sharingPreference: "allowed",
termsUrl: null,
onVote: async () => {},
}}
/>
);
}
/** Replay ticks of selfTalk gap between scripted interstitial updates (~0.7s at 1×). */
const SELF_TALK_GAP_TICKS = 30;
@@ -210,7 +234,7 @@ export function TaskChatLab() {
</div>
) : (
<div className="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col">
<TaskChatThreadView items={items} />
<TaskChatThreadView items={items} renderMessageActions={labMessageActions} />
</div>
)}
</div>