feat: make chat-style tasks the default experience (#11101)

This commit is contained in:
scotttong 2026-08-11 09:06:21 -07:00 committed by GitHub
parent 2da6a248c3
commit 815e49bb7c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 3858 additions and 1497 deletions

View File

@ -887,6 +887,9 @@ describe("renderPaperclipWakePrompt", () => {
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).not.toContain(
"for request_confirmation this resumes only after acceptance",
);
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
"Never create probe or throwaway issue-thread interactions to discover the interactions API shape or your permissions",
);
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("confirmation:{issueId}:plan:{revisionId}");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain("Wait for acceptance before creating implementation subtasks");
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(

View File

@ -170,6 +170,7 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
"- Create child issues directly when you know what needs to be done; use issue-thread interactions when the board/user must choose suggested tasks, answer structured questions, or confirm a proposal.",
"- Use `PAPERCLIP_SCRATCH_DIR` / `PAPERCLIP_RUN_SCRATCH_DIR` for temporary scratch files instead of ad hoc `/tmp` paths; Paperclip removes that run-owned directory after the run ends.",
"- To ask for that input, create an interaction on the current issue with POST /api/issues/{issueId}/interactions using kind suggest_tasks, ask_user_questions, or request_confirmation. Use continuationPolicy wake_assignee when you need to resume after a response (it wakes on acceptance and rejection alike; only expiry does not wake); use wake_assignee_on_accept when you want to resume only after acceptance.",
"- Never create probe or throwaway issue-thread interactions to discover the interactions API shape or your permissions; schema discovery goes through the OpenAPI spec and explicit validation errors, not placeholder cards. Every ask_user_questions, suggest_tasks, or request_confirmation you post must carry a real, answerable prompt; withdraw one you no longer need instead of leaving it pending.",
"- When you intentionally restart follow-up work on a completed assigned issue, include structured `resume: true` with the POST /api/issues/{issueId}/comments or PATCH /api/issues/{issueId} comment payload. Generic agent comments on closed issues are inert by default.",
"- For plan approval, update the plan document first, then create request_confirmation targeting the latest plan revision with idempotencyKey confirmation:{issueId}:plan:{revisionId}. Wait for acceptance before creating implementation subtasks, and create a fresh confirmation after superseding board/user comments if approval is still needed.",
"- If blocked, mark the issue blocked and name the unblock owner and action.",

View File

@ -85,7 +85,14 @@ afterEach(async () => {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
// The sandbox process-session bridge writes event files asynchronously; on slow
// CI shards a final write can race the recursive rm (ENOTEMPTY on the events
// dir), so let fs.rm retry until the writer has quiesced.
await Promise.all(
tempRoots
.splice(0)
.map((root) => fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 })),
);
});
class FakeRuntime {

View File

@ -0,0 +1,3 @@
CREATE UNIQUE INDEX IF NOT EXISTS "issues_onboarding_first_task_uq"
ON "issues" USING btree ("company_id")
WHERE "origin_kind" = 'onboarding_first_task';

View File

@ -1471,6 +1471,13 @@
"when": 1786129601533,
"tag": "0211_bright_morg",
"breakpoints": true
},
{
"idx": 212,
"version": "7",
"when": 1786388759523,
"tag": "0212_onboarding_first_task_unique",
"breakpoints": true
}
]
}

View File

@ -168,5 +168,12 @@ export const issues = pgTable(
and ${table.hiddenAt} is null
and ${table.status} not in ('done', 'cancelled')`,
),
// The onboarding first-task origin grants privileged behavior (agent-attributed
// greeting, description suppression), so at most one issue per company may ever
// carry it — concurrent creates race on the pre-insert count check and this
// index is what atomically rejects the loser.
onboardingFirstTaskIdx: uniqueIndex("issues_onboarding_first_task_uq")
.on(table.companyId)
.where(sql`${table.originKind} = 'onboarding_first_task'`),
}),
);

View File

@ -300,6 +300,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",
@ -309,6 +313,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}`;

View File

@ -96,10 +96,10 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableTaskChatRedesign: {
title: "Chat-Style Tasks",
enableClassicTaskInterface: {
title: "Classic Task Interface",
description:
"Reimagines the task detail page as a live conversation with your agents: chat bubbles for people and agents, streaming activity — thinking, tool calls, diffs — that folds into a one-line summary when a turn finishes, inline plan/question/permission cards, a three-mode composer (Agent · Plan · Ask), and a resizable Properties · Plan · Artifacts pane.",
"Restore the pre-chat task detail page: the page-level header with inline description editor, the plain comment thread, and the fixed Properties sidebar. Chat-only features (streaming activity folding, inline plan/question cards, the three-mode composer) are unavailable in the classic view.",
tier: "preference",
cloudDefault: false,
selfHostedDefault: false,

View File

@ -289,6 +289,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,

View File

@ -52,7 +52,7 @@ export interface InstanceExperimentalSettings {
enablePipelines: boolean;
enableCases: boolean;
enableConferenceRoomChat: boolean;
enableTaskChatRedesign: boolean;
enableClassicTaskInterface: boolean;
enableTaskWatchdogs: boolean;
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;

View File

@ -1077,6 +1077,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 {
@ -1109,8 +1117,11 @@ export interface AskUserQuestionsResult {
answers: AskUserQuestionsAnswer[];
cancelled?: true;
cancellationReason?: string | null;
expirationReason?: "superseded_by_comment";
expirationReason?: "superseded_by_comment" | "superseded_by_newer_interaction";
commentId?: string | null;
// Set with expirationReason "superseded_by_newer_interaction": the newer
// sibling ask_user_questions that replaced this one (PAP-437).
supersededByInteractionId?: string | null;
summaryMarkdown?: string | null;
}

View File

@ -46,7 +46,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enablePipelines: z.boolean().default(false),
enableCases: z.boolean().default(false),
enableConferenceRoomChat: z.boolean().default(false),
enableTaskChatRedesign: z.boolean().default(false),
enableClassicTaskInterface: z.boolean().default(false),
enableTaskWatchdogs: z.boolean().default(false),
enableIssuePlanDecompositions: z.boolean().default(false),
enableExperimentalFileViewer: z.boolean().default(false),

View File

@ -491,13 +491,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>;
@ -759,6 +770,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({
@ -789,6 +806,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({
@ -798,6 +816,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"],
});
}
}
}
}
});
@ -815,8 +843,11 @@ export const askUserQuestionsResultSchema = z.object({
answers: z.array(askUserQuestionsAnswerSchema).max(20),
cancelled: z.literal(true).optional(),
cancellationReason: z.string().trim().max(4000).nullable().optional(),
expirationReason: z.literal("superseded_by_comment").optional(),
expirationReason: z.enum(["superseded_by_comment", "superseded_by_newer_interaction"]).optional(),
commentId: z.string().uuid().nullable().optional(),
// Set alongside expirationReason "superseded_by_newer_interaction": the id of
// the newer sibling ask_user_questions that replaced this one (PAP-437).
supersededByInteractionId: z.string().uuid().nullable().optional(),
summaryMarkdown: z.string().max(20000).nullable().optional(),
});

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 () => {

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 () => {

View File

@ -29,7 +29,7 @@ describe("instance settings service", () => {
enableStreamlinedLeftNavigation: true,
enableApps: false,
enableConferenceRoomChat: false,
enableTaskChatRedesign: false,
enableClassicTaskInterface: false,
enableExternalObjects: false,
enableSmokeLab: false,
enablePipelines: false,
@ -72,6 +72,20 @@ describe("instance settings service", () => {
).toBe(false);
});
it("defaults enableClassicTaskInterface to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableClassicTaskInterface).toBe(false);
expect(normalizeExperimentalSettings({}).enableClassicTaskInterface).toBe(false);
// The retired enableTaskChatRedesign key must not bleed into the new flag:
// an install that had the chat redesign ON opted into chat-style, which is
// now the default — not into the classic view.
expect(
normalizeExperimentalSettings({ enableTaskChatRedesign: true }).enableClassicTaskInterface,
).toBe(false);
expect(
normalizeExperimentalSettings({ enableClassicTaskInterface: true }).enableClassicTaskInterface,
).toBe(true);
});
it("defaults enableSimplifiedEnglishInteractions to false for empty and legacy stored settings", () => {
expect(normalizeExperimentalSettings(undefined).enableSimplifiedEnglishInteractions).toBe(false);
expect(normalizeExperimentalSettings({}).enableSimplifiedEnglishInteractions).toBe(false);

View File

@ -0,0 +1,168 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
agentWakeupRequests,
companies,
createDb,
issueComments,
issues,
} from "@paperclipai/db";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { actorMiddleware } from "../middleware/auth.js";
import { errorHandler } from "../middleware/index.js";
import { issueRoutes } from "../routes/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres onboarding first-task route tests on this host: ${
embeddedPostgresSupport.reason ?? "unsupported environment"
}`,
);
}
describeEmbeddedPostgres("issue create onboarding first-task routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-onboarding-first-task-routes-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(agentWakeupRequests);
await db.delete(issueComments);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
function createApp() {
const app = express();
app.use(express.json());
app.use(actorMiddleware(db, { deploymentMode: "local_trusted" }));
app.use("/api", issueRoutes(db, {} as any));
app.use(errorHandler);
return app;
}
async function seedCompany() {
const companyId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `D${companyId.replace(/-/g, "").slice(0, 5).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
return companyId;
}
async function seedAgent(companyId: string) {
const agentId = randomUUID();
await db.insert(agents).values({
id: agentId,
companyId,
name: "CEO",
role: "engineer",
status: "running",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
return agentId;
}
function listOnboardingIssues(companyId: string) {
return db
.select()
.from(issues)
.where(and(
eq(issues.companyId, companyId),
eq(issues.originKind, ONBOARDING_FIRST_TASK_ORIGIN_KIND),
));
}
it("stamps the onboarding origin and seeds the agent-attributed greeting on the first task", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
const app = createApp();
const created = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Get started", onboardingFirstTask: true, assigneeAgentId: agentId })
.expect(201);
expect(created.body.originKind).toBe(ONBOARDING_FIRST_TASK_ORIGIN_KIND);
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, created.body.id));
expect(comments).toHaveLength(1);
expect(comments[0]).toMatchObject({ authorType: "agent", authorAgentId: agentId });
});
it("fails closed to an ordinary issue when the onboarding origin is already claimed", async () => {
const companyId = await seedCompany();
const agentId = await seedAgent(companyId);
const app = createApp();
// Simulate the losing side of the count-vs-create race: the winning issue
// already claimed the onboarding origin but is hidden, so the zero-count
// fast path still passes and only issues_onboarding_first_task_uq rejects
// the privileged insert.
await db.insert(issues).values({
companyId,
title: "Race winner",
status: "todo",
priority: "medium",
originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND,
hiddenAt: new Date(),
});
const created = await request(app)
.post(`/api/companies/${companyId}/issues`)
.send({ title: "Race loser", onboardingFirstTask: true, assigneeAgentId: agentId })
.expect(201);
expect(created.body.originKind).toBe("manual");
const comments = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, created.body.id));
expect(comments).toHaveLength(0);
expect(await listOnboardingIssues(companyId)).toHaveLength(1);
});
it("allows at most one onboarding first task across concurrent creates", async () => {
const companyId = await seedCompany();
const app = createApp();
const responses = await Promise.all(
["Kick off A", "Kick off B", "Kick off C"].map((title) =>
request(app)
.post(`/api/companies/${companyId}/issues`)
.send({ title, onboardingFirstTask: true }),
),
);
for (const response of responses) expect(response.status).toBe(201);
expect(await listOnboardingIssues(companyId)).toHaveLength(1);
});
});

View File

@ -24,6 +24,7 @@ import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { ONBOARDING_FIRST_TASK_ORIGIN_KIND } from "@paperclipai/shared";
import { instanceSettingsService } from "../services/instance-settings.js";
import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
@ -1262,6 +1263,190 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => {
expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending");
});
it("supersedes an agent's own older pending ask_user_questions without crossing agent, kind, or issue", async () => {
const { companyId, goalId, issueId } = await seedConfirmationIssue("Question supersedes older sibling");
const otherIssueId = randomUUID();
await db.insert(issues).values({
id: otherIssueId,
companyId,
goalId,
title: "Other issue",
status: "in_progress",
priority: "medium",
});
const probingAgentId = randomUUID();
const otherAgentId = randomUUID();
await db.insert(agents).values([
{
id: probingAgentId,
companyId,
name: "Probing agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId,
name: "Other agent",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
const question = (prompt: string) => ({
kind: "ask_user_questions" as const,
payload: {
version: 1 as const,
questions: [{
id: "q",
prompt,
selectionMode: "single" as const,
options: [{ id: "opt", label: "Option" }],
}],
},
});
const older = await interactionsSvc.create(
{ id: issueId, companyId }, question("Older question"), { agentId: probingAgentId },
);
const otherKind = await interactionsSvc.create({ id: issueId, companyId }, {
kind: "request_confirmation",
payload: { version: 1, prompt: "Approve the draft?" },
}, { agentId: probingAgentId });
const otherAgentQuestion = await interactionsSvc.create(
{ id: issueId, companyId }, question("Other agent question"), { agentId: otherAgentId },
);
const otherIssueQuestion = await interactionsSvc.create(
{ id: otherIssueId, companyId }, question("Other issue question"), { agentId: probingAgentId },
);
const replacement = await interactionsSvc.create(
{ id: issueId, companyId }, question("Newer question"), { agentId: probingAgentId },
);
const interactions = await interactionsSvc.listForIssue(issueId);
expect(interactions.find((interaction) => interaction.id === older.id)).toMatchObject({
status: "expired",
resolvedByAgentId: probingAgentId,
result: {
answers: [],
expirationReason: "superseded_by_newer_interaction",
supersededByInteractionId: replacement.id,
},
});
expect(interactions.find((interaction) => interaction.id === replacement.id)?.status).toBe("pending");
// A different agent's pending question is untouched.
expect(interactions.find((interaction) => interaction.id === otherAgentQuestion.id)?.status).toBe("pending");
// A different kind from the same agent is untouched.
expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending");
// The same agent's question on a different issue is untouched.
const otherIssueInteractions = await interactionsSvc.listForIssue(otherIssueId);
expect(otherIssueInteractions.find((interaction) => interaction.id === otherIssueQuestion.id)?.status)
.toBe("pending");
});
it("leaves exactly one pending ask_user_questions on the onboarding first task after probe cards and the real question arrive", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Chief of staff",
role: "chief_of_staff",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(goals).values({
id: goalId,
companyId,
title: "Your first task",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Your first task",
status: "in_progress",
priority: "medium",
originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND,
assigneeAgentId: agentId,
});
// Reproduces PAP-436: the assigned agent posts two throwaway schema probes
// (title/prompt/option "t"/"p"/"L") before the genuine question.
const probe = (prompt: string) => ({
kind: "ask_user_questions" as const,
payload: {
version: 1 as const,
questions: [{
id: "q",
prompt,
selectionMode: "single" as const,
options: [{ id: "L", label: "L" }],
}],
},
});
await interactionsSvc.create({ id: issueId, companyId }, probe("t"), { agentId });
await interactionsSvc.create({ id: issueId, companyId }, probe("p"), { agentId });
await interactionsSvc.create({ id: issueId, companyId }, {
kind: "ask_user_questions",
payload: {
version: 1,
questions: [{
id: "focus",
prompt: "What would you like your team to focus on first?",
selectionMode: "single",
options: [
{ id: "mvp", label: "Ship the MVP" },
{ id: "bugs", label: "Fix bugs" },
],
}],
},
}, { agentId });
const interactions = await interactionsSvc.listForIssue(issueId);
const pendingQuestions = interactions.filter(
(interaction) => interaction.kind === "ask_user_questions" && interaction.status === "pending",
);
expect(pendingQuestions).toHaveLength(1);
expect(pendingQuestions[0]?.kind).toBe("ask_user_questions");
const [remaining] = pendingQuestions;
if (remaining?.kind === "ask_user_questions") {
expect(remaining.payload.questions[0]?.prompt).toContain("focus on first");
}
// Both probe cards auto-expired with the sibling-supersede reason.
const expiredQuestions = interactions.filter(
(interaction) => interaction.kind === "ask_user_questions" && interaction.status === "expired",
);
expect(expiredQuestions).toHaveLength(2);
for (const card of expiredQuestions) {
expect(card.result).toMatchObject({ expirationReason: "superseded_by_newer_interaction" });
}
});
it("sweeps historical confirmation pile-ups idempotently per issue, kind, and agent", async () => {
const { companyId, issueId } = await seedConfirmationIssue("Historical confirmation sweep");
const firstAgentId = randomUUID();

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,
@ -166,6 +167,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,
@ -592,6 +597,27 @@ function authenticatedActorResponsibleUserId(req: Request) {
return req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : undefined;
}
// Matches the partial unique index that guarantees at most one onboarding
// first-task issue per company (packages/db/src/schema/issues.ts).
function isOnboardingFirstTaskConflict(error: unknown): boolean {
for (
let current = error, depth = 0;
current && typeof current === "object" && depth < 5;
current = (current as { cause?: unknown }).cause, depth += 1
) {
const candidate = current as { code?: string; constraint?: string; message?: string };
if (
candidate.code === "23505" &&
(candidate.constraint === "issues_onboarding_first_task_uq" ||
(typeof candidate.message === "string" &&
candidate.message.includes("issues_onboarding_first_task_uq")))
) {
return true;
}
}
return false;
}
function issueWriteAuthorizationReason(
req: Request,
decision: true | { reason?: string | null },
@ -7625,7 +7651,34 @@ 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. The zero-count read below is only a
// fast-path gate — overlapping requests could both observe zero — so the
// partial unique index issues_onboarding_first_task_uq is what atomically
// enforces at most one onboarding first task per company; the create call
// handles losing that race by degrading to an ordinary issue.
const onboardingFirstTaskRequested =
rawOnboardingFirstTask === true && req.actor.type === "board";
let isOnboardingFirstTask = onboardingFirstTaskRequested
? (await svc.count(companyId)) === 0
: false;
const watchdogDiscovery = normalizeWatchdogDiscovery(rawWatchdogDiscovery);
const watchdogProductBugFollowUp = await resolveTaskWatchdogProductBugFollowUp(
req,
@ -7680,6 +7733,9 @@ export function issueRoutes(
...(runWorkspaceInheritanceSourceIssueId
? { inheritExecutionWorkspaceFromIssueId: runWorkspaceInheritanceSourceIssueId }
: {}),
...(isOnboardingFirstTask && !watchdogProductBugFollowUp
? { originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }
: {}),
...(watchdogProductBugFollowUp
? {
description: appendWatchdogDiscoveryContext({
@ -7734,7 +7790,7 @@ export function issueRoutes(
executionPolicy,
}, actor);
let deduplicationReason: "idempotency_key" | "recent_open_title" | null = null;
const issue = await svc.create(companyId, {
const createInput = {
...createBody,
...(taskBridgeOriginForActor(req) ?? {}),
id: issueId,
@ -7747,10 +7803,23 @@ export function issueRoutes(
actorResponsibleUserId: authenticatedActorResponsibleUserId(req),
trustExplicitResponsibleUserId: actor.actorType === "user",
watchdogActorRunId: actor.runId,
onDeduplicated: (reason) => {
onDeduplicated: (reason: "idempotency_key" | "recent_open_title") => {
deduplicationReason = reason;
},
});
};
let issue: Awaited<ReturnType<typeof svc.create>>;
try {
issue = await svc.create(companyId, createInput);
} catch (error) {
// Concurrent onboarding creates can both pass the zero-count fast path;
// the issues_onboarding_first_task_uq index rejects the loser here. Fail
// closed: drop the privileged origin (and with it the agent-attributed
// greeting) and create an ordinary issue instead.
if (!(isOnboardingFirstTask && isOnboardingFirstTaskConflict(error))) throw error;
isOnboardingFirstTask = false;
const { originKind: _onboardingOriginKind, ...ordinaryCreateInput } = createInput;
issue = await svc.create(companyId, ordinaryCreateInput);
}
if (deduplicationReason) {
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
res.status(200).json({
@ -7849,6 +7918,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,

View File

@ -471,6 +471,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"],
@ -1917,7 +1923,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;

View File

@ -214,7 +214,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
enableTaskChatRedesign: parsed.data.enableTaskChatRedesign ?? false,
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false,
enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false,
@ -250,7 +250,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
enableTaskChatRedesign: false,
enableClassicTaskInterface: false,
enableTaskWatchdogs: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,

View File

@ -565,6 +565,21 @@ function buildSupersededByNewerRequestResult(replacementInteractionId: string) {
} as const;
}
// An agent that posts a fresh ask_user_questions while its own earlier ones on
// the same issue are still pending has replaced them — the newer card carries
// the real ask, so the stale siblings auto-expire (PAP-437). Mirrors the
// `superseded_by_comment` shape (ask_user_questions results key expiry off
// `expirationReason`, not `outcome`) so the UI can hide them cleanly.
function buildSupersededByNewerInteractionResult(replacementInteractionId: string) {
return {
version: 1,
answers: [],
expirationReason: "superseded_by_newer_interaction",
supersededByInteractionId: replacementInteractionId,
summaryMarkdown: null,
} as const;
}
function buildAdministrativeOutcomeResult(
row: IssueThreadInteractionRow,
outcome: "withdrawn" | "issue_closed" | "addressee_deleted",
@ -1888,16 +1903,27 @@ export function issueThreadInteractionService(db: Db, opts: IssueThreadInteracti
})
.returning();
if (data.kind !== "request_confirmation" || !actor.agentId) {
// An agent replacing its own still-pending card supersedes the older
// one so the thread never accumulates stale sibling cards. This covers
// request_confirmation drafts and ask_user_questions (PAP-437: probe
// question cards that agents never withdrew). Each kind keeps its own
// result shape. Scoped strictly to the same agent + issue + kind, so
// other agents' or other kinds' pending cards are untouched.
const canSupersedeSiblingCards =
data.kind === "request_confirmation" || data.kind === "ask_user_questions";
if (!actor.agentId || !canSupersedeSiblingCards) {
return { row, supersededRows: [] };
}
const now = new Date();
const supersededResult = data.kind === "ask_user_questions"
? buildSupersededByNewerInteractionResult(row.id)
: buildSupersededByNewerRequestResult(row.id);
const supersededRows = await tx
.update(issueThreadInteractions)
.set({
status: "expired",
result: buildSupersededByNewerRequestResult(row.id),
result: supersededResult,
resolvedByAgentId: actor.agentId,
resolvedByUserId: actor.userId ?? null,
resolvedAt: now,

View File

@ -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");
});
});

View File

@ -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");
}

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: /^Connect$/ }).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);
});
});

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([]);
}

View File

@ -66,7 +66,7 @@ test.describe("NUX Phase 4 visual QA", () => {
await createCard.first().click();
}
await expect(
page.getByRole("heading", { name: "Name your company" }),
page.getByRole("heading", { name: "Name your organization" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("Acme Corp").fill("QA Robotics");
await page.screenshot({ path: shot("02-create-name.png") });
@ -120,7 +120,7 @@ test.describe("NUX Phase 4 visual QA", () => {
await page.getByRole("button", { name: /Add agents to your org/ }).click();
// The grow path shares step 1 (company name) before its step-2 intake.
await expect(
page.getByRole("heading", { name: "Name your company" }),
page.getByRole("heading", { name: "Name your organization" }),
).toBeVisible({ timeout: 10_000 });
await page.getByPlaceholder("Acme Corp").fill("QA Robotics Grow");
await page.getByRole("button", { name: /^Next/ }).click();

View File

@ -6,7 +6,7 @@ import { test, expect } from "@playwright/test";
* The wizard now opens on a front door (path picker) and the "Create a new
* company" path runs:
* Step 0 Front door (Create a new company / Level up existing)
* Step 1a Name your company
* Step 1a Name your organization
* Step 1b Define your mission (direct or guided)
* Step 2 Hire your team lead (adapter picker)
* Step 3+ Launch celebration CEO chat hiring plan orientation
@ -53,9 +53,9 @@ test.describe("Onboarding wizard", () => {
await createCard.first().click();
}
// Step 1 — Name your company.
// Step 1 — Name your organization.
await expect(
page.getByRole("heading", { name: "Name your company" }),
page.getByRole("heading", { name: "Name your organization" }),
).toBeVisible({ timeout: 15_000 });
await page.getByPlaceholder("Acme Corp").fill(COMPANY_NAME);
await page.getByRole("button", { name: /^Next/ }).click();

View File

@ -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",
@ -47,7 +53,7 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
const createCard = page.getByRole("button", { name: /Build a new company/ });
if (await createCard.count()) await createCard.first().click();
await expect(page.getByRole("heading", { name: "Name your company" })).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: "Name your organization" })).toBeVisible({ timeout: 15_000 });
await page.locator('input[placeholder="Acme Corp"]').fill(companyName);
await page.getByRole("button", { name: /^Next/ }).click();
@ -62,11 +68,13 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await expect(page.locator('input[placeholder="Chief of staff"]')).toHaveValue(AGENT_NAME);
await page.getByRole("button", { name: /^Next/ }).click();
await page.getByRole("button", { name: /Give it a heartbeat/ }).click();
await page.getByRole("button", { name: /^Connect$/ }).click();
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`);
@ -108,11 +116,9 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await page.goto(issuePath);
await expect(page.getByText("Plan mode").first()).toBeVisible();
await expect(page.getByTestId("issue-chat-composer")).toHaveAttribute("data-pending-work-mode", "planning");
const desktopPlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle");
const desktopPlanningToggle = page.getByTestId("task-chat-composer-mode");
await expect(desktopPlanningToggle).toBeVisible();
await expect(desktopPlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
await expect(desktopPlanningToggle).toHaveAttribute("aria-pressed", "true");
await page.screenshot({
path: `${screenshotDir}/desktop-planning-detail-${timestamp}.png`,
@ -128,11 +134,9 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
});
await page.goto(issuePath);
await page.getByTestId("issue-chat-composer-work-mode-toggle").click();
await page.getByTestId("issue-chat-composer-work-mode-menu-standard").click();
await expect(page.getByTestId("issue-chat-composer")).toHaveAttribute("data-pending-work-mode", "standard");
await expect(page.getByTestId("issue-chat-composer-work-mode-toggle")).toHaveAttribute("data-pending-work-mode", "standard");
await expect(page.getByTestId("issue-chat-composer-work-mode-toggle")).toHaveAttribute("aria-pressed", "false");
await page.getByTestId("task-chat-composer-mode").click();
await page.getByRole("menuitem", { name: /Agent mode/ }).click();
await expect(page.getByTestId("task-chat-composer-mode")).toHaveAttribute("data-pending-work-mode", "standard");
await page.screenshot({
path: `${screenshotDir}/desktop-standard-toggle-${timestamp}.png`,
fullPage: true,
@ -142,10 +146,9 @@ test("captures planning mode UI for desktop and mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(issuePath);
await expect(page.getByText("Plan mode").first()).toBeVisible();
const mobilePlanningToggle = page.getByTestId("issue-chat-composer-work-mode-toggle");
const mobilePlanningToggle = page.getByTestId("task-chat-composer-mode");
await expect(mobilePlanningToggle).toBeVisible();
await expect(mobilePlanningToggle).toHaveAttribute("data-pending-work-mode", "planning");
await expect(mobilePlanningToggle).toHaveAttribute("aria-pressed", "true");
await page.screenshot({
path: `${screenshotDir}/mobile-planning-detail-${timestamp}.png`,
fullPage: true,

View File

@ -3,7 +3,6 @@ import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n";
import { Layout } from "./components/Layout";
import { ConferenceRoomChatGate } from "./components/ConferenceRoomChatGate";
import { TaskChatRedesignGate } from "./components/TaskChatRedesignGate";
import { TaskChatLab } from "./pages/TaskChatLab";
import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate";
import { CasesExperimentalGate } from "./components/CasesExperimentalGate";
@ -276,13 +275,9 @@ function boardRoutes() {
<Route path="board-chat" element={<BoardChat />} />
<Route path="artifacts" element={<Artifacts />} />
</Route>
{/* Task Chat Redesign dev harness dev builds only, and additionally
gated by enableTaskChatRedesign (redirects to /dashboard when the
flag is off). */}
{/* Task chat dev harness — dev builds only. */}
{import.meta.env.DEV ? (
<Route element={<TaskChatRedesignGate />}>
<Route path="dev/task-chat-lab" element={<TaskChatLab />} />
</Route>
<Route path="dev/task-chat-lab" element={<TaskChatLab />} />
) : null}
<Route path="decisions" element={<WhatNeedsMe />} />
<Route path="decisions/queues/:key" element={<DecisionQueuePage />} />

View File

@ -439,10 +439,10 @@ interface IssueChatThreadProps {
linkedRuns?: IssueChatLinkedRun[];
timelineEvents?: IssueTimelineEvent[];
/**
* Work-mode switch history from the activity feed. Only the redesigned
* TaskChatThread consumes this (flag: enableTaskChatRedesign) to tag each
* agent reply with the mode its request ran under; the legacy thread
* ignores it.
* Work-mode switch history from the activity feed. Only the chat-style
* TaskChatThread consumes this to tag each agent reply with the mode its
* request ran under; this thread the classic task view behind
* enableClassicTaskInterface ignores it.
*/
workModeChanges?: IssueWorkModeChange[];
liveRuns?: LiveRunForIssue[];
@ -513,16 +513,15 @@ interface IssueChatThreadProps {
footer?: ReactNode;
/**
* Issue header content (title row, badges, plugin toolbars) rendered INSIDE
* the thread's scroll viewport so it scrolls away with the messages. Only the
* redesigned TaskChatThread consumes this (flag: enableTaskChatRedesign);
* the legacy thread ignores it its header stays in the page flow.
* the thread's scroll viewport so it scrolls away with the messages. Only
* the chat-style TaskChatThread consumes this; this thread ignores it its
* header stays in the page flow.
*/
threadHeader?: ReactNode;
/**
* The task description rendered as the requester's first chat bubble
* (PAP-375). Only the redesigned TaskChatThread consumes it (flag:
* enableTaskChatRedesign); the legacy thread ignores it its description
* stays in the page header via InlineEditor.
* (PAP-375). Only the chat-style TaskChatThread consumes it; this thread
* ignores it its description stays in the page header via InlineEditor.
*/
issueBrief?: TaskChatIssueBrief;
variant?: "full" | "embedded";

View File

@ -481,7 +481,7 @@ describe("IssueProperties", () => {
it("keeps the Plan tab visible for a planning-mode issue without a plan document", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskWatchdogs: false,
enableTaskChatRedesign: true,
enableClassicTaskInterface: false,
});
mockIssuesApi.listInteractions.mockResolvedValue([
{

View File

@ -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(
@ -790,19 +925,19 @@ describe("IssueThreadInteractionCard tool-action card", () => {
expect(host.textContent).not.toContain("Technical details");
});
it("renders the agents-may-resolve policy badge and addressee chip", () => {
it("renders the addressee chip without the removed policy badge", () => {
const host = renderCard({
interaction: agentAddressedRequestConfirmationInteraction,
});
const policyBadge = host.querySelector('[data-testid="interaction-policy-badge"]');
expect(policyBadge?.textContent).toContain("Agents may resolve");
// PAP-440: the "Agents may resolve" policy badge was pure noise — never rendered.
expect(host.querySelector('[data-testid="interaction-policy-badge"]')).toBeNull();
const addresseeBadge = host.querySelector('[data-testid="interaction-addressee-badge"]');
expect(addresseeBadge?.textContent).toContain("For ");
});
it("omits the policy and addressee badges for a board-only interaction", () => {
it("omits the addressee badge for a board-only interaction", () => {
const host = renderCard({
interaction: pendingRequestConfirmationInteraction,
});

View File

@ -1,6 +1,6 @@
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 { AlertTriangle, ArrowUpRight, Bot, Check, CheckCircle2, ChevronDown, ChevronRight, CircleDashed, Clock, ExternalLink, FileText, GitBranch, ImagePlus, Loader2, MessageSquareQuote, MinusCircle, ShieldAlert, ThumbsUp, TriangleAlert, Wrench, X, XCircle } from "lucide-react";
import { Link } from "@/lib/router";
import { formatAssigneeUserLabel } from "../lib/assignees";
import {
@ -10,6 +10,7 @@ import {
getCheckboxConfirmationSelectedLabels,
getItemVerdictProgress,
getQuestionAnswerLabels,
shouldHideInteractionCard,
normalizeRequestConfirmationTargetHref,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
@ -989,8 +990,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],
@ -1064,7 +1073,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)"
@ -1099,50 +1112,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">
@ -1878,6 +1923,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,
@ -1900,37 +2143,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]);
@ -1954,11 +2188,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() {
@ -1974,14 +2208,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 {
@ -2009,161 +2241,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} />
)}
@ -2293,11 +2453,13 @@ function CheckboxOptionRow({
function RequestCheckboxConfirmationCard({
interaction,
primaryActionOnRight = false,
onAcceptInteraction,
onRejectInteraction,
externalReferences,
}: {
interaction: RequestCheckboxConfirmationInteraction;
primaryActionOnRight?: boolean;
onAcceptInteraction?: (
interaction: RequestCheckboxConfirmationInteraction,
selectedClientKeys: undefined,
@ -2324,10 +2486,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);
@ -2335,22 +2494,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;
@ -2405,14 +2558,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 {
@ -2503,91 +2654,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>
);
@ -3096,6 +3177,16 @@ export function IssueThreadInteractionCard({
onUploadImage,
externalReferences,
}: IssueThreadInteractionCardProps) {
// Single enforcement point (PAP-424, plan from PAP-420; extended by PAP-437):
// a card that should never be drawn — a degenerate `ask_user_questions`
// (placeholder junk like the onboarding `Test / A` card, no genuine question)
// or a stale sibling the server auto-expired when its creator posted a newer
// question (`superseded_by_newer_interaction`). 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 (shouldHideInteractionCard(interaction)) return null;
const isPlan = isPlanConfirmation(interaction);
const isToolAction =
interaction.kind === "request_confirmation" && isToolActionConfirmation(interaction);
@ -3152,8 +3243,6 @@ export function IssueThreadInteractionCard({
: null;
// P4: audit-visible distinction between agent and human resolution.
const resolvedByAgent = Boolean(interaction.resolvedByAgentId);
// P2: agents may resolve when the governance-capped policy allows it.
const agentsMayResolve = interaction.effectiveResolverPolicy === "board_or_agents";
// P3: interactions directed at a specific agent addressee.
const addresseeLabel = interaction.addresseeAgentId
? resolveActorLabel({
@ -3183,23 +3272,6 @@ export function IssueThreadInteractionCard({
<span className="text-current/60">/</span>
{statusText}
</span>
{agentsMayResolve ? (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="gap-1 border-indigo-500/50 text-indigo-700 dark:text-indigo-200"
data-testid="interaction-policy-badge"
>
<Users className="h-3 w-3" />
Agents may resolve
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs text-xs">
Governance allows an assigned agent to resolve this interaction without waiting for the board.
</TooltipContent>
</Tooltip>
) : null}
{addresseeLabel ? (
<Tooltip>
<TooltipTrigger asChild>
@ -3275,6 +3347,7 @@ export function IssueThreadInteractionCard({
) : interaction.kind === "request_checkbox_confirmation" ? (
<RequestCheckboxConfirmationCard
interaction={interaction}
primaryActionOnRight={primaryActionOnRight}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
externalReferences={externalReferences}

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>
@ -988,7 +1025,7 @@ export function OnboardingWizard() {
: "text-muted-foreground group-focus-within:text-foreground"
)}
>
Company name
Name
</label>
<input
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
@ -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 && (

View File

@ -72,10 +72,10 @@ describe("PropertiesPanel", () => {
vi.clearAllMocks();
});
describe("flag off (current behavior)", () => {
describe("classic task interface on (legacy panel)", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskChatRedesign: false,
enableClassicTaskInterface: true,
});
});
@ -98,10 +98,10 @@ describe("PropertiesPanel", () => {
});
});
describe("flag on (task chat redesign)", () => {
describe("classic task interface off (default resizable pane)", () => {
beforeEach(() => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableTaskChatRedesign: true,
enableClassicTaskInterface: false,
});
});

View File

@ -1,18 +1,18 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Maximize2, Minimize2, X } from "lucide-react";
import { usePanel } from "../context/PanelContext";
import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled";
import { cn } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
export function PropertiesPanel() {
const { panelContent, panelVisible, setPanelVisible } = usePanel();
const { enabled: redesignEnabled } = useTaskChatRedesignEnabled();
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
if (!panelContent) return null;
if (!redesignEnabled) {
if (classicTaskInterfaceEnabled) {
return (
<aside
className="hidden md:flex border-l border-border bg-card flex-col shrink-0 overflow-hidden transition-(--tp-width-opacity) duration-200 ease-in-out h-full"
@ -43,8 +43,8 @@ export function PropertiesPanel() {
}
/* ------------------------------------------------------------------------- *
* Task Chat Redesign (flag: enableTaskChatRedesign) resizable/maximizable
* variant. Everything below renders only when the flag is ON.
* Chat-style (default) resizable/maximizable variant. Everything below
* renders only when the Classic Task Interface flag is OFF.
* ------------------------------------------------------------------------- */
/**

View File

@ -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,9 +281,9 @@ 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).not.toContain("Company settings");
expect(document.body.textContent).toContain("Sign out");
const signOutButton = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
@ -412,7 +412,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(() => {
@ -494,7 +494,7 @@ describe("SidebarCompanyMenu", () => {
expect(document.body.textContent).toContain("Switch organization");
expect(document.body.textContent).toContain("Create new organization...");
expect(document.body.textContent).toContain("Organization settings");
expect(document.body.textContent).not.toContain("Organization settings");
expect(document.body.textContent).not.toContain("Switch company");
expect(document.body.textContent).not.toContain("Create new company...");
expect(document.body.textContent).not.toContain("Company settings");

View File

@ -6,7 +6,6 @@ import {
GripVertical,
LogOut,
Plus,
Settings,
UserPlus,
} from "lucide-react";
import {
@ -309,7 +308,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(
@ -451,7 +453,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 />
</>
@ -473,21 +475,6 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild disabled={isEditingOrder}>
<Link
to="/company/settings"
onClick={(event) => {
if (isEditingOrder) {
event.preventDefault();
return;
}
closeNavigationChrome();
}}
>
<Settings className="size-4" />
<span>{isCloud ? "Organization settings" : "Company settings"}</span>
</Link>
</DropdownMenuItem>
{session?.session ? (
<>
<DropdownMenuSeparator />

View File

@ -1,95 +0,0 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TaskChatRedesignGate } from "./TaskChatRedesignGate";
import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled";
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("@/lib/router", () => ({
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => (
<div data-testid="navigate" data-to={to} data-replace={String(replace ?? false)} />
),
Outlet: () => <div data-testid="outlet">gated content</div>,
}));
async function flushReact() {
for (let index = 0; index < 5; index += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
flushSync(() => {});
}
describe("TaskChatRedesignGate", () => {
let container: HTMLDivElement;
let root: Root | null = null;
async function renderGate() {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<TaskChatRedesignGate />
</QueryClientProvider>,
);
});
await flushReact();
}
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
flushSync(() => {
root?.unmount();
});
root = null;
container.remove();
vi.clearAllMocks();
});
it("redirects to the company home when the flag is off (flag-off isolation)", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: false });
await renderGate();
const navigate = container.querySelector('[data-testid="navigate"]');
expect(navigate?.getAttribute("data-to")).toBe("/dashboard");
expect(container.querySelector('[data-testid="outlet"]')).toBeNull();
});
it("renders the gated harness when the flag is on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableTaskChatRedesign: true });
await renderGate();
expect(container.querySelector('[data-testid="outlet"]')).not.toBeNull();
expect(container.querySelector('[data-testid="navigate"]')).toBeNull();
});
});
describe("useTaskChatRedesignEnabled", () => {
it("resolves to flag-off when rendered without a QueryClientProvider", () => {
// Rendered detached (no provider) it must default OFF and loaded — this is
// what makes flag-off the provable current behavior at every call site.
let captured: { enabled: boolean; loaded: boolean } | null = null;
function Probe() {
captured = useTaskChatRedesignEnabled();
return null;
}
const container = document.createElement("div");
const root = createRoot(container);
flushSync(() => root.render(<Probe />));
flushSync(() => root.unmount());
expect(captured).toEqual({ enabled: false, loaded: true });
});
});

View File

@ -1,18 +0,0 @@
import { Navigate, Outlet } from "@/lib/router";
import { useTaskChatRedesignEnabled } from "@/hooks/useTaskChatRedesignEnabled";
/**
* Layout route guard for Task Chat Redesign dev surfaces (e.g. the
* /dev/task-chat-lab harness).
*
* The gated routes stay registered (gating is presentation-only, no 404
* flash); when the experimental flag is off the element redirects to the
* company home instead of rendering. While the flag is still loading nothing
* renders so an enabled user is not bounced away by a premature redirect.
*/
export function TaskChatRedesignGate() {
const { enabled, loaded } = useTaskChatRedesignEnabled();
if (!loaded) return null;
if (!enabled) return <Navigate to="/dashboard" replace />;
return <Outlet />;
}

View File

@ -5,10 +5,13 @@ import { forwardRef, useImperativeHandle, type ForwardedRef } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { TaskChatThread } from "./TaskChatThread";
const transcriptState = vi.hoisted(() => ({ transcriptByRun: new Map() }));
vi.mock("@/components/transcript/useLiveRunTranscripts", () => ({
useLiveRunTranscripts: () => ({ transcriptByRun: new Map() }),
useLiveRunTranscripts: () => transcriptState,
}));
vi.mock("@/context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
@ -31,6 +34,7 @@ let root: Root | null = null;
beforeEach(() => {
localStorage.clear();
transcriptState.transcriptByRun.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
@ -44,7 +48,7 @@ afterEach(() => {
});
function render(ui: ReactElement) {
flushSync(() => root!.render(ui));
flushSync(() => root!.render(<ThemeProvider>{ui}</ThemeProvider>));
}
describe("TaskChatThread draft pass-through", () => {
@ -63,3 +67,110 @@ describe("TaskChatThread draft pass-through", () => {
.toBe("half-written thought");
});
});
describe("TaskChatThread live transcript", () => {
it("renders in-flight output through TaskChatLiveTail, dropping the debug plumbing (PAP-463 C1)", () => {
// Interleave the exact noise the old RunTranscriptView tail surfaced (init
// row, stdout/stderr/system dumps) with real content. Only the streamed
// reply markdown and the tool row may reach the thread.
transcriptState.transcriptByRun.set("run-1", [
{ kind: "init", ts: "2026-08-07T00:00:00.000Z", model: "claude", sessionId: "sess-INITMARKER" },
{ kind: "system", ts: "2026-08-07T00:00:00.000Z", text: "SYSTEMNOISE environment hint" },
{ kind: "stdout", ts: "2026-08-07T00:00:00.000Z", text: "STDOUTNOISE raw json dump" },
{ kind: "stderr", ts: "2026-08-07T00:00:00.000Z", text: "STDERRNOISE adapter timeout note" },
{
kind: "assistant",
ts: "2026-08-07T00:00:00.000Z",
text: "Streaming through the shared renderer",
},
{ kind: "tool_call", ts: "2026-08-07T00:00:00.000Z", name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" } },
]);
render(
<TaskChatThread
comments={[]}
onAdd={async () => {}}
issueStatus="in_progress"
activeRun={{
id: "run-1",
status: "running",
invocationSource: "issue",
triggerDetail: null,
startedAt: "2026-08-07T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-08-07T00:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
}}
/>,
);
const tail = container.querySelector('[data-testid="task-chat-live-transcript"]');
expect(tail).not.toBeNull();
// Clean content survives: streamed reply markdown + tool row.
expect(tail!.textContent).toContain("Streaming through the shared renderer");
expect(tail!.textContent).toContain("src/app.ts");
// None of the debug plumbing reaches the thread.
for (const noise of ["INITMARKER", "SYSTEMNOISE", "STDOUTNOISE", "STDERRNOISE"]) {
expect(container.textContent).not.toContain(noise);
}
});
it("keeps the transcript mounted through run settle until the settled turn renders (PAP-462 B4)", () => {
transcriptState.transcriptByRun.set("run-1", [
{
kind: "assistant",
ts: "2026-08-07T00:00:00.000Z",
text: "Last words before the run stops",
},
]);
const liveProps = {
comments: [] as never[],
onAdd: async () => {},
issueStatus: "in_progress",
activeRun: {
id: "run-1",
status: "running",
invocationSource: "issue" as const,
triggerDetail: null,
startedAt: "2026-08-07T00:00:00.000Z",
finishedAt: null,
createdAt: "2026-08-07T00:00:00.000Z",
agentId: "agent-1",
agentName: "Coder",
adapterType: "codex_local",
},
};
render(<TaskChatThread {...liveProps} />);
expect(
container.querySelector('[data-testid="task-chat-live-transcript"]'),
).not.toBeNull();
// The run settles: the issue goes terminal and the run reports succeeded, so
// `liveRun` flips to null — but no reply comment has landed yet. The
// transcript must NOT vanish; it stays mounted (now as a settled tail) until
// its settled turn/comment renders.
render(
<TaskChatThread
{...liveProps}
issueStatus="done"
activeRun={{
...liveProps.activeRun,
status: "succeeded",
finishedAt: "2026-08-07T00:01:00.000Z",
}}
/>,
);
expect(
container.querySelector('[data-testid="task-chat-live-transcript"]'),
).not.toBeNull();
expect(container.textContent).toContain("Last words before the run stops");
// The pill has settled to its "Worked" state rather than flipping back to a
// spinner while it waits for the reply comment.
expect(container.textContent).toContain("Worked");
});
});

View File

@ -1,14 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from "react";
import { IssueChatThread } from "@/components/IssueChatThread";
import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts";
import {
useLiveRunTranscripts,
type RunTranscriptSource,
} from "@/components/transcript/useLiveRunTranscripts";
import { TaskChatLiveTail } from "@/components/task-chat/TaskChatLiveTail";
import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter";
import {
assembleThreadItems,
attachSettledTurns,
buildTurnSummary,
coalesceSettledTurns,
deriveRunStatusLabel,
isNestableLiveChild,
isTerminalRunStatus,
prependIssueBrief,
settledRunChildren,
@ -16,6 +18,10 @@ import {
type SettledTurnMergeMeta,
} from "@/components/task-chat/transcript-adapter";
import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble";
import {
TaskChatLiveRunPill,
toolCountSummaryFromEntries,
} from "@/components/task-chat/TaskChatLiveRunPill";
import type {
TaskChatInteractionItem,
TaskChatItem,
@ -23,6 +29,11 @@ import type {
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 { shouldHideInteractionCard } 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";
@ -42,32 +53,30 @@ function toMs(value: Date | string | null | undefined): number {
return Number.isNaN(ms) ? 0 : ms;
}
// PAP-462 B4: backstop for how long the just-settled run's transcript stays
// mounted when neither a settled turn nor a reply comment ever arrives to hand
// off to (e.g. a stopped run with no tool activity). Normal completions hand off
// well within this as soon as the settled turn/comment lands.
const SETTLING_TAIL_MAX_MS = 15_000;
export type TaskChatThreadProps = ComponentProps<typeof IssueChatThread>;
/**
* Task Chat Redesign thread (experimental flag: `enableTaskChatRedesign`).
* Chat-style task thread the default task detail experience.
*
* Renders the redesigned, Claude-Code-style thread for the live task. It shares
* Renders the Claude-Code-style thread for the live task. It shares
* IssueChatThread's exact prop type so the IssueDetail seam ternary
* (`redesign ? TaskChatThread : IssueChatThread`) type-checks with no casts.
* (`classic ? IssueChatThread : TaskChatThread`, flag:
* `enableClassicTaskInterface`) type-checks with no casts.
*
* Two data sources feed the render layer, both reused from the existing thread:
* - the comment stream (incl. optimistic echoes) author-typed bubbles, and
* - the live run transcript (useLiveRunTranscripts, the same poll+websocket
* source the current thread uses) the in-flight turn streams
* thinking tool diff responding, capped by a live "running" status
* pill.
* source the current thread uses) clean TaskChatLiveTail rows (tool cards,
* diffs, streamed reply markdown) while in flight, via the same
* transcriptToTaskChatItems converter the settled turns use (PAP-463).
*
* Run activity is grouped into TaskChatTurnItem: the in-flight run renders as
* ONE expandable parent row its status line (whimsy gerund or in-flight
* tool state + elapsed + tokens) is the turn's single visible line, with the
* chronological tool activity nested behind an expand (PAP-354). Mid-run
* agent text (interstitial updates between tool calls) is ephemeral
* (PAP-361, round 9): while one streams it occupies a dedicated one-line row
* PERMANENTLY RESERVED above the status line (so the layout above never
* jumps), and when it finishes the text slides out and the slot sits empty
* nothing persists; the run log and the classic transcript remain the
* archive. When the run terminates, its settled turn anchors after the run's
* Once a run terminates, its settled turn anchors after the run's
* last comment (comment.runId linkage) and when it directly follows that
* reply bubble attaches to it: the "✓ Worked · …" summary renders appended
* to the bubble's always-visible timestamp line (round 9), still expandable
@ -229,6 +238,15 @@ 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 never-rendered card — a degenerate `ask_user_questions` (e.g. the
// onboarding `Test / A` placeholder) or a stale sibling superseded by a
// newer question (PAP-437) — is filtered here so it leaves no empty slot
// or gap in the ordered backbone (PAP-424, plan from PAP-420).
if (shouldHideInteractionCard(interaction)) continue;
const createdAtMs = toMs(interaction.createdAt);
const handoffAtMs =
interaction.kind === "request_confirmation" && interaction.sourceRunId
@ -243,7 +261,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 },
@ -274,7 +295,12 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// heavy assembly memo doesn't recompute on every parent render.
const hasBrief = Boolean(issueBrief);
const items = useMemo<TaskChatItem[]>(() => {
const { items, settledRunIds } = useMemo<{ items: TaskChatItem[]; settledRunIds: Set<string> }>(() => {
// Runs whose settled turn made it into the assembled thread — the live tail
// hands off to this as its "the settled turn has rendered" signal (PAP-462
// B4), so the transcript stays mounted through the settle gap without ever
// double-rendering beside its own settled turn.
const settledRunIds = new Set<string>();
// Settled turns for every terminal run whose transcript we have. Messages
// and thinking are excluded entirely (PAP-361): the final reply already
// landed as the run's comment bubble, interstitial updates are ephemeral
@ -303,6 +329,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
});
const children = settledRunChildren(parsed);
if (children.length === 0) continue;
settledRunIds.add(source.id);
const failed = source.status !== "succeeded";
const startSlotRaw = meta?.startedAt ?? meta?.createdAt;
turnMergeMetaById.set(`${source.id}:turn`, {
@ -347,57 +374,133 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// (PAP-367).
const out = assembleThreadItems(orderedEntries, turnsByAnchor, unanchored);
if (liveRun) {
const entries = transcriptByRun.get(liveRun.id) ?? [];
const parsed = transcriptToTaskChatItems(entries, {
runId: liveRun.id,
agentName: liveRun.agentName,
running: true,
});
// Parent-row model (PAP-354): the run's status line IS the live turn —
// one expandable row owning the activity. Only tool/usage rows nest
// inside it; a streaming interstitial update renders in the reserved row
// above the status line (liveStatus.selfTalk, PAP-361/round 9) and
// vanishes when it completes. The parent row sits last; on settle its
// summary attaches to the reply bubble's timestamp line.
const children = parsed.filter(isNestableLiveChild);
const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null;
const queued = liveRun.status === "queued";
const status = queued
? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined }
: deriveRunStatusLabel(entries);
out.push({
id: `${liveRun.id}:turn`,
kind: "turn",
settled: false,
items: children,
summary: buildTurnSummary(entries),
liveStatus: {
id: `${liveRun.id}:status`,
kind: "status",
status: "running",
label: status.label,
detail: status.detail,
toolName: status.toolName,
selfTalk: status.selfTalk,
startedAtMs: startedAt ?? undefined,
},
});
}
// PAP-362: two runs replying back-to-back (same agent, nothing but the
// agent's own bubbles between) fold into ONE "Worked" row below the last
// reply; a user message, interaction, or the live turn keeps them apart.
// reply; a user message or interaction keeps them apart.
// Round 9: a settled turn directly following its own agent's reply bubble
// then attaches to that bubble — the "Worked · …" summary renders on the
// bubble's always-visible timestamp line instead of as a standalone row.
// PAP-375: the description-as-first-bubble placeholder prepends LAST, after
// every assembly/merge pass, so nothing can ever sort above it.
return prependIssueBrief(
attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
hasBrief,
);
return {
items: prependIssueBrief(
attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById),
hasBrief,
),
settledRunIds,
};
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
// PAP-462 B4: the moment a run settles, `liveRun` flips to null — but its
// settled turn (or reply comment) can take seconds to arrive on the next
// comment refetch. Without a handoff the whole transcript unmounts that frame,
// blinking out already-streamed output before its settled form renders. Snapshot
// the just-settled run so its now-static transcript stays mounted through the gap.
const [settlingRun, setSettlingRun] = useState<{ id: string; startedAtMs: number | null } | null>(null);
const prevLiveRunRef = useRef<typeof liveRun>(null);
// Layout effect (not passive): snapshot the settled run before the browser
// paints the `liveRun === null` frame, so the tail never blinks empty for a
// frame between the run stopping and this snapshot committing.
useLayoutEffect(() => {
if (liveRun) {
prevLiveRunRef.current = liveRun;
// A fresh live run supersedes any lingering settled remnant.
setSettlingRun((current) => (current && current.id !== liveRun.id ? null : current));
return;
}
const prev = prevLiveRunRef.current;
prevLiveRunRef.current = null;
if (!prev) return;
setSettlingRun({
id: prev.id,
startedAtMs: (prev.startedAt ? toMs(prev.startedAt) : null) ?? toMs(prev.createdAt),
});
}, [liveRun]);
// Hand off once the settled turn or its reply comment is in the thread; a
// stopped run that yields neither is released by the backstop timeout so the
// tail never lingers indefinitely.
const settledRunRendered =
settlingRun != null &&
(settledRunIds.has(settlingRun.id) ||
comments.some((comment) => comment.runId === settlingRun.id && !comment.deletedAt));
useEffect(() => {
if (!settlingRun) return;
if (settledRunRendered) {
setSettlingRun(null);
return;
}
const timer = window.setTimeout(() => setSettlingRun(null), SETTLING_TAIL_MAX_MS);
return () => window.clearTimeout(timer);
}, [settlingRun, settledRunRendered]);
// The tail streams the live run, or — through the settle gap — the last run's
// now-static transcript until its settled form renders (PAP-462 B4).
const showSettlingTail =
!liveRun &&
settlingRun != null &&
!settledRunRendered &&
(transcriptByRun.get(settlingRun.id)?.length ?? 0) > 0;
const tailRunId = liveRun ? liveRun.id : showSettlingTail ? settlingRun!.id : null;
const tailStreaming = Boolean(liveRun);
const tailEntries = tailRunId ? (transcriptByRun.get(tailRunId) ?? []) : [];
const tailContentKey = tailEntries.reduce((total, entry) => {
if ("text" in entry) return total + entry.text.length;
if ("content" in entry) return total + entry.content.length;
return total + entry.kind.length;
}, tailEntries.length);
const threadContentKey = taskChatContentKey(items) + tailContentKey;
// Status-pill inputs for the tail (PAP-461, A1): the run's start, its finish
// (once terminal), and the "called N tools" summary. Memoized on the
// transcript content key so the O(n) tool count is not re-walked every render
// while the pill's own second-tick keeps the elapsed readout moving. Through
// the settle gap the run reads terminal, so the pill lands on its "Worked"
// state instead of flipping back to a spinner.
const settlingFinishedAt = settlingRun ? linkedRunMetaById.get(settlingRun.id)?.finishedAt : undefined;
const tailStatus = liveRun
? liveRun.status
: runs.find((run) => run.id === settlingRun?.id)?.status ?? "succeeded";
const tailStartedAtMs = liveRun
? (liveRun.startedAt ? toMs(liveRun.startedAt) : null) ?? toMs(liveRun.createdAt)
: settlingRun?.startedAtMs ?? null;
const tailFinishedAtMs = liveRun
? liveRun.finishedAt
? toMs(liveRun.finishedAt)
: null
: settlingFinishedAt
? toMs(settlingFinishedAt)
: null;
const tailToolSummary = useMemo(
() => (tailRunId ? toolCountSummaryFromEntries(tailEntries) : null),
// tailEntries is a fresh array each render; tailContentKey tracks its content.
// eslint-disable-next-line react-hooks/exhaustive-deps
[tailRunId, tailContentKey],
);
// The tail's clean rows (PAP-463 C1): the streaming transcript parsed through
// the SAME transcriptToTaskChatItems converter the settled turns use, which
// drops the debug plumbing (init/stdout/stderr/system) so RunTranscriptView's
// noise can never reach the thread. Memoized on the content key (tailEntries
// is a fresh array each render) so the O(n) parse is not re-walked while the
// pill's own second-tick keeps the elapsed readout moving. `running` follows
// tailStreaming: true while live, false through the settle gap — so the tail
// renders identically either side of the run finishing.
const tailAgentName = liveRun?.agentName ?? linkedRunMetaById.get(tailRunId ?? "")?.agentName;
const tailItems = useMemo(
() =>
tailRunId
? transcriptToTaskChatItems(tailEntries, {
runId: tailRunId,
agentName: tailAgentName,
running: tailStreaming,
})
: [],
// tailEntries is a fresh array each render; tailContentKey tracks its content.
// eslint-disable-next-line react-hooks/exhaustive-deps
[tailRunId, tailContentKey, tailStreaming, tailAgentName],
);
// 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).
@ -471,7 +574,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
// in document flow instead (the same scroll={false} path the previews use)
// and track auto-follow against window scroll. Desktop stays byte-identical.
const { isMobile } = useSidebar();
useWindowAutoFollow(isMobile ? taskChatContentKey(items) : 0, isMobile);
useWindowAutoFollow(isMobile ? threadContentKey : 0, isMobile);
return (
<div
@ -479,7 +582,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
data-testid="task-chat-thread"
>
<div className={cn("flex flex-col", !isMobile && "min-h-0 flex-1")}>
{items.length === 0 ? (
{items.length === 0 && !tailRunId ? (
<div className={isMobile ? undefined : "min-h-0 flex-1 overflow-y-auto"}>
{threadHeader ? (
<div
@ -498,6 +601,25 @@ export function TaskChatThread(props: TaskChatThreadProps) {
renderInteraction={renderInteraction}
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
tail={tailRunId ? (
<div data-testid="task-chat-live-transcript">
<TaskChatLiveRunPill
status={tailStatus}
startedAtMs={tailStartedAtMs}
finishedAtMs={tailFinishedAtMs}
toolSummary={tailToolSummary}
/>
<TaskChatLiveTail
items={tailItems}
emptyMessage={
tailStatus === "queued"
? "Waiting to start..."
: "Waiting for transcript..."
}
/>
</div>
) : null}
contentKey={threadContentKey}
scroll={!isMobile}
/>
)}

View File

@ -1,117 +0,0 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router-dom";
import type { Issue, RequestConfirmationInteraction } from "@paperclipai/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "@/lib/queryKeys";
import { IssuePropertiesPlansTab } from "./IssuePropertiesPlansTab";
import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar";
const mockIssuesApi = vi.hoisted(() => ({
listInteractions: vi.fn(),
listAcceptedPlanDecompositions: vi.fn(),
acceptInteraction: vi.fn(),
rejectInteraction: vi.fn(),
}));
vi.mock("@/api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("@/hooks/useIssuePlanDocument", () => ({
useIssuePlanDocument: () => ({ data: undefined, isLoading: false }),
}));
vi.mock("../PropertiesPanel", () => ({
PROPERTIES_PANE_FOOTER_SLOT_ID: "properties-pane-footer-slot",
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const issue = {
id: "issue-1",
identifier: "PAP-1",
} as Issue;
const confirmation = {
id: "confirmation-1",
companyId: "company-1",
issueId: issue.id,
kind: "request_confirmation",
status: "pending",
continuationPolicy: "wake_assignee",
resolverPolicy: "board_only",
requestedResolverPolicy: "board_only",
effectiveResolverPolicy: "board_only",
createdAt: "2026-08-05T00:00:00.000Z",
updatedAt: "2026-08-05T00:00:00.000Z",
payload: {
version: 1,
prompt: "Approve this plan?",
acceptLabel: "Approve plan",
rejectLabel: "Request changes",
rejectRequiresReason: true,
allowDeclineReason: true,
target: { type: "issue_document", key: "plan", revisionId: "rev-1" },
},
} satisfies RequestConfirmationInteraction;
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
let client: QueryClient | null = null;
function render(element: React.ReactElement) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(queryKeys.issues.interactions(issue.id), [confirmation]);
client.setQueryData(queryKeys.issues.acceptedPlanDecompositions(issue.id), []);
act(() => root?.render(
<MemoryRouter>
<QueryClientProvider client={client!}>{element}</QueryClientProvider>
</MemoryRouter>,
));
return container;
}
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
container?.remove();
container = null;
client?.clear();
client = null;
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("IssuePlanConfirmationActionBar", () => {
it("renders a pending plan confirmation even before its plan document exists", () => {
const rendered = render(<IssuePropertiesPlansTab issue={issue} />);
expect(rendered.querySelector('[data-testid="plan-pane-action-bar"]')).not.toBeNull();
expect(rendered.textContent).toContain("Approve plan");
expect(rendered.textContent).toContain("Request changes");
});
it("moves into a footer slot that mounts on the next paint", () => {
let resolveNextPaint: FrameRequestCallback | undefined;
vi.stubGlobal("requestAnimationFrame", vi.fn((callback: FrameRequestCallback) => {
resolveNextPaint = callback;
return 1;
}));
vi.stubGlobal("cancelAnimationFrame", vi.fn());
const rendered = render(<IssuePlanConfirmationActionBar issue={issue} />);
const footer = document.createElement("div");
footer.id = "properties-pane-footer-slot";
document.body.appendChild(footer);
act(() => resolveNextPaint?.(0));
expect(rendered.querySelector('[data-testid="plan-pane-action-bar"]')).toBeNull();
expect(footer.querySelector('[data-testid="plan-pane-action-bar"]')).not.toBeNull();
footer.remove();
});
});

View File

@ -1,216 +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;
}
const resolveFooterSlot = () => {
setFooterSlot(document.getElementById(PROPERTIES_PANE_FOOTER_SLOT_ID));
};
// The properties pane and this action bar can mount in either order. Check
// once synchronously, then again on the next paint so the footer slot is
// found when it is mounted later in the same commit.
resolveFooterSlot();
const frame = requestAnimationFrame(resolveFooterSlot);
return () => cancelAnimationFrame(frame);
}, [confirmation?.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
?? (confirmation.payload.acceptLabel === "Approve plan"
? "Optional: what would you like revised?"
: "Optional: tell the agent what you'd change.")
}
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;
}

View File

@ -171,38 +171,40 @@ export function IssueProperties({
queryFn: () => instanceSettingsApi.getExperimental(),
});
const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true;
// Task Chat Redesign: gate the Properties | Plans | Artifacts tab shell. Flag
// OFF renders today's stacked sections verbatim (no Tabs wrapper). This pane
// is always task-scoped, so the flag alone is a sufficient gate.
const taskChatRedesignEnabled = experimentalSettings?.enableTaskChatRedesign === true;
// When hosted by the redesigned PropertiesPanel, the tab strip portals into
// Classic Task Interface: gate the Properties | Plans | Artifacts tab shell.
// Flag ON renders the legacy stacked sections verbatim (no Tabs wrapper);
// flag OFF — including while settings load — renders the chat-style tab
// shell. This pane is always task-scoped, so the flag alone is a sufficient
// gate.
const taskChatShellEnabled = experimentalSettings?.enableClassicTaskInterface !== true;
// When hosted by the resizable PropertiesPanel, the tab strip portals into
// the pane's header bar (left of the window controls). The slot only exists
// once the panel has committed, hence the effect; inline hosts (mobile sheet)
// keep the tab strip in place.
const [paneHeaderSlot, setPaneHeaderSlot] = useState<HTMLElement | null>(null);
useEffect(() => {
if (!taskChatRedesignEnabled || inline) {
if (!taskChatShellEnabled || inline) {
setPaneHeaderSlot(null);
return;
}
setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID));
}, [taskChatRedesignEnabled, inline]);
}, [taskChatShellEnabled, inline]);
// Plan earns a tab as soon as an issue is in planning mode, even before the
// plan document arrives. This keeps an expected plan surface visible and
// lets its diagnostic empty state explain what is missing.
// Same query keys as the tab bodies, so these share their cached fetches.
const { data: paneTabPlanDocument } = useIssuePlanDocument(
taskChatRedesignEnabled ? issue.id : null,
taskChatShellEnabled ? issue.id : null,
);
const { data: paneTabAcceptedPlans } = useQuery({
queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id),
queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id),
enabled: taskChatRedesignEnabled,
enabled: taskChatShellEnabled,
});
const { data: paneTabAttachments } = useQuery({
queryKey: queryKeys.issues.attachments(issue.id),
queryFn: () => issuesApi.listAttachments(issue.id),
enabled: taskChatRedesignEnabled,
enabled: taskChatShellEnabled,
});
const hasPlanTab =
Boolean(paneTabPlanDocument)
@ -210,6 +212,20 @@ export function IssueProperties({
|| issue.workMode === "planning";
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
@ -2496,10 +2512,10 @@ export function IssueProperties({
</div>
);
// Flag OFF (or non-redesign hosts): today's stacked pane, byte-for-byte.
if (!taskChatRedesignEnabled) return propertiesBody;
// Classic Task Interface ON: the legacy stacked pane, byte-for-byte.
if (!taskChatShellEnabled) return propertiesBody;
// Flag ON with nothing to switch between: no tab strip — the header bar
// Chat-style with nothing to switch between: no tab strip — the header bar
// shows a plain title and the pane body is just the properties stack.
if (!hasPlanTab && !hasArtifactsTab) {
return (
@ -2552,7 +2568,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

View File

@ -15,7 +15,7 @@ function formatBytes(n: number): string {
}
/**
* Artifacts tab of the redesigned properties pane (flag: enableTaskChatRedesign).
* Artifacts tab of the properties pane.
*
* A read-only gallery of the task's attachments / work products. Uploads,
* previews, and deletes stay on the existing attachment surfaces for the

View File

@ -8,12 +8,11 @@ import { MarkdownBody } from "@/components/MarkdownBody";
import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations";
import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument";
import { useLocation } from "@/lib/router";
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;
}
@ -28,15 +27,15 @@ function hasPendingPlanConfirmation(interactions: IssueThreadInteraction[] | und
}
/**
* Plans tab of the redesigned properties pane (flag: enableTaskChatRedesign).
* Plans tab of the properties pane.
*
* Owns the plan surface with the flag ON: the `plan` document itself (formerly
* pinned above the tabs via IssueDocumentsSection, which the chat shell gates
* off) rendered above the accepted-plan decomposition history. Structured live
* Owns the plan surface: the `plan` document itself (formerly pinned above
* the tabs via IssueDocumentsSection, which the chat shell gates off)
* rendered above the accepted-plan decomposition history. Structured live
* 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 location = useLocation();
const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false);
@ -53,35 +52,31 @@ export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansT
if (!planDocument && !hasPlans) {
return (
<>
{/* This is deliberately outside the plan-document gate: an interaction
can arrive before its plan document query resolves or persists. */}
<IssuePlanConfirmationActionBar issue={issue} inline={inline} />
<div className="px-1 py-6 text-sm text-muted-foreground">
{planDocumentLoading ? (
"Loading plan…"
) : issue.workMode === "planning" ? (
<div className="space-y-2">
<p>This task is in plan mode but no plan document has been written yet.</p>
{pendingPlanConfirmation ? (
<p className="text-amber-foreground">
A plan confirmation is pending, but the plan document it should confirm is missing.
</p>
) : null}
</div>
) : (
"No plan yet. The plan document, accepted plans, and their revisions will appear here."
)}
</div>
</>
<div className="px-1 py-6 text-sm text-muted-foreground">
{planDocumentLoading ? (
"Loading plan…"
) : issue.workMode === "planning" ? (
<div className="space-y-2">
<p>This task is in plan mode but no plan document has been written yet.</p>
{pendingPlanConfirmation ? (
<p className="text-amber-foreground">
A plan confirmation is pending, but the plan document it should confirm is missing.
</p>
) : null}
</div>
) : (
"No plan yet. The plan document, accepted plans, and their revisions will appear here."
)}
</div>
);
}
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. */}
<IssuePlanConfirmationActionBar issue={issue} inline={inline} />
{/* 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="flex items-center gap-1 text-xs text-muted-foreground">

View File

@ -1,11 +1,11 @@
import { useState } from "react";
import type {
FeedbackDataSharingPreference,
FeedbackVoteValue,
} from "@paperclipai/shared";
import {
BubbleCopyButton,
IssueChatFeedbackButtons,
} from "@/components/AgentBubbleActionRow";
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 {
@ -22,10 +22,9 @@ export interface TaskChatBubbleFeedback {
* 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 BubbleCopyButton} and {@link IssueChatFeedbackButtons} so the
* redesigned task thread speaks the same footer language as the conference
* room's {@link AgentBubbleActionRow} without re-declaring their markup; the
* timestamp stays owned by the summary/bubble, so it is not duplicated here.
* {@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,
@ -34,9 +33,26 @@ export function TaskChatBubbleActions({
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">
<BubbleCopyButton copyText={copyText} />
<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}

View File

@ -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");
});

View File

@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TranscriptEntry } from "../../adapters";
import { TaskChatLiveRunPill, toolCountSummaryFromEntries } from "./TaskChatLiveRunPill";
function toolCall(overrides: Partial<Extract<TranscriptEntry, { kind: "tool_call" }>>): TranscriptEntry {
return { kind: "tool_call", ts: "2026-08-08T00:00:00.000Z", name: "read_file", input: {}, ...overrides };
}
describe("toolCountSummaryFromEntries", () => {
it("returns null when there are no tool calls", () => {
expect(toolCountSummaryFromEntries([])).toBeNull();
expect(
toolCountSummaryFromEntries([{ kind: "assistant", ts: "t", text: "hi" } as TranscriptEntry]),
).toBeNull();
});
it("counts commands and other tools separately with pluralization", () => {
const entries: TranscriptEntry[] = [
toolCall({ name: "bash", input: { command: "ls" }, toolUseId: "c1" }),
toolCall({ name: "command_execution", input: { command: "pwd" }, toolUseId: "c2" }),
toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
];
expect(toolCountSummaryFromEntries(entries)).toBe("ran 2 commands, called 1 tool");
});
it("dedupes re-emitted tool_calls that share a toolUseId", () => {
const entries: TranscriptEntry[] = [
toolCall({ name: "read_file", input: {}, toolUseId: "t1" }),
toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
toolCall({ name: "read_file", input: { path: "a.ts" }, toolUseId: "t1" }),
];
expect(toolCountSummaryFromEntries(entries)).toBe("called 1 tool");
});
});
describe("TaskChatLiveRunPill", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("shimmers 'Working' with elapsed + tool summary while streaming", () => {
const startedAtMs = Date.now() - 65_000; // ~1 minute ago
act(() => {
root.render(
<TaskChatLiveRunPill
status="running"
startedAtMs={startedAtMs}
toolSummary="called 3 tools"
/>,
);
});
const pill = container.querySelector('[data-testid="task-chat-live-run-pill"]');
expect(pill).not.toBeNull();
const shimmer = container.querySelector(".shimmer-text");
expect(shimmer?.textContent).toBe("Working");
expect(pill?.textContent).toContain("for 1 minute");
expect(pill?.textContent).toContain("called 3 tools");
});
it("settles to a static 'Worked' summary once the run is terminal", () => {
const startedAtMs = 1_000;
act(() => {
root.render(
<TaskChatLiveRunPill
status="succeeded"
startedAtMs={startedAtMs}
finishedAtMs={startedAtMs + 42_000}
toolSummary="ran 1 command"
/>,
);
});
const pill = container.querySelector('[data-testid="task-chat-live-run-pill"]');
expect(container.querySelector(".shimmer-text")).toBeNull();
expect(pill?.textContent).toContain("Worked");
expect(pill?.textContent).toContain("for 42 seconds");
expect(pill?.textContent).toContain("ran 1 command");
});
});

View File

@ -0,0 +1,88 @@
import { Loader2 } from "lucide-react";
import type { TranscriptEntry } from "../../adapters";
import { cn } from "@/lib/utils";
import { useSecondTick } from "@/hooks/useSecondTick";
import { formatDurationWords } from "@/lib/issue-chat-messages";
import { isCommandTool } from "@/lib/transcriptPresentation";
import { isTerminalRunStatus } from "@/components/task-chat/transcript-adapter";
/**
* "ran N commands, called M tools" for the live tail's status pill, counted off
* the streamed transcript entries. Mirrors IssueChatThread's `toolCountSummary`
* (which counts a settled message's tool-call parts) so the experimental live
* tail reads the same as the default view. Streaming runtimes re-emit a
* tool_call as its status progresses, so calls carrying a `toolUseId` are
* counted once.
*/
export function toolCountSummaryFromEntries(entries: readonly TranscriptEntry[]): string | null {
const seen = new Set<string>();
let commands = 0;
let other = 0;
for (const entry of entries) {
if (entry.kind !== "tool_call") continue;
if (entry.toolUseId) {
if (seen.has(entry.toolUseId)) continue;
seen.add(entry.toolUseId);
}
if (isCommandTool(entry.name, entry.input)) commands += 1;
else other += 1;
}
const parts: string[] = [];
if (commands > 0) parts.push(`ran ${commands} command${commands === 1 ? "" : "s"}`);
if (other > 0) parts.push(`called ${other} tool${other === 1 ? "" : "s"}`);
return parts.length > 0 ? parts.join(", ") : null;
}
/**
* Status pill for the experimental chat-style view's live tail (PAP-461, A1).
*
* Byte-for-byte the same affordance the default view shows above a run's chain
* of thought (IssueChatThread's CoT header): a spinner + shimmering "Working"
* verb + elapsed words + "· called N tools" while the run streams, settling to
* a static emerald dot + "Worked" summary once it terminates. The verb shimmers
* via `.shimmer-text` (reduced-motion aware) exactly as the default view does.
*/
export function TaskChatLiveRunPill({
status,
startedAtMs,
finishedAtMs,
toolSummary,
}: {
status: string;
/** Run start (startedAt, falling back to createdAt) in ms, or null if unknown. */
startedAtMs: number | null;
/** Run finish in ms once terminal; drives the settled elapsed readout. */
finishedAtMs?: number | null;
toolSummary: string | null;
}) {
const active = !isTerminalRunStatus(status);
// One shared page-wide ticker drives the live elapsed readout, matching the
// default view's `useLiveElapsed`.
useSecondTick(active && startedAtMs != null);
const elapsedMs =
startedAtMs == null ? null : (active ? Date.now() : finishedAtMs ?? Date.now()) - startedAtMs;
const elapsed = elapsedMs != null ? formatDurationWords(elapsedMs) : null;
const verb = active ? "Working" : "Worked";
const suffix = elapsed ? `for ${elapsed}` : null;
return (
<div
className="flex min-w-0 items-center gap-2.5 px-1 py-2"
data-testid="task-chat-live-run-pill"
>
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
{active ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
) : (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500/70" />
</span>
)}
{active ? <span className={cn("shimmer-text")}>{verb}</span> : verb}
</span>
{suffix ? <span className="text-xs text-muted-foreground/60">{suffix}</span> : null}
{toolSummary ? <span className="text-xs text-muted-foreground/40">· {toolSummary}</span> : null}
</div>
);
}

View File

@ -0,0 +1,138 @@
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TranscriptEntry } from "@/adapters";
import { ThemeProvider } from "@/context/ThemeContext";
import { TaskChatLiveTail } from "./TaskChatLiveTail";
import { transcriptToTaskChatItems } from "./transcript-adapter";
import type { TaskChatItem } from "./task-chat-model";
const TS = "2026-08-08T12:00:00.000Z";
describe("TaskChatLiveTail", () => {
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(items: TaskChatItem[], emptyMessage?: string) {
flushSync(() =>
root!.render(
<ThemeProvider>
<TaskChatLiveTail items={items} emptyMessage={emptyMessage} />
</ThemeProvider>,
),
);
}
function parse(entries: TranscriptEntry[], running = true) {
return transcriptToTaskChatItems(entries, { runId: "run-1", running });
}
it("renders streamed reply markdown and tool cards from a live transcript", () => {
const items = parse([
{ kind: "assistant", ts: TS, text: "Looking into the failing test." },
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" } },
{ kind: "tool_result", ts: TS, toolUseId: "t1", content: "ok", isError: false },
]);
render(items);
expect(container.querySelector('[data-testid="task-chat-live-text"]')?.textContent).toContain(
"Looking into the failing test.",
);
// Tool row renders with its name + mono target.
expect(container.textContent).toContain("Read");
expect(container.textContent).toContain("src/app.ts");
});
it("renders a tool's diff inset", () => {
const items = parse([
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "t1", input: { file_path: "a.ts" } },
{ kind: "diff", ts: TS, changeType: "add", text: "const x = 1;" },
{ kind: "diff", ts: TS, changeType: "remove", text: "const x = 0;" },
]);
render(items);
expect(container.textContent).toContain("const x = 1;");
expect(container.textContent).toContain("+1 1");
});
it("drops the debug plumbing kinds RunTranscriptView surfaced", () => {
// Feed the exact noise the board flagged: init row, stdout/stderr/system
// dumps, and a result line — interleaved with real content. None of the
// noise may reach the DOM; only the assistant text + tool row survive.
const items = parse([
{ kind: "init", ts: TS, model: "claude-opus", sessionId: "sess-INITMARKER" },
{ kind: "system", ts: TS, text: "SYSTEMNOISE hint about the environment" },
{ kind: "stdout", ts: TS, text: "STDOUTNOISE raw log line" },
{ kind: "stderr", ts: TS, text: "STDERRNOISE warning" },
{ kind: "assistant", ts: TS, text: "Here is the real reply." },
{ kind: "tool_call", ts: TS, name: "Bash", toolUseId: "t1", input: { command: "pnpm test" } },
{
kind: "result",
ts: TS,
text: "RESULTNOISE",
inputTokens: 10,
outputTokens: 5,
cachedTokens: 0,
costUsd: 0.01,
subtype: "success",
isError: false,
errors: [],
},
]);
render(items);
const text = container.textContent ?? "";
expect(text).toContain("Here is the real reply.");
expect(text).toContain("pnpm test");
// No debug plumbing, no RunTranscriptView chrome.
for (const noise of [
"INITMARKER",
"SYSTEMNOISE",
"STDOUTNOISE",
"STDERRNOISE",
"RESULTNOISE",
"Streaming",
"LOG LINES",
"SYSTEM MESSAGES",
]) {
expect(text).not.toContain(noise);
}
});
it("does not render a thinking row (its signal is the status pill)", () => {
const items = parse([
{ kind: "thinking", ts: TS, text: "SECRET internal reasoning" },
{ kind: "assistant", ts: TS, text: "Visible answer." },
]);
render(items);
expect(container.textContent).toContain("Visible answer.");
expect(container.textContent).not.toContain("SECRET internal reasoning");
});
it("shows the empty message when nothing renderable has streamed yet", () => {
render([], "Waiting to start...");
expect(container.textContent).toContain("Waiting to start...");
});
it("renders nothing (not even the empty message) once content exists", () => {
const items = parse([{ kind: "assistant", ts: TS, text: "streaming…" }]);
render(items, "Waiting to start...");
expect(container.textContent).not.toContain("Waiting to start...");
expect(container.textContent).toContain("streaming…");
});
});

View File

@ -0,0 +1,86 @@
import type { ReactElement } from "react";
import { MarkdownBody } from "@/components/MarkdownBody";
import type { TaskChatItem } from "./task-chat-model";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
/**
* Live-tail body for the experimental chat-style view (PAP-463, Workstream C1
* of PAP-458).
*
* Renders the in-flight run's streaming transcript as the SAME clean rows the
* settled thread uses tool cards (with diffs) and the streamed reply markdown
* instead of the verbatim `RunTranscriptView` debug viewer that the live tail
* used since `e4f3d7733`. The items come from `transcriptToTaskChatItems`, which
* already drops the debug plumbing (init / stdout / stderr / system / user /
* result), so none of `RunTranscriptView`'s noise can reach the thread: no INIT
* row, no "N LOG LINES" / "N SYSTEM MESSAGES" banners, no raw stdout/JSON dumps,
* no "Streaming" chip, no uppercase "USED TERMINAL" cards. The status pill above
* this body (`TaskChatLiveRunPill`) owns the run-status affordance.
*
* Live and settle-gap render identically both feed their parsed items here
* (`running: true` while in flight, `false` through the settle gap) so the
* tail never restyles when a run finishes; the hand-off to the folded settled
* turn is the only visible transition.
*/
export function TaskChatLiveTail({
items,
emptyMessage,
}: {
items: readonly TaskChatItem[];
/** Shown when nothing renderable has streamed yet (queued / pre-first-token). */
emptyMessage?: string;
}) {
const rows = items
.map((item) => renderTailRow(item))
.filter((row): row is ReactElement => row != null);
if (rows.length === 0) {
return emptyMessage ? (
<div className="px-1 py-1 text-xs text-muted-foreground/70">{emptyMessage}</div>
) : null;
}
return <div className="flex flex-col gap-2">{rows}</div>;
}
function renderTailRow(item: TaskChatItem): ReactElement | null {
switch (item.kind) {
case "message": {
// Streamed reply text (always interstitial from the transcript adapter).
// Rendered as plain markdown — the settled thread later replaces it with
// the posted comment bubble, so no author header/bubble chrome here.
const text = item.text.trim();
if (!text) return null;
return (
<div
key={item.id}
className="px-1 text-sm text-foreground/90"
data-testid="task-chat-live-text"
>
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
</div>
);
}
case "tool":
return (
<div key={item.id}>
<TaskChatToolCard item={item} />
</div>
);
case "usage":
return (
<div key={item.id}>
<TaskChatUsageReadout item={item} />
</div>
);
// Thinking never renders as a row (PAP-361): its live signal is the status
// pill, and the text stays in the run log / classic transcript. Every other
// kind (markers, interactions, briefs, statuses, turns, and the dropped
// debug kinds) cannot appear in a parsed live transcript.
default:
return null;
}
}

View File

@ -40,6 +40,10 @@ interface TaskChatThreadViewProps {
* fixtures omit it and the bubbles render actionless.
*/
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
/** Content appended inside the transcript scroller after the settled thread. */
tail?: ReactNode;
/** Optional streaming-aware key when `tail` changes without changing `items`. */
contentKey?: number;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
@ -128,6 +132,8 @@ export function TaskChatThreadView({
renderInteraction,
renderBrief,
renderMessageActions,
tail,
contentKey,
className,
scroll = true,
}: TaskChatThreadViewProps) {
@ -143,12 +149,17 @@ export function TaskChatThreadView({
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)}
</div>
))}
{tail}
</div>
);
if (!scroll) return body;
return <TaskMessageScroller contentKey={taskChatContentKey(items)}>{body}</TaskMessageScroller>;
return (
<TaskMessageScroller contentKey={contentKey ?? taskChatContentKey(items)}>
{body}
</TaskMessageScroller>
);
}
// Cheap content signature so streaming growth (text lengthening without the

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -1,6 +1,6 @@
/**
* Normalized presentation model for the Task Chat Redesign (flag:
* enableTaskChatRedesign).
* Normalized presentation model for the chat-style task thread (the default
* task view; the classic legacy view sits behind enableClassicTaskInterface).
*
* This is a deliberately small, protocol-agnostic model that the new render
* layer consumes. Two producers feed it:

View File

@ -1,6 +1,6 @@
/**
* Canonical state inventory for the Task Chat Redesign (flag:
* enableTaskChatRedesign).
* Canonical state inventory for the chat-style task thread (the default task
* view; the classic legacy view sits behind enableClassicTaskInterface).
*
* This list is the single source of truth for:
* - the dev harness state switcher (/dev/task-chat-lab), and

View File

@ -6,7 +6,7 @@ import { parseAcpxStdoutLine } from "@paperclipai/adapter-utils/acpx-engine/ui";
import { buildTranscript, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import type { ToolRunDecision } from "@paperclipai/shared";
import { ThemeProvider } from "../../context/ThemeContext";
import { RunTranscriptView, normalizeTranscript } from "./RunTranscriptView";
import { RunTranscriptView, keyTranscriptBlocks, normalizeTranscript } from "./RunTranscriptView";
describe("RunTranscriptView", () => {
it("folds repeated tool_call status updates for the same toolUseId into one block", () => {
@ -322,4 +322,48 @@ describe("RunTranscriptView", () => {
expect(html).not.toContain("line-250");
expect(html).not.toContain("line-499");
});
it("keeps the streaming tail block's key stable as deltas merge in", () => {
// A streaming assistant message accumulates deltas; each delta advances the
// block's `ts`. The React key must stay anchored to the block's opening
// timestamp so the tail does not unmount/remount (restarting its fade).
const firstDelta: TranscriptEntry[] = [
{ kind: "assistant", ts: "2026-03-12T00:00:00.000Z", text: "Hel", delta: true },
];
const withMoreDeltas: TranscriptEntry[] = [
...firstDelta,
{ kind: "assistant", ts: "2026-03-12T00:00:00.400Z", text: "lo the", delta: true },
{ kind: "assistant", ts: "2026-03-12T00:00:00.900Z", text: "re", delta: true },
];
const keyOf = (entries: TranscriptEntry[]) => {
const keyed = keyTranscriptBlocks(normalizeTranscript(entries, true));
return keyed[keyed.length - 1]!.key;
};
// Same opening timestamp → identical key even though `ts` advanced.
expect(keyOf(withMoreDeltas)).toBe(keyOf(firstDelta));
// And that key is anchored to the opening ts, not the latest one.
expect(keyOf(withMoreDeltas)).toContain("2026-03-12T00:00:00.000Z");
expect(keyOf(withMoreDeltas)).not.toContain("2026-03-12T00:00:00.900Z");
});
it("assigns unique keys and does not remount earlier blocks when a new tail arrives", () => {
const base: TranscriptEntry[] = [
{ kind: "assistant", ts: "2026-03-12T00:00:00.000Z", text: "first message", delta: true },
{ kind: "thinking", ts: "2026-03-12T00:00:01.000Z", text: "pondering", delta: true },
];
const withNewTail: TranscriptEntry[] = [
...base,
{ kind: "assistant", ts: "2026-03-12T00:00:02.000Z", text: "second message", delta: true },
];
const baseKeys = keyTranscriptBlocks(normalizeTranscript(base, true)).map((b) => b.key);
const nextKeys = keyTranscriptBlocks(normalizeTranscript(withNewTail, true)).map((b) => b.key);
// Keys are unique within a render.
expect(new Set(nextKeys).size).toBe(nextKeys.length);
// Existing blocks keep their identity when a new block appends after them.
expect(nextKeys.slice(0, baseKeys.length)).toEqual(baseKeys);
});
});

View File

@ -48,12 +48,18 @@ type TranscriptBlock =
type: "message";
role: "assistant" | "user";
ts: string;
// Timestamp of the first entry that opened this block. `ts` tracks the
// latest merged delta and mutates every chunk; `startTs` stays fixed so
// the React key is stable and the streaming block does not remount (and
// restart its fade) on each delta.
startTs: string;
text: string;
streaming: boolean;
}
| {
type: "thinking";
ts: string;
startTs: string;
text: string;
streaming: boolean;
}
@ -73,6 +79,7 @@ type TranscriptBlock =
| {
type: "activity";
ts: string;
startTs: string;
activityId?: string;
name: string;
status: "running" | "completed";
@ -125,6 +132,7 @@ type TranscriptBlock =
| {
type: "stdout";
ts: string;
startTs: string;
text: string;
}
| {
@ -550,6 +558,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
type: "message",
role: entry.kind,
ts: entry.ts,
startTs: entry.ts,
text: entry.text,
streaming: isStreaming,
});
@ -567,6 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
blocks.push({
type: "thinking",
ts: entry.ts,
startTs: entry.ts,
text: entry.text,
streaming: isStreaming,
});
@ -691,6 +701,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
const block: Extract<TranscriptBlock, { type: "activity" }> = {
type: "activity",
ts: entry.ts,
startTs: entry.ts,
activityId: activity.activityId,
name: activity.name,
status: activity.status,
@ -758,6 +769,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
blocks.push({
type: "stdout",
ts: entry.ts,
startTs: entry.ts,
text: entry.text,
});
}
@ -766,6 +778,55 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
return groupToolBlocks(groupCommandBlocks(blocks));
}
/**
* Stable identity for a block's React key. Anchored to the block's opening
* timestamp (`startTs`) or a durable id (`toolUseId`, `activityId`) rather than
* its latest `ts`, which mutates on every streamed delta. A mutating key
* remounts the block, restarting its 300ms fade-in so the text visibly blinks
* out and back each chunk; a stable one keeps the streaming tail mounted.
*/
function transcriptBlockIdentity(block: TranscriptBlock): string {
switch (block.type) {
case "message":
return `message:${block.role}:${block.startTs}`;
case "thinking":
return `thinking:${block.startTs}`;
case "stdout":
return `stdout:${block.startTs}`;
case "activity":
return `activity:${block.activityId ?? block.startTs}`;
case "tool":
return `tool:${block.toolUseId ?? block.ts}`;
case "command_group":
return `command_group:${block.ts}`;
case "tool_group":
return `tool_group:${block.ts}`;
case "stderr_group":
return `stderr_group:${block.ts}`;
case "system_group":
return `system_group:${block.ts}`;
case "diff_group":
return `diff_group:${block.ts}`;
case "event":
return `event:${block.label}:${block.ts}`;
}
}
/**
* Assign each block a stable, unique React key. Identity is position-independent
* so earlier blocks collapsing (truncation) does not remount the survivors; a
* per-render occurrence counter disambiguates the rare identity collision.
*/
export function keyTranscriptBlocks(blocks: TranscriptBlock[]): Array<{ block: TranscriptBlock; key: string }> {
const seen = new Map<string, number>();
return blocks.map((block) => {
const identity = transcriptBlockIdentity(block);
const occurrence = seen.get(identity) ?? 0;
seen.set(identity, occurrence + 1);
return { block, key: occurrence === 0 ? identity : `${identity}#${occurrence}` };
});
}
function TranscriptMessageBlock({
block,
density,
@ -789,7 +850,9 @@ function TranscriptMessageBlock({
<MarkdownBody
className={cn(
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
compact ? "text-xs leading-5 text-foreground/85" : "text-sm",
// Match the default view's chat message body (IssueChatThread:
// `text-sm leading-6`) so streamed text reads identically (PAP-461, A2).
compact ? "text-xs leading-5 text-foreground/85" : "text-sm leading-6",
)}
externalReferences={externalReferences}
>
@ -798,7 +861,7 @@ function TranscriptMessageBlock({
{block.streaming && (
<div className="mt-2 inline-flex items-center gap-1 text-(length:--text-nano) font-medium italic text-muted-foreground">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-70" />
<span className="tc-live-ping absolute inline-flex h-full w-full rounded-full bg-current opacity-70" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
Streaming
@ -822,8 +885,11 @@ function TranscriptThinkingBlock({
return (
<MarkdownBody
className={cn(
"italic text-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
density === "compact" ? "text-(length:--text-micro) leading-5" : "text-sm leading-6",
// Match the default view's chain-of-thought text (IssueChatThread:
// `text-(length:--text-compact) italic leading-5 text-muted-foreground/70`)
// so streamed thinking reads identically across both views (PAP-461, A2).
"italic text-muted-foreground/70 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
density === "compact" ? "text-(length:--text-micro) leading-5" : "text-(length:--text-compact) leading-5",
className,
)}
externalReferences={externalReferences}
@ -1304,7 +1370,7 @@ function TranscriptActivityRow({
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-300" />
) : (
<span className="relative mt-1 flex h-2.5 w-2.5 shrink-0">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-70" />
<span className="tc-live-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-70" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
</span>
)}
@ -1716,6 +1782,7 @@ export function RunTranscriptView({
[entries, mode, streaming],
);
const visibleBlocks = limit ? blocks.slice(-limit) : blocks;
const keyedBlocks = useMemo(() => keyTranscriptBlocks(visibleBlocks), [visibleBlocks]);
const visibleEntries = limit ? entries.slice(-limit) : entries;
if (entries.length === 0) {
@ -1736,10 +1803,10 @@ export function RunTranscriptView({
return (
<div className={cn("space-y-3", className)}>
{visibleBlocks.map((block, index) => (
{keyedBlocks.map(({ block, key }, index) => (
<div
key={`${block.type}-${block.ts}-${index}`}
className={cn(index === visibleBlocks.length - 1 && streaming && "animate-in fade-in slide-in-from-bottom-1 duration-300")}
key={key}
className={cn(index === keyedBlocks.length - 1 && streaming && "tc-stream-block-enter")}
>
{block.type === "message" && (
<TranscriptMessageBlock

View File

@ -515,4 +515,112 @@ describe("useLiveRunTranscripts", () => {
});
container.remove();
});
it("retains an accumulated buffer through a transient empty poll (PAP-462 B3)", async () => {
const ts = "2026-08-08T00:00:00.000Z";
const row = JSON.stringify({
ts,
stream: "stdout",
chunk: '{"type":"acpx.text_delta","text":"hello"}\n',
seq: 1,
});
// Serve the buffered chunk on the FIRST persisted-log read only; every later
// read (including after the run reappears) returns nothing new. So the chunk
// can only be present the second time if the buffer survived the gap.
logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 100 });
logMock.mockResolvedValueOnce({ runId: "run-1", store: "memory", logRef: "log-1", content: `${row}\n`, nextOffset: 100 });
const captured: { value: ReturnType<typeof useLiveRunTranscripts> | null } = { value: null };
function Harness({ runs }: { runs: Array<{ id: string; status: string; adapterType: string }> }) {
captured.value = useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const runList = [{ id: "run-1", status: "running", adapterType: "gemini_local" }];
await act(async () => {
root.render(<Harness runs={runList} />);
await Promise.resolve();
});
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
// Transient empty poll: run momentarily absent from the list.
await act(async () => {
root.render(<Harness runs={[]} />);
await Promise.resolve();
});
// Run reappears within the grace window — the buffer must survive rather than
// re-hydrate from a (now empty) truncated read.
await act(async () => {
root.render(<Harness runs={runList} />);
await Promise.resolve();
});
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
act(() => {
root.unmount();
});
container.remove();
});
it("prunes a buffer once a run stays absent past the grace window (PAP-462 B3)", async () => {
vi.useFakeTimers();
try {
const ts = "2026-08-08T00:00:00.000Z";
const row = JSON.stringify({
ts,
stream: "stdout",
chunk: '{"type":"acpx.text_delta","text":"hello"}\n',
seq: 1,
});
logMock.mockResolvedValue({ runId: "run-1", store: "memory", logRef: "log-1", content: "", nextOffset: 100 });
logMock.mockResolvedValueOnce({ runId: "run-1", store: "memory", logRef: "log-1", content: `${row}\n`, nextOffset: 100 });
const captured: { value: ReturnType<typeof useLiveRunTranscripts> | null } = { value: null };
function Harness({ runs }: { runs: Array<{ id: string; status: string; adapterType: string }> }) {
captured.value = useLiveRunTranscripts({ companyId: "company-1", runs, enableRealtimeUpdates: false });
return null;
}
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const runList = [{ id: "run-1", status: "running", adapterType: "gemini_local" }];
await act(async () => {
root.render(<Harness runs={runList} />);
await Promise.resolve();
});
expect(captured.value?.transcriptByRun.get("run-1")).toHaveLength(1);
await act(async () => {
root.render(<Harness runs={[]} />);
await Promise.resolve();
});
// Stay absent long enough for the grace window to lapse and the deferred
// prune to fire.
await act(async () => {
await vi.advanceTimersByTimeAsync(25_000);
});
// On reappear the buffer is gone, so the (now empty) read rebuilds nothing.
await act(async () => {
root.render(<Harness runs={runList} />);
await Promise.resolve();
});
expect(captured.value?.transcriptByRun.get("run-1") ?? []).toHaveLength(0);
act(() => {
root.unmount();
});
container.remove();
} finally {
vi.useRealTimers();
}
});
});

View File

@ -11,6 +11,7 @@ import {
mergeRunLogChunks,
parsePersistedLogContent,
readChunkSeq,
type ChunkRetentionBudget,
} from "../../lib/run-log-chunks";
// TODO(perf): this whole hook polls the log/runs endpoints on an interval. The
@ -24,6 +25,20 @@ const LOG_READ_LIMIT_BYTES = 256_000;
// gaps and reconnects instead of polling every couple of seconds.
const REALTIME_FALLBACK_POLL_INTERVAL_MS = 30_000;
const EMPTY_RUN_LOG_CHUNKS: RunLogChunk[] = [];
// Retained transcript payload budget for full task views. A byte budget (rather
// than a tiny chunk count) keeps the whole streamed scrollback intact — a
// delta-streaming run emits thousands of one-token chunks in seconds, and the
// old 200-chunk cap discarded just-rendered messages off the top irreversibly.
// If a run genuinely exceeds this, the oldest output collapses behind a visible
// marker instead of vanishing (see `applyRetentionBudget`).
const TASK_VIEW_MAX_BYTES_PER_RUN = 2_000_000;
// Grace period before an accumulated transcript buffer is pruned for a run that
// has vanished from the `runs` list. The parent refetches runs on its own
// interval, and a single transient empty/errored poll would otherwise wipe the
// buffer — forcing a rehydration that skips to the last `LOG_READ_LIMIT_BYTES`
// and silently drops already-rendered scrollback (PAP-462 B3). A run that is
// genuinely gone stays absent past this window and is then pruned as before.
const RUN_ABSENCE_PRUNE_GRACE_MS = 20_000;
export interface RunTranscriptSource {
id: string;
@ -37,7 +52,13 @@ export interface RunTranscriptSource {
interface UseLiveRunTranscriptsOptions {
runs: RunTranscriptSource[];
companyId?: string | null;
/**
* Compact chunk-count cap for ticker-style consumers (dashboard). When set,
* trimming is silent the historical behavior. Full task views omit this and
* use the byte budget below instead.
*/
maxChunksPerRun?: number;
maxBytesPerRun?: number;
logPollIntervalMs?: number;
logReadLimitBytes?: number;
enableRealtimeUpdates?: boolean;
@ -67,11 +88,21 @@ export function resolveInitialLogOffset(run: RunTranscriptSource, limitBytes: nu
export function useLiveRunTranscripts({
runs,
companyId,
maxChunksPerRun = 200,
maxChunksPerRun,
maxBytesPerRun = TASK_VIEW_MAX_BYTES_PER_RUN,
logPollIntervalMs = LOG_POLL_INTERVAL_MS,
logReadLimitBytes = LOG_READ_LIMIT_BYTES,
enableRealtimeUpdates = true,
}: UseLiveRunTranscriptsOptions) {
// Ticker consumers opt into the silent chunk-count cap; full task views use a
// byte budget that collapses (not discards) the oldest output when exceeded.
const retentionBudget: ChunkRetentionBudget = useMemo(
() =>
typeof maxChunksPerRun === "number"
? { maxChunks: maxChunksPerRun }
: { maxBytes: maxBytesPerRun, collapseTrimmed: true },
[maxChunksPerRun, maxBytesPerRun],
);
const runsKey = useMemo(
() =>
runs
@ -95,6 +126,14 @@ export function useLiveRunTranscripts({
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const missingTerminalLogRunIdsRef = useRef(new Set<string>());
// PAP-462 B3: buffered runs that dropped out of the `runs` list, mapped to the
// wall-clock deadline (ms) after which their buffer may be pruned. A run still
// inside its grace window is retained across the empty poll; `pruneTick` fires
// the effect again once the nearest deadline elapses so a run that stays gone
// is eventually cleaned up even if the `runs` list never changes again.
const absenceDeadlineByRunRef = useRef(new Map<string, number>());
const prevKnownRunIdsRef = useRef(new Set<string>());
const [pruneTick, setPruneTick] = useState(0);
const transcriptCacheRef = useRef(new Map<string, {
adapterType: string;
chunks: RunLogChunk[];
@ -134,7 +173,7 @@ export function useLiveRunTranscripts({
seenChunkKeys: seenChunkKeysRef.current,
trimmedSeqFloorByRun: trimmedSeqFloorByRunRef.current,
},
maxChunksPerRun,
retentionBudget,
);
if (!changed) return prev;
const next = new Map(prev);
@ -145,10 +184,40 @@ export function useLiveRunTranscripts({
useEffect(() => {
const knownRunIds = new Set(normalizedRuns.map((run) => run.id));
const now = Date.now();
const deadlines = absenceDeadlineByRunRef.current;
// PAP-462 B3: a run that just disappeared from the list starts its grace
// clock; one that reappeared clears any pending deadline. Comparing against
// the previous known set means a transient empty poll only *arms* the timer
// rather than pruning the buffer outright.
for (const runId of prevKnownRunIdsRef.current) {
if (!knownRunIds.has(runId) && !deadlines.has(runId)) {
deadlines.set(runId, now + RUN_ABSENCE_PRUNE_GRACE_MS);
}
}
for (const runId of knownRunIds) {
deadlines.delete(runId);
}
prevKnownRunIdsRef.current = knownRunIds;
// Retain known runs plus any absent run still inside its grace window; only
// runs absent past their deadline are actually pruned.
const retainedRunIds = new Set(knownRunIds);
let soonestExpiryMs = Number.POSITIVE_INFINITY;
for (const [runId, deadline] of deadlines) {
if (deadline > now) {
retainedRunIds.add(runId);
soonestExpiryMs = Math.min(soonestExpiryMs, deadline);
} else {
deadlines.delete(runId);
}
}
setChunksByRun((prev) => {
const next = new Map<string, RunLogChunk[]>();
for (const [runId, chunks] of prev) {
if (knownRunIds.has(runId)) {
if (retainedRunIds.has(runId)) {
next.set(runId, chunks);
}
}
@ -157,7 +226,7 @@ export function useLiveRunTranscripts({
setHydratedRunIds((prev) => {
const next = new Set<string>();
for (const runId of prev) {
if (knownRunIds.has(runId)) {
if (retainedRunIds.has(runId)) {
next.add(runId);
}
}
@ -166,31 +235,41 @@ export function useLiveRunTranscripts({
for (const key of pendingLogRowsByRunRef.current.keys()) {
const runId = key.replace(/:records$/, "");
if (!knownRunIds.has(runId)) {
if (!retainedRunIds.has(runId)) {
pendingLogRowsByRunRef.current.delete(key);
}
}
for (const runId of logOffsetByRunRef.current.keys()) {
if (!knownRunIds.has(runId)) {
if (!retainedRunIds.has(runId)) {
logOffsetByRunRef.current.delete(runId);
}
}
for (const runId of trimmedSeqFloorByRunRef.current.keys()) {
if (!knownRunIds.has(runId)) {
if (!retainedRunIds.has(runId)) {
trimmedSeqFloorByRunRef.current.delete(runId);
}
}
for (const runId of missingTerminalLogRunIdsRef.current.keys()) {
if (!knownRunIds.has(runId)) {
if (!retainedRunIds.has(runId)) {
missingTerminalLogRunIdsRef.current.delete(runId);
}
}
for (const runId of transcriptCacheRef.current.keys()) {
if (!knownRunIds.has(runId)) {
if (!retainedRunIds.has(runId)) {
transcriptCacheRef.current.delete(runId);
}
}
}, [normalizedRuns]);
// Re-run once the nearest grace window elapses so a run that stays gone is
// pruned even if `normalizedRuns` never changes again.
if (soonestExpiryMs !== Number.POSITIVE_INFINITY) {
const timer = window.setTimeout(
() => setPruneTick((tick) => tick + 1),
Math.max(0, soonestExpiryMs - now) + 50,
);
return () => window.clearTimeout(timer);
}
}, [normalizedRuns, pruneTick]);
useEffect(() => {
if (normalizedRuns.length === 0) return;

View File

@ -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)" },

View File

@ -16,19 +16,16 @@ function getDetachedClient(): QueryClient {
}
/**
* Task Chat Redesign experimental flag.
* Classic Task Interface experimental flag (`enableClassicTaskInterface`).
*
* Wraps the shared experimental-settings query so gated call sites don't
* repeat the boilerplate. `enabled` stays false while the query is in flight
* (no flash of gated UI); `loaded` lets route gates avoid redirecting away
* before the flag value is actually known.
*
* Renders without a QueryClientProvider resolve to the flag-off default
* (`{ enabled: false, loaded: true }`) instead of throwing, so widely shared
* leaf components (e.g. the task detail thread) stay mountable in isolation
* and, critically, flag-off is provably the current behavior.
* repeat the boilerplate. Fail-closed to chat-style: `enabled` stays false
* while the query is in flight, on fetch errors, and in renders without a
* QueryClientProvider (isolated unit-test mounts), so the default chat-style
* view is what renders unless the classic opt-in is provably on. `loaded`
* lets hosts that care distinguish "flag is off" from "flag not yet known".
*/
export function useTaskChatRedesignEnabled(): { enabled: boolean; loaded: boolean } {
export function useClassicTaskInterfaceEnabled(): { enabled: boolean; loaded: boolean } {
const contextClient = useContext(QueryClientContext);
const { data, isFetched } = useQuery(
{
@ -41,5 +38,5 @@ export function useTaskChatRedesignEnabled(): { enabled: boolean; loaded: boolea
if (!contextClient) {
return { enabled: false, loaded: true };
}
return { enabled: data?.enableTaskChatRedesign === true, loaded: isFetched };
return { enabled: data?.enableClassicTaskInterface === true, loaded: isFetched };
}

View File

@ -196,7 +196,8 @@
--sz-tweak-panel-max: 70vh; /* Task Chat Redesign dev tweak panel scroll cap. */
/*
Motion tokens Task Chat Redesign (flag: enableTaskChatRedesign).
Motion tokens chat-style task thread (default; classic legacy view sits
behind enableClassicTaskInterface).
These are the single source of truth for the redesigned thread's motion.
Kept in :root (NOT @theme inline) on purpose: @theme inline bakes literals
@ -548,7 +549,8 @@
}
/*
Task Chat Redesign animation utilities (flag: enableTaskChatRedesign).
Chat-style task thread animation utilities (default view; classic legacy
view sits behind enableClassicTaskInterface).
These classes are the ONLY place the redesigned thread's motion is applied;
every duration/easing is a var(--motion-*) reference (never a literal), so
@ -841,6 +843,35 @@
}
}
/* RunTranscriptView live-tail motion (PAP-461, A3). The experimental chat-style
view streams its live output through RunTranscriptView; these token-driven
classes replace the raw Tailwind `animate-in duration-300` / `animate-ping`
the tail used so its streaming affordances honor --motion-* retuning AND the
reduced-motion override below, exactly like the rest of the redesign. */
@keyframes tc-stream-block-in {
from { opacity: 0; transform: translateY(0.25rem); }
to { opacity: 1; transform: translateY(0); }
}
.tc-stream-block-enter {
animation: tc-stream-block-in var(--motion-tool-enter) var(--motion-ease-standard) both;
}
@keyframes tc-live-ping {
75%, 100% { transform: scale(2); opacity: 0; }
}
.tc-live-ping {
animation: tc-live-ping var(--motion-approval-pulse) var(--motion-ease-standard) infinite;
}
@media (prefers-reduced-motion: reduce) {
.tc-stream-block-enter,
.tc-live-ping {
animation: none;
}
}
/* Shimmer text effect for active "Working" state — Cursor-style sweep */
@keyframes shimmer-text-slide {
0% { background-position: 100% center; }

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.

View File

@ -10,6 +10,8 @@ import {
type IssueChatLinkedRun,
} from "./issue-chat-messages";
import type {
AskUserQuestionsInteraction,
AskUserQuestionsQuestion,
RequestConfirmationInteraction,
SuggestTasksInteraction,
} from "./issue-thread-interactions";
@ -719,6 +721,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",

View File

@ -13,6 +13,7 @@ import { formatAssigneeUserLabel } from "./assignees";
import { isOperatorInterruptedRun } from "./interrupt-handoff";
import {
buildIssueThreadInteractionSummary,
shouldHideInteractionCard,
type IssueThreadInteraction,
} from "./issue-thread-interactions";
import type { IssueTimelineEvent } from "./issue-timeline-events";
@ -1091,6 +1092,11 @@ export function buildIssueChatMessages(args: {
}
for (const interaction of sortByCreated(interactions)) {
// A card IssueThreadInteractionCard never renders — a degenerate
// `ask_user_questions` (e.g. the onboarding `Test / A` placeholder) or a
// stale sibling superseded by a newer question (PAP-437) — is skipped here so
// it leaves no empty message slot in the thread (PAP-424, plan from PAP-420).
if (shouldHideInteractionCard(interaction)) continue;
const createdAtMs = toTimestamp(interaction.createdAt);
const handoffAtMs = interaction.kind === "request_confirmation" && interaction.sourceRunId
? latestSameRunHandoffTimestamp({

View File

@ -8,9 +8,17 @@ import {
getItemVerdictProgress,
getRequestConfirmationTargetHref,
getQuestionAnswerLabels,
isDegenerateAskUserQuestions,
isSupersededByNewerSiblingInteraction,
shouldHideInteractionCard,
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 +381,211 @@ 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);
});
});
describe("isSupersededByNewerSiblingInteraction", () => {
function askInteraction(overrides: Partial<AskUserQuestionsInteraction>): AskUserQuestionsInteraction {
return {
id: "interaction-ask",
companyId: "company-1",
issueId: "issue-1",
kind: "ask_user_questions",
status: "expired",
continuationPolicy: "wake_assignee",
...resolverPolicyFields,
createdAt: "2026-04-06T12:00:00.000Z",
updatedAt: "2026-04-06T12:00:00.000Z",
payload: {
version: 1,
questions: [{ id: "q", prompt: "t", selectionMode: "single", options: [{ id: "L", label: "L" }] }],
},
...overrides,
} as AskUserQuestionsInteraction;
}
it("hides an expired card superseded by a newer sibling question", () => {
const interaction = askInteraction({
status: "expired",
result: {
version: 1,
answers: [],
expirationReason: "superseded_by_newer_interaction",
supersededByInteractionId: "interaction-newer",
summaryMarkdown: null,
},
});
expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(true);
expect(shouldHideInteractionCard(interaction)).toBe(true);
});
it("never hides a still-pending card (would strand the assignee)", () => {
// Guards the PAP-424 / 00b136f45 invariant: a pending question must always
// render even if the result somehow already names a superseding sibling.
const interaction = askInteraction({
status: "pending",
result: {
version: 1,
answers: [],
expirationReason: "superseded_by_newer_interaction",
supersededByInteractionId: "interaction-newer",
summaryMarkdown: null,
},
});
expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(false);
expect(shouldHideInteractionCard(interaction)).toBe(false);
});
it("keeps the stale notice for a comment-superseded card (does not hide it)", () => {
const interaction = askInteraction({
status: "expired",
result: {
version: 1,
answers: [],
expirationReason: "superseded_by_comment",
commentId: "11111111-1111-1111-1111-111111111111",
summaryMarkdown: null,
},
});
expect(isSupersededByNewerSiblingInteraction(interaction)).toBe(false);
expect(shouldHideInteractionCard(interaction)).toBe(false);
});
it("still hides a degenerate card through the combined predicate", () => {
const degenerate = askInteraction({
status: "pending",
result: null,
payload: { version: 1, questions: [] },
});
expect(isSupersededByNewerSiblingInteraction(degenerate)).toBe(false);
expect(shouldHideInteractionCard(degenerate)).toBe(true);
});
});

View File

@ -304,3 +304,81 @@ 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);
}
/**
* A stale sibling `ask_user_questions` that the server auto-expired when its own
* creator posted a newer one on the same issue (PAP-437). The replacement card
* is already in the thread, so this expired shell adds nothing and is never
* drawn. Gated on `status === "expired"` so a still-pending card is never hidden
* (PAP-424 / 00b136f45: hiding a pending question would strand the assignee).
* Distinct from `superseded_by_comment`, which keeps its stale notice.
*/
export function isSupersededByNewerSiblingInteraction(
interaction: IssueThreadInteraction,
): boolean {
if (interaction.kind !== "ask_user_questions") return false;
if (interaction.status !== "expired") return false;
return interaction.result?.expirationReason === "superseded_by_newer_interaction";
}
/**
* Single enforcement point for whether an interaction card should be suppressed
* from every thread surface. Routing all render sites through this one predicate
* keeps composition backbones and the card in lockstep, so a suppressed card
* never leaves an empty slot in one place while another still draws it.
*/
export function shouldHideInteractionCard(
interaction: IssueThreadInteraction,
): boolean {
return (
isDegenerateAskUserQuestions(interaction)
|| isSupersededByNewerSiblingInteraction(interaction)
);
}

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,
});
});
});

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,
};
}

View File

@ -1,10 +1,13 @@
import { describe, expect, it } from "vitest";
import type { RunLogChunk } from "../adapters";
import {
applyRetentionBudget,
isStructuredStreamingTextDelta,
isTrimmedOutputMarkerChunk,
mergeRunLogChunks,
parsePersistedLogContent,
readChunkSeq,
TRIMMED_OUTPUT_MARKER_TEXT,
type ChunkMergeRefs,
type IncomingRunLogChunk,
} from "./run-log-chunks";
@ -127,4 +130,79 @@ describe("mergeRunLogChunks", () => {
expect(result.chunks).toBe(state);
expect(result.changed).toBe(false);
});
it("retains far more than the old 200-chunk cap under a byte budget", () => {
const refs = freshRefs();
let state: RunLogChunk[] = [];
// 500 one-byte delta chunks — the old count cap would have dropped 300 of
// them off the top irreversibly. A generous byte budget keeps them all.
for (let seq = 1; seq <= 500; seq += 1) {
({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(seq, "x")], refs, { maxBytes: 10_000 }));
}
expect(state).toHaveLength(500);
expect(state.some(isTrimmedOutputMarkerChunk)).toBe(false);
expect(state[0]!.chunk).toBe("x");
});
it("collapses the oldest output behind a single visible marker instead of discarding it", () => {
const refs = freshRefs();
let state: RunLogChunk[] = [];
// Each chunk is 10 units; a 25-unit budget keeps only the newest two.
const ten = "0123456789";
for (let seq = 1; seq <= 4; seq += 1) {
({ chunks: state } = mergeRunLogChunks("r", state, [seqChunk(seq, ten)], refs, {
maxBytes: 25,
collapseTrimmed: true,
}));
}
// First element is the marker; the two newest real chunks follow.
expect(isTrimmedOutputMarkerChunk(state[0]!)).toBe(true);
expect(state[0]!.chunk).toBe(TRIMMED_OUTPUT_MARKER_TEXT);
expect(state.slice(1).map((c) => c.seq)).toEqual([3, 4]);
// Exactly one marker — repeated trims do not stack markers.
expect(state.filter(isTrimmedOutputMarkerChunk)).toHaveLength(1);
// Trimmed seq floor tracks the removed records so re-delivery is dropped.
expect(refs.trimmedSeqFloorByRun.get("r")).toBe(2);
});
});
describe("applyRetentionBudget", () => {
const chunk = (seq: number, text: string): RunLogChunk => ({ ts: `t${seq}`, stream: "stdout", chunk: text, seq });
it("leaves chunks untouched when within budget", () => {
const chunks = [chunk(1, "aa"), chunk(2, "bb")];
const result = applyRetentionBudget(chunks, { maxBytes: 100, collapseTrimmed: true });
expect(result.chunks).toBe(chunks);
expect(result.trimmedSeq).toBeNull();
});
it("keeps the newest chunk even when it alone exceeds the byte budget", () => {
const chunks = [chunk(1, "0123456789")];
const result = applyRetentionBudget(chunks, { maxBytes: 3, collapseTrimmed: true });
expect(result.chunks).toEqual(chunks);
expect(result.chunks.some(isTrimmedOutputMarkerChunk)).toBe(false);
});
it("discards silently (no marker) when collapseTrimmed is off", () => {
const chunks = [chunk(1, "a"), chunk(2, "b"), chunk(3, "c")];
const result = applyRetentionBudget(chunks, { maxChunks: 2 });
expect(result.chunks.map((c) => c.chunk)).toEqual(["b", "c"]);
expect(result.chunks.some(isTrimmedOutputMarkerChunk)).toBe(false);
expect(result.trimmedSeq).toBe(1);
});
it("does not accumulate markers when trimming an already-collapsed window", () => {
const first = applyRetentionBudget(
[chunk(1, "aa"), chunk(2, "bb"), chunk(3, "cc")],
{ maxBytes: 3, collapseTrimmed: true },
);
expect(first.chunks.filter(isTrimmedOutputMarkerChunk)).toHaveLength(1);
// Feed the marker-prefixed result back in with more content over budget.
const second = applyRetentionBudget(
[...first.chunks, chunk(4, "dd")],
{ maxBytes: 3, collapseTrimmed: true },
);
expect(second.chunks.filter(isTrimmedOutputMarkerChunk)).toHaveLength(1);
expect(second.chunks[0]!.chunk).toBe(TRIMMED_OUTPUT_MARKER_TEXT);
});
});

View File

@ -26,6 +26,114 @@ export interface ChunkMergeRefs {
const SEEN_CHUNK_KEY_CAP = 12000;
/**
* Retention budget for a run's kept chunk window. Task views pass a byte budget
* with `collapseTrimmed` so the just-rendered scrollback is retained and, when
* the budget is genuinely exceeded, the oldest content collapses behind a
* visible marker instead of silently vanishing midstream. Compact consumers
* (dashboard tickers, summary draft) keep the historical chunk-count cap by
* passing a bare number, which discards silently as before.
*/
export interface ChunkRetentionBudget {
/** Hard ceiling on retained chunk count. */
maxChunks?: number;
/** Soft ceiling on retained chunk payload size (UTF-16 code units ≈ bytes). */
maxBytes?: number;
/**
* When true, trimming leaves a single visible "earlier output trimmed" marker
* at the head so collapsed content never disappears without a trace. When
* false (the default for count-only callers), trimming is silent.
*/
collapseTrimmed?: boolean;
}
/**
* Text of the synthetic system chunk that marks where older output was
* collapsed out of the retained window. Rendered as an ordinary system line by
* `buildTranscript`, so the affordance is adapter-agnostic.
*/
export const TRIMMED_OUTPUT_MARKER_TEXT =
"⋯ earlier output trimmed to stay within the live transcript buffer ⋯";
export function isTrimmedOutputMarkerChunk(chunk: RunLogChunk): boolean {
return (
chunk.stream === "system" &&
chunk.chunk === TRIMMED_OUTPUT_MARKER_TEXT &&
chunk.seq === undefined
);
}
function makeTrimmedOutputMarkerChunk(ts: string): RunLogChunk {
return { ts, stream: "system", chunk: TRIMMED_OUTPUT_MARKER_TEXT };
}
function chunkRetainedSize(chunk: RunLogChunk): number {
return chunk.chunk.length;
}
function normalizeRetentionBudget(budget: number | ChunkRetentionBudget): ChunkRetentionBudget {
return typeof budget === "number" ? { maxChunks: budget } : budget;
}
/**
* Trim a run's retained window to its budget. Byte-budget trimming keeps the
* newest chunks and (when `collapseTrimmed`) prepends one marker so the
* scrollback shows earlier output was collapsed rather than silently dropped.
* Returns the highest sequenced chunk actually removed so callers can raise the
* trimmed-seq floor and drop re-delivered older records.
*/
export function applyRetentionBudget(
chunks: RunLogChunk[],
budget: ChunkRetentionBudget,
): { chunks: RunLogChunk[]; trimmedSeq: number | null } {
const { maxChunks, maxBytes, collapseTrimmed } = budget;
// Peel off any existing marker so it is never counted or duplicated; a single
// marker is re-added below if trimming is (still) in effect.
const hadMarker = chunks.length > 0 && isTrimmedOutputMarkerChunk(chunks[0]!);
const real = hadMarker ? chunks.slice(1) : chunks;
let removeCount = 0;
if (typeof maxChunks === "number" && real.length > maxChunks) {
removeCount = real.length - maxChunks;
}
if (typeof maxBytes === "number") {
let totalBytes = 0;
for (const chunk of real) totalBytes += chunkRetainedSize(chunk);
let byteRemove = 0;
// Always keep the newest chunk even if it alone exceeds the byte budget.
while (byteRemove < real.length - 1 && totalBytes > maxBytes) {
totalBytes -= chunkRetainedSize(real[byteRemove]!);
byteRemove += 1;
}
if (byteRemove > removeCount) removeCount = byteRemove;
}
if (removeCount <= 0) {
// Nothing new to trim. Preserve an existing marker so an earlier collapse
// keeps its trace; otherwise return the marker-stripped array only if we
// actually stripped one (we never had a real trim to justify keeping it).
if (hadMarker) return { chunks, trimmedSeq: null };
return { chunks: real, trimmedSeq: null };
}
const removed = real.slice(0, removeCount);
const kept = real.slice(removeCount);
let trimmedSeq: number | null = null;
for (const item of removed) {
if (typeof item.seq === "number" && (trimmedSeq === null || item.seq > trimmedSeq)) {
trimmedSeq = item.seq;
}
}
if (collapseTrimmed || hadMarker) {
const markerTs = kept[0]?.ts ?? removed[removed.length - 1]!.ts;
return { chunks: [makeTrimmedOutputMarkerChunk(markerTs), ...kept], trimmedSeq };
}
return { chunks: kept, trimmedSeq };
}
export function readChunkSeq(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
@ -96,7 +204,7 @@ export function mergeRunLogChunks(
prevChunks: RunLogChunk[],
incoming: IncomingRunLogChunk[],
refs: ChunkMergeRefs,
maxChunksPerRun: number,
budget: number | ChunkRetentionBudget,
): { chunks: RunLogChunk[]; changed: boolean } {
if (incoming.length === 0) return { chunks: prevChunks, changed: false };
@ -144,14 +252,12 @@ export function mergeRunLogChunks(
if (refs.seenChunkKeys.size > SEEN_CHUNK_KEY_CAP) {
refs.seenChunkKeys.clear();
}
if (existing.length > maxChunksPerRun) {
const trimmed = existing.splice(0, existing.length - maxChunksPerRun);
let seqFloor = refs.trimmedSeqFloorByRun.get(runId) ?? 0;
for (const item of trimmed) {
if (typeof item.seq === "number" && item.seq > seqFloor) seqFloor = item.seq;
}
if (seqFloor > 0) refs.trimmedSeqFloorByRun.set(runId, seqFloor);
const { chunks: retained, trimmedSeq } = applyRetentionBudget(existing, normalizeRetentionBudget(budget));
if (trimmedSeq !== null) {
const seqFloor = refs.trimmedSeqFloorByRun.get(runId) ?? 0;
if (trimmedSeq > seqFloor) refs.trimmedSeqFloorByRun.set(runId, trimmedSeq);
}
return { chunks: existing, changed: true };
return { chunks: retained, changed: true };
}

View File

@ -49,8 +49,8 @@ const STREAMLINED_TOGGLE_SELECTOR =
'button[aria-label="Toggle streamlined left navigation experimental setting"]';
const TASK_WATCHDOGS_TOGGLE_SELECTOR =
'button[aria-label="Toggle task watchdogs experimental setting"]';
const TASK_CHAT_REDESIGN_TOGGLE_SELECTOR =
'button[aria-label="Toggle chat-style tasks experimental setting"]';
const CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR =
'button[aria-label="Toggle classic task interface experimental setting"]';
const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR =
'button[aria-label="Toggle goals sidebar link experimental setting"]';
const DECISIONS_TOGGLE_SELECTOR =
@ -78,7 +78,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
enablePipelines: false,
enableCases: false,
enableConferenceRoomChat: false,
enableTaskChatRedesign: false,
enableClassicTaskInterface: false,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
@ -294,18 +294,20 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
});
});
it("renders and patches the Chat-Style Tasks experimental toggle on and off", async () => {
it("renders and patches the Classic Task Interface experimental toggle on and off", async () => {
await renderPage();
expect(container.textContent).toContain("Chat-Style Tasks");
expect(container.textContent).toContain("Classic Task Interface");
expect(container.textContent).toContain(
"Reimagines the task detail page as a live conversation with your agents",
"Restores the previous task detail page",
);
expect(container.textContent).toContain(
"Turning this off instantly restores the classic task page. No task data is affected.",
"Switching takes effect immediately. No task data is affected.",
);
const toggle = container.querySelector<HTMLButtonElement>(TASK_CHAT_REDESIGN_TOGGLE_SELECTOR);
const toggle = container.querySelector<HTMLButtonElement>(
CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR,
);
expect(toggle?.getAttribute("aria-checked")).toBe("false");
await act(async () => {
@ -314,7 +316,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({
enableTaskChatRedesign: true,
enableClassicTaskInterface: true,
});
expect(toggle?.getAttribute("aria-checked")).toBe("true");
@ -326,7 +328,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await renderPage();
const enabledToggle = container.querySelector<HTMLButtonElement>(
TASK_CHAT_REDESIGN_TOGGLE_SELECTOR,
CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR,
);
expect(enabledToggle?.getAttribute("aria-checked")).toBe("true");
@ -336,7 +338,7 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233)
await flushReact();
expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({
enableTaskChatRedesign: false,
enableClassicTaskInterface: false,
});
});

View File

@ -360,7 +360,7 @@ export function InstanceExperimentalSettings() {
// Streamlined left navigation is now the standard sidebar (PAP-12472); the
// experimental opt-out was retired, so it no longer surfaces a toggle here.
const enableConferenceRoomChat = experimentalQuery.data?.enableConferenceRoomChat === true;
const enableTaskChatRedesign = experimentalQuery.data?.enableTaskChatRedesign === true;
const enableClassicTaskInterface = experimentalQuery.data?.enableClassicTaskInterface === true;
const enableIssuePlanDecompositions =
experimentalQuery.data?.enableIssuePlanDecompositions === true;
const enableExperimentalFileViewer =
@ -601,14 +601,14 @@ export function InstanceExperimentalSettings() {
/>
<ExperimentalToggleCard
title="Chat-Style Tasks"
description="Reimagines the task detail page as a live conversation with your agents: chat bubbles for people and agents, streaming activity — thinking, tool calls, diffs — that folds into a one-line summary when a turn finishes, inline plan/question/permission cards, a three-mode composer (Agent · Plan · Ask), and a resizable Properties · Plan · Artifacts pane."
footnote="Turning this off instantly restores the classic task page. No task data is affected."
checked={enableTaskChatRedesign}
onCheckedChange={(checked) => toggleMutation.mutate({ enableTaskChatRedesign: checked })}
title="Classic Task Interface"
description="Restores the previous task detail page: the page-level header with inline description editing, the plain comment thread, and the fixed Properties sidebar. Chat-only features — streaming activity folding, inline plan and question cards, the three-mode composer — are unavailable in the classic view."
footnote="Switching takes effect immediately. No task data is affected."
checked={enableClassicTaskInterface}
onCheckedChange={(checked) => toggleMutation.mutate({ enableClassicTaskInterface: checked })}
disabled={toggleMutation.isPending}
managed={managedKeys.enableTaskChatRedesign}
ariaLabel="Toggle chat-style tasks experimental setting"
managed={managedKeys.enableClassicTaskInterface}
ariaLabel="Toggle classic task interface experimental setting"
/>
{SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? (

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(() => ({
@ -290,11 +292,47 @@ vi.mock("../components/IssueChatThread", () => ({
},
}));
// The redesign thread pulls in the MarkdownEditor composer, whose @mdxeditor
// dependency cannot load under jsdom's CSSOM. These tests exercise the legacy
// (flag-off) path, so an inert stub keeps the suite unit-scoped.
// The task chat thread pulls in the MarkdownEditor composer, whose @mdxeditor
// dependency cannot load under jsdom's CSSOM. The stub keeps the suite
// unit-scoped but still renders the threadHeader JSX (the issue header row
// lives inside the thread) so header controls stay testable, records its props
// on the shared thread-render spy, and exposes the same run-control buttons as
// the IssueChatThread stub above.
vi.mock("../components/TaskChatThread", () => ({
TaskChatThread: () => <div data-testid="task-chat-thread">Task chat thread</div>,
TaskChatThread: (props: {
threadHeader?: ReactNode;
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
runFinalizationActions?: readonly {
id: string;
label: string;
onSelect: (runId: string) => Promise<void> | void;
}[];
footer?: ReactNode;
}) => {
mockIssueChatThreadRender(props);
return (
<div data-testid="task-chat-thread">
{props.threadHeader}
Task chat thread
{props.onStopRun ? (
<button type="button" onClick={() => void props.onStopRun?.("run-active-1")}>
{props.stopRunLabel ?? "Stop run"}
</button>
) : null}
{props.runFinalizationActions?.map((action) => (
<button
key={action.id}
type="button"
onClick={() => void action.onSelect("run-active-1")}
>
{action.label}
</button>
))}
{props.footer}
</div>
);
},
}));
vi.mock("../components/IssueDocumentsSection", () => ({
@ -1029,6 +1067,9 @@ describe("IssueDetail", () => {
enableExternalObjects: false,
});
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([]);
mockIssuesApi.getDocument.mockResolvedValue(null);
mockOpenPanel.mockClear();
mockClosePanel.mockClear();
mockIssuesListRender.mockClear();
mockIssueChatThreadRender.mockClear();
mockImageGalleryRender.mockClear();
@ -1067,7 +1108,7 @@ describe("IssueDetail", () => {
await flushReact();
expect(container.textContent).toContain("Issue detail smoke");
expect(container.textContent).toContain("Chat thread");
expect(container.textContent).toContain("Task chat thread");
expect(
consoleErrorSpy.mock.calls.some((call: unknown[]) =>
String(call[0]).includes("React has detected a change in the order of Hooks"),
@ -1568,9 +1609,6 @@ describe("IssueDetail", () => {
await flushReact();
expect(container.querySelector('[aria-label="Open file in this issue"]')).toBeNull();
const latestWorkspaceProps = mockIssueWorkspaceCardRender.mock.calls.at(-1)?.[0];
expect(latestWorkspaceProps?.onBrowseFiles).toBeUndefined();
expect(latestWorkspaceProps?.onOpenFileByPath).toBeUndefined();
});
it("shows file viewer entry points when the experimental flag is enabled", async () => {
@ -1592,48 +1630,19 @@ describe("IssueDetail", () => {
await flushReact();
expect(container.querySelector('[aria-label="Open file in this issue"]')).not.toBeNull();
const latestWorkspaceProps = mockIssueWorkspaceCardRender.mock.calls.at(-1)?.[0];
expect(latestWorkspaceProps?.onBrowseFiles).toEqual(expect.any(Function));
expect(latestWorkspaceProps?.onOpenFileByPath).toEqual(expect.any(Function));
});
it("shows the plan decomposition panel when the experimental flag is enabled", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
it("hides the properties sidebar on the first onboarding task until a plan document exists", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: true,
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.listAcceptedPlanDecompositions.mockResolvedValue([
{
id: "decomp-1",
companyId: "company-1",
sourceIssueId: "issue-1",
acceptedPlanRevisionId: "plan-rev-1",
acceptedPlanRevisionNumber: 2,
acceptedInteractionId: null,
status: "completed",
requestFingerprint: "fingerprint-1",
requestedChildCount: 2,
childIssueIds: ["issue-2", "issue-3"],
childIssues: [
{
id: "issue-2",
identifier: "PAP-2",
title: "First child issue",
status: "todo",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
},
],
ownerAgentId: null,
ownerUserId: null,
ownerRunId: null,
completedAt: "2026-05-28T06:00:00.000Z",
createdAt: "2026-05-28T05:50:00.000Z",
updatedAt: "2026-05-28T06:00:00.000Z",
},
]);
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(
@ -1646,46 +1655,23 @@ describe("IssueDetail", () => {
await flushReact();
await flushReact();
expect(container.textContent).toContain("Plan decomposition");
expect(container.textContent).toContain("Plan revision 2");
expect(container.textContent).toContain("2 of 2 child tasks created");
expect(container.textContent).toContain("First child issue");
expect(mockIssuesApi.listAcceptedPlanDecompositions).toHaveBeenCalledWith("issue-1");
// 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("renders sibling previous and next navigation at the chat footer", async () => {
const issue = createIssue({
id: "issue-2",
identifier: "PAP-2",
issueNumber: 2,
parentId: "parent-1",
title: "Current sibling",
createdAt: new Date("2026-04-02T00:00:00.000Z"),
});
const previous = createIssue({
id: "issue-1",
identifier: "PAP-1",
issueNumber: 1,
parentId: "parent-1",
title: "Previous sibling",
status: "done",
createdAt: new Date("2026-04-01T00:00:00.000Z"),
});
const next = createIssue({
id: "issue-3",
identifier: "PAP-3",
issueNumber: 3,
parentId: "parent-1",
title: "Next sibling",
blockedBy: [{ id: "issue-2" }] as Issue["blockedBy"],
createdAt: new Date("2026-04-03T00:00:00.000Z"),
});
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; parentId?: string }) => {
if (filters?.parentId === "parent-1") return Promise.resolve([next, previous, issue]);
return Promise.resolve([]);
it("keeps the Show properties button clickable on the first task and reveals the sidebar on demand", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }),
);
// No plan yet: the panel mount is suppressed by default.
mockIssuesApi.getDocument.mockResolvedValue(null);
await act(async () => {
root.render(
@ -1694,54 +1680,40 @@ describe("IssueDetail", () => {
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockOpenPanel).not.toHaveBeenCalled();
// Even though panelVisible is true, the suppressed first task keeps the
// opt-in button visible instead of fading it out.
const showPropertiesButton = container.querySelector<HTMLButtonElement>(
'button[title="Show properties"]',
);
expect(showPropertiesButton).toBeTruthy();
expect(showPropertiesButton!.className).not.toContain("pointer-events-none");
await act(async () => {
showPropertiesButton!.click();
});
await flushReact();
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
parentId: "parent-1",
includeBlockedBy: true,
// The click overrides the first-task suppression and mounts the panel.
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
expect(container.querySelector('a[aria-label="Previous sub-task: PAP-1 - Previous sibling"]')).toBeTruthy();
expect(container.querySelector('a[aria-label="Next sub-task: PAP-3 - Next sibling"]')).toBeTruthy();
expect(container.textContent).toContain("Previous");
expect(container.textContent).toContain("Previous sibling");
expect(container.textContent).toContain("Next");
expect(container.textContent).toContain("Next sibling");
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].footer).toBeTruthy();
});
it("uses the first child issue as next navigation for parent issues without a sibling next", async () => {
const parent = createIssue({
id: "issue-parent",
identifier: "PAP-10",
issueNumber: 10,
parentId: null,
title: "Plan parent",
createdAt: new Date("2026-04-01T00:00:00.000Z"),
});
const firstChild = createIssue({
id: "issue-child-1",
identifier: "PAP-11",
issueNumber: 11,
parentId: "issue-parent",
title: "First child",
createdAt: new Date("2026-04-02T00:00:00.000Z"),
});
const secondChild = createIssue({
id: "issue-child-2",
identifier: "PAP-12",
issueNumber: 12,
parentId: "issue-parent",
title: "Second child",
blockedBy: [{ id: "issue-child-1" }] as Issue["blockedBy"],
createdAt: new Date("2026-04-03T00:00:00.000Z"),
});
mockIssuesApi.get.mockResolvedValue(parent);
mockIssuesApi.list.mockImplementation((_companyId, filters?: { descendantOf?: string; parentId?: string }) => {
if (filters?.descendantOf === "issue-parent") return Promise.resolve([secondChild, firstChild]);
return Promise.resolve([]);
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,
});
mockIssuesApi.get.mockResolvedValue(
createIssue({ originKind: ONBOARDING_FIRST_TASK_ORIGIN_KIND }),
);
mockIssuesApi.getDocument.mockResolvedValue({ id: "doc-1", key: "plan" });
await act(async () => {
root.render(
@ -1750,17 +1722,31 @@ describe("IssueDetail", () => {
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
descendantOf: "issue-parent",
includeBlockedBy: true,
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,
});
mockIssuesApi.get.mockResolvedValue(createIssue({ originKind: "manual" }));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await waitForAssertion(() => {
expect(mockOpenPanel).toHaveBeenCalled();
});
expect(container.querySelector('a[aria-label="Next sub-task: PAP-11 - First child"]')).toBeTruthy();
expect(container.textContent).toContain("Next");
expect(container.textContent).toContain("First child");
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0].footer).toBeTruthy();
});
it("passes blocker attention to the issue detail header status icon", async () => {
@ -1856,8 +1842,6 @@ describe("IssueDetail", () => {
await waitForAssertion(() => {
expect(container.textContent).toContain("Subtree pause is active.");
expect(mockIssuesListRender.mock.calls.at(-1)?.[0].issueBadgeById.get("child-1")).toBe("Paused");
expect(mockIssuesListRender.mock.calls.at(-1)?.[0].showProgressSummary).toBe(true);
});
const resumeButton = Array.from(container.querySelectorAll("button"))
@ -1892,7 +1876,6 @@ describe("IssueDetail", () => {
}));
await waitForAssertion(() => {
expect(container.textContent).not.toContain("Subtree pause is active.");
expect(mockIssuesListRender.mock.calls.at(-1)?.[0].issueBadgeById.has("child-1")).toBe(false);
});
});
@ -2167,10 +2150,9 @@ describe("IssueDetail", () => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
issueWorkMode: "planning",
});
expect(container.textContent).toContain("Plan mode");
});
it("passes ask work mode to the issue chat thread and renders the ask badge", async () => {
it("passes ask work mode to the issue chat thread", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "ask" }));
await act(async () => {
root.render(
@ -2184,7 +2166,6 @@ describe("IssueDetail", () => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
issueWorkMode: "ask",
});
expect(container.textContent).toContain("Ask mode");
});
it("falls back to execCommand when copying the task from an insecure context", async () => {
@ -2260,7 +2241,7 @@ describe("IssueDetail", () => {
}
});
it("renders the graduated task thread without the chat flag", async () => {
it("renders the task chat thread as the default thread", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
@ -2272,12 +2253,18 @@ describe("IssueDetail", () => {
});
await flushReact();
expect(container.querySelector('[data-testid="issue-chat-thread"]')).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-thread"]')).not.toBeNull();
expect(mockIssueChatThreadRender).toHaveBeenCalled();
});
it("uses graduated Plan mode chip copy", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
it("renders the legacy issue chat thread when the classic task interface flag is on", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableIssuePlanDecompositions: false,
enableExperimentalFileViewer: false,
enableExternalObjects: false,
enableClassicTaskInterface: true,
});
mockIssuesApi.get.mockResolvedValue(createIssue());
await act(async () => {
root.render(
@ -2287,9 +2274,11 @@ describe("IssueDetail", () => {
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Plan mode");
expect(container.textContent).not.toContain("Planning");
expect(container.querySelector('[data-testid="issue-chat-thread"]')).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-thread"]')).toBeNull();
expect(mockIssueChatThreadRender).toHaveBeenCalled();
});
it("passes @task mention options to the thread by default", async () => {
@ -2366,73 +2355,9 @@ describe("IssueDetail", () => {
expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, { workMode: "ask" });
expect(localStorage.getItem("paperclip:issue-comment-draft:issue-1")).toBe("Draft follow-up message");
expect(container.textContent).toContain("planning-notes.txt");
localStorage.removeItem("paperclip:issue-comment-draft:issue-1");
});
it("hides attachments backing promoted outputs while keeping filtered markdown artifacts visible", async () => {
const issue = createIssue();
const videoAttachment = createAttachment({
id: "11111111-1111-4111-8111-111111111111",
contentType: "video/mp4",
originalFilename: "demo.mp4",
});
const imageAttachment = createAttachment({
id: "33333333-3333-4333-8333-333333333333",
contentType: "image/png",
originalFilename: "screenshot.png",
});
const markdownAttachment = createAttachment({
id: "22222222-2222-4222-8222-222222222222",
contentType: "text/markdown",
originalFilename: "report.md",
});
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.listAttachments.mockResolvedValue([videoAttachment, imageAttachment, markdownAttachment]);
mockIssuesApi.listWorkProducts.mockResolvedValue([
createArtifactWorkProduct({
id: "wp-video",
attachmentId: videoAttachment.id,
contentType: "video/mp4",
originalFilename: "demo.mp4",
isPrimary: true,
}),
createArtifactWorkProduct({
id: "wp-image",
attachmentId: imageAttachment.id,
contentType: "image/png",
originalFilename: "screenshot.png",
}),
createArtifactWorkProduct({
id: "wp-markdown",
attachmentId: markdownAttachment.id,
contentType: "text/markdown",
originalFilename: "report.md",
}),
]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Output");
expect(container.textContent).toContain("demo.mp4");
expect(container.textContent).toContain("Attachments");
expect(container.textContent).toContain("report.md");
expect(container.textContent).toContain("Attachments1");
expect(container.querySelectorAll("video")).toHaveLength(1);
expect(mockImageGalleryRender.mock.calls.at(-1)?.[0].items.map((attachment: IssueAttachment) => attachment.id)).toEqual([
videoAttachment.id,
imageAttachment.id,
]);
});
it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => {
const activeHold = createPauseHold();
const releasedHold = createPauseHold({

View File

@ -92,7 +92,7 @@ import {
} from "../components/IssueChatThread";
import { TaskChatThread } from "../components/TaskChatThread";
import type { TaskChatIssueBrief } from "../components/task-chat/TaskChatDescriptionBubble";
import { useTaskChatRedesignEnabled } from "../hooks/useTaskChatRedesignEnabled";
import { useClassicTaskInterfaceEnabled } from "../hooks/useClassicTaskInterfaceEnabled";
import { workModeMetaFor } from "../lib/work-mode-meta";
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
import { IssueAttachmentsSection } from "../components/IssueAttachmentsSection";
@ -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";
@ -201,6 +202,7 @@ import {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
ONBOARDING_FIRST_TASK_ORIGIN_KIND,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type ActivityEvent,
@ -690,7 +692,8 @@ function IssueDetailLoadingState({
headerSeed: ReturnType<typeof readIssueDetailHeaderSeed>;
}) {
const identifier = headerSeed?.identifier ?? headerSeed?.id.slice(0, 8) ?? null;
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
const taskChatShellEnabled = !classicTaskInterfaceEnabled;
return (
<div
@ -915,14 +918,14 @@ type IssueDetailChatTabProps = {
/** Optional node rendered inline directly above the reply composer (e.g. the monitor strip). */
composerAccessory?: ReactNode;
/**
* Issue header (title row, badges, plugin toolbars) that the redesigned
* Issue header (title row, badges, plugin toolbars) that the chat-style
* thread renders inside its scroll viewport so it scrolls away with the
* messages (flag: enableTaskChatRedesign). Ignored by the legacy thread.
* messages. Ignored by the classic thread (flag: enableClassicTaskInterface).
*/
threadHeader?: ReactNode;
/**
* The task description rendered as the requester's first chat bubble in the
* redesigned thread (PAP-375). Ignored by the legacy thread.
* chat-style thread (PAP-375). Ignored by the classic thread.
*/
issueBrief?: TaskChatIssueBrief;
footer?: ReactNode;
@ -1056,11 +1059,12 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
externalReferences,
linkCaseReferences,
}: IssueDetailChatTabProps) {
// Seam for the Task Chat Redesign (flag: enableTaskChatRedesign). Flag OFF
// renders IssueChatThread verbatim — the flag-off branch is provably today's
// UI. Both components share one prop type, so no cast is needed.
const { enabled: taskChatRedesignEnabled } = useTaskChatRedesignEnabled();
const ThreadComponent = taskChatRedesignEnabled ? TaskChatThread : IssueChatThread;
// Seam for the Classic Task Interface (flag: enableClassicTaskInterface).
// Flag ON renders the legacy IssueChatThread verbatim; flag OFF (the
// default) renders the chat-style TaskChatThread. Both components share one
// prop type, so no cast is needed.
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
const ThreadComponent = classicTaskInterfaceEnabled ? IssueChatThread : TaskChatThread;
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
queryFn: () => activityApi.forIssue(issueId),
@ -1215,10 +1219,10 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
) : null;
return (
<div className={taskChatRedesignEnabled ? "flex min-h-0 flex-1 flex-col" : "space-y-3"}>
{/* Redesign: the button rides inside the thread's scroll viewport with the
header so nothing sits above the thread in the page flow. */}
{taskChatRedesignEnabled ? null : loadOlderButton}
<div className={classicTaskInterfaceEnabled ? "space-y-3" : "flex min-h-0 flex-1 flex-col"}>
{/* Chat-style: the button rides inside the thread's scroll viewport with
the header so nothing sits above the thread in the page flow. */}
{classicTaskInterfaceEnabled ? loadOlderButton : null}
{commentsInitialLoading && commentsWithRunMeta.length === 0 && interactions.length === 0 ? (
<IssueChatSkeleton />
) : (
@ -1226,7 +1230,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
composerRef={composerRef}
composerAccessory={composerAccessory}
threadHeader={
taskChatRedesignEnabled && (threadHeader || loadOlderButton) ? (
!classicTaskInterfaceEnabled && (threadHeader || loadOlderButton) ? (
<>
{threadHeader}
{loadOlderButton}
@ -1604,15 +1608,16 @@ function IssueDetailActivityTab({
export function IssueDetail() {
const { issueId } = useParams<{ issueId: string }>();
const { selectedCompanyId } = useCompany();
// Task Chat Redesign (flag: enableTaskChatRedesign): with the flag ON the
// thread owns the center column — the legacy title/description block,
// sub-tasks table, plan decompositions and Documents section are gated off
// (plan lives in the properties-pane Plan tab). Flag OFF renders the legacy
// page byte-identically.
const { enabled: taskChatShellEnabled } = useTaskChatRedesignEnabled();
// Flag ON the page wrapper spans the full center pane so the thread's scroll
// viewport (and its scrollbar) reaches the properties-pane border; every
// non-thread section re-centers itself at the 60rem shell cap instead.
// Classic Task Interface (flag: enableClassicTaskInterface): with the flag
// OFF (the default) the chat-style thread owns the center column — the
// legacy title/description block, sub-tasks table, plan decompositions and
// Documents section are gated off (plan lives in the properties-pane Plan
// tab). Flag ON restores the legacy page.
const { enabled: classicTaskInterfaceEnabled } = useClassicTaskInterfaceEnabled();
const taskChatShellEnabled = !classicTaskInterfaceEnabled;
// Chat-style: the page wrapper spans the full center pane so the thread's
// scroll viewport (and its scrollbar) reaches the properties-pane border;
// every non-thread section re-centers itself at the 60rem shell cap instead.
const shellSectionClass = taskChatShellEnabled
? "mx-auto w-full max-w-(--tc-shell-max-w)"
: undefined;
@ -1996,6 +2001,27 @@ 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. The user
// can still opt in early via the "Show properties" header button, which sets a
// per-issue override (keyed on the issue id so it resets across navigations).
const isOnboardingFirstTask =
taskChatShellEnabled &&
issue?.originKind === ONBOARDING_FIRST_TASK_ORIGIN_KIND;
const { data: firstTaskPlanDoc } = useIssuePlanDocument(
isOnboardingFirstTask ? issue?.id : null,
);
const [firstTaskPanelOverrideIssueId, setFirstTaskPanelOverrideIssueId] = useState<
string | null
>(null);
const firstTaskPanelOverride =
firstTaskPanelOverrideIssueId !== null && firstTaskPanelOverrideIssueId === issue?.id;
const suppressPanelForFirstTask =
isOnboardingFirstTask && !firstTaskPlanDoc && !firstTaskPanelOverride;
const showRichSubIssuesSection = shouldRenderRichSubIssuesSection(childIssuesLoading, childIssues.length);
const siblingNavigation = useMemo(
() => issue && !childIssuesLoading && !siblingIssuesLoading && !siblingIssuesError
@ -3273,7 +3299,7 @@ export function IssueDetail() {
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!panelIssue) {
if (!panelIssue || suppressPanelForFirstTask) {
closePanel();
return;
}
@ -3301,6 +3327,7 @@ export function IssueDetail() {
openPanel,
panelChildIssues,
panelIssue,
suppressPanelForFirstTask,
resolvedHasActiveRun,
checkIssueMonitorNow.isPending,
checkIssueMonitorNow.mutate,
@ -4393,9 +4420,16 @@ export function IssueDetail() {
size="icon-xs"
className={cn(
"shrink-0 transition-opacity duration-200",
panelVisible ? "opacity-0 pointer-events-none w-0 overflow-hidden" : "opacity-100",
panelVisible && !suppressPanelForFirstTask
? "opacity-0 pointer-events-none w-0 overflow-hidden"
: "opacity-100",
)}
onClick={() => setPanelVisible(true)}
onClick={() => {
if (suppressPanelForFirstTask && issue?.id) {
setFirstTaskPanelOverrideIssueId(issue.id);
}
setPanelVisible(true);
}}
title="Show properties"
>
<SlidersHorizontal className="h-4 w-4" />
@ -4929,7 +4963,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",

View File

@ -132,8 +132,8 @@ function useStreamingReplay(
}
/**
* Dev harness for the Task Chat Redesign (route: /dev/task-chat-lab, behind the
* enableTaskChatRedesign flag). Drives the render layer into every inventory
* Dev harness for the chat-style task thread (route: /dev/task-chat-lab, dev
* builds only). Drives the render layer into every inventory
* state via synthetic events no live agent and is also the human's
* post-baseline iteration cockpit: state switcher, streaming replay, a
* 0.1×10× speed control, and the live motion tweak panel.