Polish board settings and skills workflow (#4863)

## Thinking Path

> - Paperclip's board UI and bundled skills are the operator layer for
configuring agents, routines, issue workflows, and local troubleshooting
loops.
> - The prior rollup mixed this operator polish with database backups,
backend reliability, thread scale, and cost/workflow primitives.
> - This pull request isolates the remaining board QoL, settings,
issue-detail integration, adapter config cleanup, and skills smoke
tooling.
> - It includes some integration-level overlap with the thread and
workflow slices so this branch can run from `origin/master` while still
preserving the full original work.
> - Preferred merge order is the narrower primitives first, then this
integration PR last.
> - The benefit is that reviewers can inspect the user-facing
board/settings/skills layer separately from backend infrastructure
changes.

## What Changed

- Added board/settings polish for agents, routines, company settings,
project workspace detail, and issue detail controls.
- Added agent/routine UI regression tests and New Issue dialog coverage.
- Integrated issue-detail activity/cost/interaction surfaces and leaf
work pause/resume controls.
- Cleaned bundled adapter UI config defaults and onboarding copy.
- Added terminal-bench loop and work-stoppage diagnosis skills plus a
smoke test script.
- Updated attachment type handling and Paperclip skill/API guidance.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run ui/src/pages/Agents.test.tsx
ui/src/pages/Routines.test.tsx ui/src/components/NewIssueDialog.test.tsx
ui/src/pages/IssueDetail.test.tsx
server/src/__tests__/costs-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts`
- Result: 7 test files passed, 54 tests passed.
- `pnpm run smoke:terminal-bench-loop-skill`
- Result: JSON output included `"ok": true` and `"cleanup": true`.
- UI screenshots not included because verification is focused
component/page coverage for the changed board surfaces.

## Risks

- This is the integration-heavy PR in the split and intentionally
overlaps some component/API primitives with the issue-thread and
workflow PRs so it can run from `origin/master`.
- Preferred merge order: #4859, #4860, #4861, #4862, then this PR last.
If earlier branches merge first, this PR may need a straightforward
conflict refresh in shared UI files.
- The terminal-bench smoke script creates temporary mock issues and
relies on cleanup; the verified run returned `cleanup: true`.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium
reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-04-30 15:28:11 -05:00
committed by GitHub
parent c4269bab59
commit 1fe1067361
28 changed files with 1718 additions and 173 deletions
+1
View File
@@ -35,6 +35,7 @@
"smoke:openclaw-join": "./scripts/smoke/openclaw-join.sh",
"smoke:openclaw-docker-ui": "./scripts/smoke/openclaw-docker-ui.sh",
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs",
"test:e2e": "npx playwright test --config tests/e2e/playwright.config.ts",
"test:e2e:headed": "npx playwright test --config tests/e2e/playwright.config.ts --headed",
@@ -66,8 +66,6 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
if (v.chrome) ac.chrome = true;
@@ -70,8 +70,6 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
ac.timeoutSec = 0;
@@ -61,8 +61,6 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
const mode = normalizeMode(v.thinkingEffort);
if (mode) ac.mode = mode;
@@ -55,8 +55,6 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;
@@ -54,8 +54,6 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
@@ -47,8 +47,6 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..");
function parseArgs(argv) {
const parsed = {
keep: false,
sourceIssueId: process.env.PAPERCLIP_TASK_ID ?? null,
projectId: process.env.PAPERCLIP_PROJECT_ID ?? null,
goalId: process.env.PAPERCLIP_GOAL_ID ?? null,
runKey: null,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--keep") {
parsed.keep = true;
continue;
}
if (arg === "--source-issue-id") {
parsed.sourceIssueId = argv[++index] ?? null;
continue;
}
if (arg === "--project-id") {
parsed.projectId = argv[++index] ?? null;
continue;
}
if (arg === "--goal-id") {
parsed.goalId = argv[++index] ?? null;
continue;
}
if (arg === "--run-key") {
parsed.runKey = argv[++index] ?? null;
continue;
}
if (arg === "--help" || arg === "-h") {
printUsage();
process.exit(0);
}
throw new Error(`Unknown argument: ${arg}`);
}
return parsed;
}
function printUsage() {
console.log(`
Usage:
PAPERCLIP_API_URL=http://localhost:3100 \\
PAPERCLIP_API_KEY=... \\
PAPERCLIP_COMPANY_ID=... \\
pnpm smoke:terminal-bench-loop-skill
Options:
--source-issue-id <uuid> Attach smoke issues under an existing Paperclip issue.
--project-id <uuid> Override inferred project id.
--goal-id <uuid> Override inferred goal id.
--run-key <string> Stable key used in smoke titles and mocked artifact paths.
--keep Leave smoke issues in their verified blocked/in_review posture.
`);
}
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required. Run against a local Paperclip server with an agent or board API token.`);
}
return value;
}
function slugify(value) {
return value.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function assertLocalSkillPackage() {
const skillPath = join(repoRoot, "skills", "terminal-bench-loop", "SKILL.md");
const markdown = await readFile(skillPath, "utf8");
for (const expected of [
"name: terminal-bench-loop",
"request_confirmation",
"diagnosis",
"blockedByIssueIds",
"PAPERCLIPAI_CMD",
]) {
assert(markdown.includes(expected), `Skill smoke expected ${skillPath} to mention ${expected}`);
}
}
function createApiClient({ apiUrl, apiKey, runId }) {
const baseUrl = apiUrl.replace(/\/+$/, "");
return async function api(method, path, { body, ok } = {}) {
const expectedStatuses = ok ?? (method === "POST" || method === "PUT" ? [200, 201] : [200]);
const headers = {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
};
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
if (runId && method !== "GET") {
headers["X-Paperclip-Run-Id"] = runId;
}
const response = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!expectedStatuses.includes(response.status)) {
throw new Error(`${method} ${path} returned ${response.status}: ${text}`);
}
return data;
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const apiUrl = requireEnv("PAPERCLIP_API_URL");
const apiKey = requireEnv("PAPERCLIP_API_KEY");
const companyId = requireEnv("PAPERCLIP_COMPANY_ID");
const runId = process.env.PAPERCLIP_RUN_ID ?? null;
const api = createApiClient({ apiUrl, apiKey, runId });
await assertLocalSkillPackage();
const sourceIssue = args.sourceIssueId
? await api("GET", `/api/issues/${args.sourceIssueId}`)
: null;
const projectId = args.projectId ?? sourceIssue?.projectId ?? null;
const goalId = args.goalId ?? sourceIssue?.goalId ?? null;
const runKey = slugify(args.runKey ?? runId ?? `local-${new Date().toISOString()}`);
const artifactRoot = `mock://terminal-bench-loop-smoke/${runKey}`;
const titlePrefix = `[smoke:${runKey}]`;
const commonIssueFields = {
...(projectId ? { projectId } : {}),
...(goalId ? { goalId } : {}),
priority: "low",
};
const loop = await api("POST", `/api/companies/${companyId}/issues`, {
body: {
...commonIssueFields,
...(sourceIssue ? { parentId: sourceIssue.id } : {}),
title: `${titlePrefix} Terminal-Bench loop skill smoke`,
status: "todo",
description: [
"Deterministic smoke for the /terminal-bench-loop skill.",
"",
"- Task: terminal-bench/fix-git",
"- Iteration budget: 1",
"- Benchmark command: mocked; no Terminal-Bench, Harbor, model, or provider process is started.",
`- Artifact root: ${artifactRoot}`,
].join("\n"),
},
});
const iteration = await api("POST", `/api/companies/${companyId}/issues`, {
body: {
...commonIssueFields,
parentId: loop.id,
title: `${titlePrefix} Iteration 1: terminal-bench/fix-git`,
status: "todo",
description: [
"Smoke iteration child created by the deterministic terminal-bench-loop skill smoke.",
"",
"This issue records mocked run artifacts, diagnosis, and the pending confirmation path.",
].join("\n"),
},
});
const runDocument = await api("PUT", `/api/issues/${iteration.id}/documents/run`, {
body: {
title: "Mocked benchmark run",
format: "markdown",
body: [
"# Mocked benchmark run",
"",
"- Label: smoke / non-comparable",
"- Terminal-Bench task: terminal-bench/fix-git",
"- Stop reason: verifier_failed",
`- Manifest: ${artifactRoot}/manifest.json`,
`- Results JSONL: ${artifactRoot}/results.jsonl`,
`- Harbor raw job folder: ${artifactRoot}/harbor/raw-job`,
"",
"No benchmark process, Harbor job, model call, or provider call was started.",
].join("\n"),
changeSummary: "Record deterministic mocked benchmark artifact paths.",
},
});
const diagnosisDocument = await api("PUT", `/api/issues/${iteration.id}/documents/diagnosis`, {
body: {
title: "Smoke diagnosis",
format: "markdown",
body: [
"# Smoke diagnosis",
"",
`Exact stop point: ${iteration.identifier ?? iteration.id} is waiting on a product-fix confirmation after a mocked verifier failure.`,
"",
"Next-action owner: board/user must accept or reject the confirmation before implementation subtasks exist.",
"",
"Failure taxonomy: Paperclip product gap, mocked for smoke coverage.",
"",
"Invariant check:",
"",
"- Productive work continues: acceptance wakes the assignee and would create the implementation path.",
"- Only real blockers stop work: the loop parent is blocked by this iteration child while the confirmation is pending.",
"- No infinite loops: iteration budget is 1 and the smoke does not start a rerun.",
].join("\n"),
changeSummary: "Record exact stop point and next-action owner.",
},
});
const planDocument = await api("PUT", `/api/issues/${iteration.id}/documents/plan`, {
body: {
title: "Smoke fix proposal",
format: "markdown",
body: [
"# Smoke fix proposal",
"",
"Proposed product rule: a Terminal-Bench loop iteration that identifies a product gap must create a request_confirmation interaction before implementation subtasks exist.",
"",
`Evidence: mocked run document ${runDocument.id}; diagnosis document ${diagnosisDocument.id}.`,
].join("\n"),
changeSummary: "Record smoke proposal for confirmation target.",
},
});
const confirmation = await api("POST", `/api/issues/${iteration.id}/interactions`, {
body: {
kind: "request_confirmation",
idempotencyKey: `confirmation:${iteration.id}:plan:${planDocument.latestRevisionId}`,
title: "Smoke plan confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Accept the mocked terminal-bench-loop product-fix proposal?",
acceptLabel: "Accept smoke plan",
rejectLabel: "Reject smoke plan",
rejectRequiresReason: true,
rejectReasonLabel: "What should change?",
detailsMarkdown: "This deterministic smoke verifies the waiting path only; do not treat it as a real benchmark result.",
supersedeOnUserComment: true,
target: {
type: "issue_document",
issueId: iteration.id,
documentId: planDocument.id,
key: "plan",
revisionId: planDocument.latestRevisionId,
revisionNumber: planDocument.latestRevisionNumber,
label: "Smoke fix proposal",
},
},
},
});
await api("PATCH", `/api/issues/${iteration.id}`, {
body: {
status: "in_review",
comment: [
"Smoke waiting path opened.",
"",
`Pending confirmation: ${confirmation.id}`,
"Next-action owner: board/user accepts or rejects the mocked proposal.",
].join("\n"),
},
});
await api("PATCH", `/api/issues/${loop.id}`, {
body: {
status: "blocked",
blockedByIssueIds: [iteration.id],
comment: [
"Smoke loop parent is blocked by its iteration child while the typed confirmation is pending.",
"",
`Blocking iteration: ${iteration.identifier ?? iteration.id}`,
].join("\n"),
},
});
const [verifiedLoop, verifiedIteration, verifiedRunDoc, verifiedDiagnosisDoc, interactions] = await Promise.all([
api("GET", `/api/issues/${loop.id}`),
api("GET", `/api/issues/${iteration.id}`),
api("GET", `/api/issues/${iteration.id}/documents/run`),
api("GET", `/api/issues/${iteration.id}/documents/diagnosis`),
api("GET", `/api/issues/${iteration.id}/interactions`),
]);
assert(verifiedLoop.status === "blocked", `Expected loop issue to be blocked, got ${verifiedLoop.status}`);
assert(
Array.isArray(verifiedLoop.blockedBy) && verifiedLoop.blockedBy.some((blocker) => blocker.id === iteration.id),
"Expected loop issue to be blocked by the iteration child",
);
assert(
verifiedIteration.status === "in_review",
`Expected iteration issue to be in_review, got ${verifiedIteration.status}`,
);
assert(verifiedRunDoc.body.includes(`${artifactRoot}/results.jsonl`), "Expected run doc to include mocked results path");
assert(
verifiedDiagnosisDoc.body.includes("Exact stop point") && verifiedDiagnosisDoc.body.includes("Next-action owner"),
"Expected diagnosis doc to include exact stop point and next-action owner",
);
assert(
interactions.some((interaction) =>
interaction.id === confirmation.id
&& interaction.kind === "request_confirmation"
&& interaction.status === "pending"
&& interaction.continuationPolicy === "wake_assignee"
),
"Expected a pending request_confirmation interaction with wake_assignee continuation",
);
if (!args.keep) {
await api("PATCH", `/api/issues/${loop.id}`, {
body: {
status: "cancelled",
blockedByIssueIds: [],
comment: "Smoke cleanup: verified topology and cancelled the short-lived loop parent.",
},
});
await api("PATCH", `/api/issues/${iteration.id}`, {
body: {
status: "cancelled",
comment: "Smoke cleanup: verified confirmation/waiting posture and cancelled the short-lived iteration child.",
},
});
}
console.log(JSON.stringify({
ok: true,
cleanup: !args.keep,
loopIssue: { id: loop.id, identifier: loop.identifier ?? null },
iterationIssue: { id: iteration.id, identifier: iteration.identifier ?? null },
runDocument: runDocument.id,
diagnosisDocument: diagnosisDocument.id,
confirmation: confirmation.id,
artifactRoot,
}, null, 2));
}
main().catch((error) => {
console.error(`terminal-bench-loop skill smoke failed: ${error.message}`);
process.exit(1);
});
+5 -2
View File
@@ -14,7 +14,10 @@
* - Exact types: "application/pdf"
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
*/
import { MAX_COMPANY_ATTACHMENT_MAX_BYTES } from "@paperclipai/shared";
import {
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
} from "@paperclipai/shared";
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/png",
@@ -96,7 +99,7 @@ export const MAX_ATTACHMENT_BYTES =
export function normalizeIssueAttachmentMaxBytes(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return MAX_ATTACHMENT_BYTES;
return Math.min(DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES);
}
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES);
}
+1 -1
View File
@@ -34,7 +34,7 @@ You MUST delegate work rather than doing it yourself. When a task is assigned to
- If the board asks you to do something and you're unsure who should own it, default to the CTO for technical work.
- Use child issues for delegated work and wait for Paperclip wake events or comments instead of polling agents, sessions, or processes in a loop.
- Create child issues directly when ownership and scope are clear. Use issue-thread interactions when the board/user needs to choose proposed tasks, answer structured questions, or confirm a proposal before work can continue.
- Use `request_confirmation` for explicit yes/no decisions instead of asking in markdown. For plan approval, update the `plan` document, create a confirmation targeting the latest plan revision with an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and wait for acceptance before delegating implementation subtasks.
- Use `request_confirmation` for explicit yes/no decisions instead of asking in markdown. For plan approval, update the `plan` document, create a confirmation targeting the latest plan revision with an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, put the source issue in `in_review`, and wait for acceptance before delegating implementation subtasks.
- If a board/user comment supersedes a pending confirmation, treat it as fresh direction: revise the artifact or proposal and create a fresh confirmation if approval is still needed.
- Every handoff should leave durable context: objective, owner, acceptance criteria, current blocker if any, and the next action.
- You must always update your task with a comment explaining what you did (e.g., who you delegated to and why).
@@ -40,7 +40,7 @@ Status quick guide:
- `todo`: ready to execute, but not yet checked out.
- `in_progress`: actively owned work. Agents should reach this by checkout, not by manually flipping status.
- `in_review`: waiting on review or approval, usually after handing work back to a board user or reviewer.
- `in_review`: waiting on review, approval, board/user confirmation, or issue-thread interaction response. Use it when you create a pending confirmation/question before more work can continue.
- `blocked`: cannot move until something specific changes. Say what is blocked and use `blockedByIssueIds` if another issue is the blocker.
- `done`: finished.
- `cancelled`: intentionally dropped.
@@ -49,7 +49,7 @@ Status quick guide:
- Create subtasks with `POST /api/companies/{companyId}/issues`. Always set `parentId` and `goalId`. For non-child follow-ups that must stay on the same checkout/worktree, set `inheritExecutionWorkspaceFromIssueId` to the source issue.
- When you know the needed work and owner, create those subtasks directly. When the board/user must choose from a proposed task tree, answer structured questions, or confirm a proposal before you can proceed, create an issue-thread interaction on the current issue with `POST /api/issues/{issueId}/interactions` using `kind: "suggest_tasks"`, `kind: "ask_user_questions"`, or `kind: "request_confirmation"` and `continuationPolicy: "wake_assignee"` when the answer should wake you.
- For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, and do not create implementation subtasks until the board/user accepts it.
- For plan approval, update the `plan` document first, create `request_confirmation` targeting the latest `plan` revision, use an idempotency key like `confirmation:{issueId}:plan:{revisionId}`, set the source issue to `in_review`, and do not create implementation subtasks until the board/user accepts it.
- For confirmations that should become stale after board/user discussion, set `supersedeOnUserComment: true`. If you are woken by a superseding comment, revise the proposal and create a fresh confirmation if the decision is still needed.
- Use `paperclip-create-agent` skill when hiring new agents.
- Assign work to the right agent for the job.
+161
View File
@@ -0,0 +1,161 @@
---
name: diagnose-why-work-stopped
description: >
How to handle "why did this work stop / why is this looping?" assignments.
Forensics first on the named tree, surface the exact stop-point, frame the
fix as a general product rule that respects three invariants (productive
work continues, only real blockers stop work, no infinite loops), and
deliver a plan — no code changes — gated by board/CTO approval before
child issues are created. Use whenever the issue title or body asks for
forensics on a stalled, looping, or "went too deep" tree.
---
# Diagnose Why Work Stopped
A repeatable procedure for the recurring class of issues where the user (or a manager) points at a stalled / looping / over-recovered issue tree and asks "why did this stop / why is this looping / how do we make sure this doesn't happen again?"
This skill is **diagnostic + product-design**, not engineering. The output is a written root cause and an approved plan. No code changes leave this skill.
Canonical execution model: read `doc/execution-semantics.md` before diagnosing or proposing a new liveness/recovery rule. Use that document as the source of truth for status, action-path, post-run disposition, bounded continuation, productivity review, pause-hold, watchdog, and explicit recovery semantics. If the investigation finds a true product-rule gap, the plan should say whether `doc/execution-semantics.md` needs a matching update.
## When to use
Trigger on an assignment whose title or body matches any of:
- "why did this work stop", "why did this stall", "why did this just stop"
- "infinite loop", "looping", "spinning", "going too deep", "recovery went too deep"
- "liveness — what happened here", "this tree stopped working", "stuck"
- "approach it from a product perspective", "general product principle / rule"
- An attached link to a specific stalled / looping / over-recovered issue tree
Also use when the user asks for forensics, root cause, or a write-up *before* any product change.
## When NOT to use
- The assignment asks you to ship a code change directly. Use normal engineering flow.
- The assignment is a normal bug report against a specific feature. Use normal investigation.
- You are the original implementer being asked to fix your own bug. Use normal debugging.
## Three invariants you must preserve
Every diagnosis and every proposed rule must hold these three invariants together. The user has restated them on at least four issues; treat them as load-bearing:
1. **Productive work continues.** Agents that have a clear next action must keep working without needing the user to wake them. ([PAP-2674](/PAP/issues/PAP-2674), [PAP-2708](/PAP/issues/PAP-2708))
2. **Only real blockers stop work.** Stops happen when something genuinely cannot proceed (missing approval, missing dependency, human owner). Pseudo-stops (in_review with no action path, cancelled leaves, malformed metadata) must be detected and routed, not left silent. ([PAP-2335](/PAP/issues/PAP-2335), [PAP-2674](/PAP/issues/PAP-2674))
3. **No infinite loops.** Stranded-work recovery and continuation loops must be bounded and distinguishable from genuinely productive continuation. ([PAP-2602](/PAP/issues/PAP-2602), [PAP-2486](/PAP/issues/PAP-2486))
If a proposed rule violates any of the three, drop it or rework it. State explicitly in the plan how each invariant is held.
## Procedure
### 0. Read the current execution contract
Before walking the tree, read `doc/execution-semantics.md` and keep its terms intact:
- live path / waiting path / recovery path
- post-run disposition: terminal, explicitly live, explicitly waiting, invalid
- bounded `run_liveness_continuation`
- productivity review vs liveness recovery
- active subtree pause holds
- silent active-run watchdog
Do not invent a new rule until you can state how it differs from the current execution semantics document.
### 1. Forensics on the named tree — before anything else
Do this in the same heartbeat. Do not propose a rule until you have a concrete stop point.
- Open the linked issue (and its blocker chain, parents, recovery siblings, recent runs).
- Walk the tree node-by-node and find the exact issue + state combination that stops the world. Common shapes seen in the company so far:
- `in_review` with no typed execution participant, no active run, no pending interaction, no recovery issue ([PAP-2335](/PAP/issues/PAP-2335), [PAP-2674](/PAP/issues/PAP-2674)).
- `in_progress` after a successful run with no future action path queued ([PAP-2674](/PAP/issues/PAP-2674)).
- Blocker chain whose leaf is `cancelled` / malformed / cross-company-inaccessible ([PAP-2602](/PAP/issues/PAP-2602)).
- `issue.continuation_recovery` waking the same issue >N times after successful runs ([PAP-2602](/PAP/issues/PAP-2602)).
- Stranded-work recovery treating its own recovery issues as more recoverable source work ([PAP-2486](/PAP/issues/PAP-2486)).
- Quote the evidence: run ids, comment timestamps, status transitions. "Inferred" is acceptable only when an API boundary blocks direct evidence — say so explicitly and mark the claim provisional ([PAP-2631](/PAP/issues/PAP-2631)).
Respect the API boundary. If the linked issue is in another company and your agent token returns 403, do not bypass scoping. Either request a board-approved diagnostic path or proceed from inferred PAP-side evidence and label it.
### 2. Survey recent related work
Before proposing a new product rule, read what already shipped this week in the same area. The user has explicitly called this out: ([PAP-2602](/PAP/issues/PAP-2602)) "review our recent work on liveness that we shipped in the last couple of days." A new rule that contradicts code merged 48 hours ago is rework, not improvement.
Quick survey:
- Recent merged PRs in the affected area.
- Recent done issues whose title mentions liveness, recovery, productivity, continuation, or the affected subsystem.
- Any active plan documents on parent issues. The fix may belong as a revision to an existing plan, not as a new top-level proposal.
State in the forensics: "I reviewed X, Y, Z. The new gap is …"
### 3. Classify each non-progressing issue in the tree
For every issue in the affected tree that is not `done` / `cancelled` / actively running, decide:
- **Truly needs human or board intervention** — name the owner and the action.
- **Agent-actionable but not currently routed** — name the rule that would have routed it, and the agent that should have been waked.
- **Already covered** — point at the active run, queued wake, recovery issue, or pending interaction.
This is the table the user has asked for repeatedly ([PAP-2335](/PAP/issues/PAP-2335)). Without it the plan is abstract.
### 4. Frame as a general product rule
The user does not want a one-off patch on the named tree. They want the rule. Two checks:
- The rule is **stated as a contract**, not as an if/else patch. Example contract: "every agent-owned non-terminal issue must finish each heartbeat with a terminal state, an explicit waiting path, or an explicit live path" ([PAP-2674](/PAP/issues/PAP-2674)).
- The rule is reconciled against `doc/execution-semantics.md`. Prefer citing and applying the existing contract; propose a document change only when the current doc is incomplete or contradicted by accepted/implemented behavior.
- The rule **explicitly preserves the three invariants** above. Show the work.
If the rule would have blocked a recent productive run from succeeding, drop or narrow it.
### 5. Plan, do not code
Write the plan into the issue's `plan` document. Cover:
- Forensics summary (root cause + evidence).
- The general product rule, stated as a contract.
- Whether the existing `doc/execution-semantics.md` contract already covers the case, or what exact documentation update is needed.
- Phased subtasks: typically `Phase 0` resolves the named live tree (carefully, not destructively), `Phase 1` codifies the contract in docs, then implementation phases for detection, recovery, UI surfacing, security review, QA, and CTO review.
- Explicit assignees per phase; favor team specialty (CodexCoder for server, ClaudeCoder for FE, UXDesigner for visible state, SecurityEngineer for ownership/permissions, QA for validation).
- Blocking dependencies wired with `blockedByIssueIds`, parallel branches identified.
Do not create the child issues yet. Do not push code.
### 6. Request approval, then decompose
- Open a `request_confirmation` interaction targeting the latest plan revision. Idempotency key `confirmation:{issueId}:plan:{revisionId}`.
- Wait for board/CTO acceptance. If the user posts a new comment that supersedes the plan, the prior confirmation is invalidated — open a fresh confirmation tied to the new revision ([PAP-2602](/PAP/issues/PAP-2602) cycled three revisions; that is fine).
- Only after acceptance: create the phased child issues with the right assignees and dependencies, then block this parent on the final QA / CTO review issue so the parent only wakes when the chain finishes.
### 7. Phase 0 hygiene on the named tree
Phase 0 cleans up the live tree without papering over evidence:
- Move stalled `in_review` leaves with no participant to `todo` with a precise next action and named owner ([PAP-2335](/PAP/issues/PAP-2335)).
- Detach cancelled/dead blockers from chains they were holding hostage; do not silently mark issues `done` to clear backlog.
- Leave a comment on the original named issue summarizing what changed and why; never hide the recovery chain history.
### 8. Final close-out
When the phase chain is complete, post a board-level summary comment on the parent issue: what changed, what the new contract is, what the rollout step is (e.g. "restart the control-plane to pick up the new response shape"), and the live state of the originally-named tree. Then close the parent.
## Pitfalls
- **Coding before approval.** The user has said "make a plan first" on every recent diagnostic issue. Producing code in the forensic phase wastes the round-trip.
- **Restating one invariant at the cost of another.** Bound continuation too tightly and productive work stalls; loosen recovery and infinite loops return. Always check all three.
- **Skipping the recent-work survey.** Proposing a contract that contradicts what shipped 24 hours ago is the easiest way to get the plan rejected.
- **Letting "in_review" mean done.** A leaf assigned to another agent with no participant or active run is not progress; treat it as a stop.
- **Bypassing company scoping.** Cross-company forensics needs a board-approved diagnostic path, not a database read.
- **Recursive recovery.** Stranded-work recovery that recovers its own recovery issues is the canonical infinite loop ([PAP-2486](/PAP/issues/PAP-2486)). Detect it and refuse to deepen.
- **Hiding the chain.** Don't silently delete or hide the symptomatic recovery issues — the operator needs the audit trail.
## Verification checklist (before posting the plan)
- [ ] The exact stop point in the named tree is identified with run ids / comment ids.
- [ ] Recent shipped work in the same area was surveyed and is referenced.
- [ ] Every non-progressing issue is classified human-needed / agent-actionable / already-covered.
- [ ] The proposed rule is stated as a contract, not a patch.
- [ ] All three invariants are explicitly preserved.
- [ ] No code change has landed in this heartbeat.
- [ ] A `request_confirmation` against the latest plan revision is open.
- [ ] Phase 0 of the plan addresses the live named tree without destroying evidence.
- [ ] Implementation phases name specialty-appropriate assignees and `blockedByIssueIds` dependencies.
+6 -3
View File
@@ -89,6 +89,7 @@ If `currentParticipant` does not match you, do not try to advance the stage —
- If the issue is actionable, start concrete work in the same heartbeat. Do not stop at a plan unless the issue specifically asks for planning.
- Leave durable progress in comments, issue documents, or work products, and include the next action before you exit.
- Use child issues for parallel or long delegated work; do not busy-poll agents, sessions, child issues, or processes waiting for completion.
- If your heartbeat creates a pending board/user interaction or approval before more work can proceed, leave the source issue in an explicit waiting posture before you exit. Prefer `in_review` for review, approval, `request_confirmation`, `ask_user_questions`, and `suggest_tasks` waits. Use `blocked` with `blockedByIssueIds` when another issue is the blocker.
- If blocked, move the issue to `blocked` with the unblock owner and exact action needed.
- Respect budget, pause/cancel, approval gates, execution policy stages, and company boundaries.
@@ -121,7 +122,7 @@ Status values: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`,
- `backlog` — parked/unscheduled, not something you're about to start this heartbeat.
- `todo` — ready and actionable, but not checked out yet. Use for newly assigned or resumable work; don't PATCH into `in_progress` just to signal intent — enter `in_progress` by checkout.
- `in_progress` — actively owned, execution-backed work.
- `in_review` — paused pending reviewer/approver/board/user feedback. Use when handing work off for review; not a synonym for done. If a human asks to take the task back, reassign to them and set `in_review`.
- `in_review` — paused pending reviewer/approver/board/user feedback. Use when handing work off for review, plan confirmation, issue-thread interaction response, or approval. This is a healthy waiting path, not a synonym for done. If a human asks to take the task back, reassign to them and set `in_review`.
- `blocked` — cannot proceed until something specific changes. Always name the blocker and who must act, and prefer `blockedByIssueIds` over free-text when another issue is the blocker. `parentId` alone does not imply a blocker.
- `done` — work complete, no follow-up on this issue.
- `cancelled` — intentionally abandoned, not to be resumed.
@@ -285,9 +286,11 @@ When you mention a plan or another issue document in a comment, include a direct
If the issue identifier is available, prefer the document deep link over a plain issue link so the reader lands directly on the updated document.
If you're asked to make a plan, _do not mark the issue as done_. Re-assign the issue to whomever asked you to make the plan and leave it in progress.
If you're asked to make a plan, _do not mark the issue as done_. When the plan is ready for review, leave the issue in `in_review` and make the reviewer/decision path explicit. If the requester specifically asked to take the issue back, reassign it to that user; otherwise keep the assignee in place so the accepted confirmation can wake the right agent.
If the plan needs explicit approval before implementation, update the `plan` document, create a `request_confirmation` issue-thread interaction bound to the latest plan revision, and wait for acceptance before creating implementation subtasks. See `references/api-reference.md` for the interaction payload.
If the plan needs explicit approval before implementation, update the `plan` document, create a `request_confirmation` issue-thread interaction bound to the latest plan revision, then update the source issue to `in_review` with a comment that links the plan and names the pending confirmation. This is a deliberate waiting path, not an abandoned productive run. Wait for acceptance before creating implementation subtasks. See `references/api-reference.md` for the interaction payload.
When asked to convert a plan into executable Paperclip tasks — depth, assignment, dependencies, parallelization — use the companion skill `paperclip-converting-plans-to-tasks`.
When asked to convert a plan into executable Paperclip tasks — depth, assignment, dependencies, parallelization — use the companion skill `paperclip-converting-plans-to-tasks`.
+6 -2
View File
@@ -683,7 +683,8 @@ Rules:
- Rejection does not wake the assignee by default. The board/user can add a normal comment when revisions are needed.
- Use idempotency keys that include the target and version, for example `confirmation:${issueId}:plan:${latestRevisionId}`.
- Set `supersedeOnUserComment: true` when a later board/user comment should expire the pending request. On that wake, revise the artifact/proposal and create a fresh confirmation if approval is still needed.
- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, and wait for acceptance before creating implementation subtasks.
- A pending interaction is an explicit waiting path. Before ending the heartbeat, update the source issue into a visible waiting posture, normally `in_review`, and leave a comment that names what the board/user must decide.
- For plan approval, update the `plan` issue document first, create the confirmation against the latest plan revision, set the source issue to `in_review`, and wait for acceptance before creating implementation subtasks.
### Checking approval status
@@ -724,7 +725,7 @@ Terminal states: `done`, `cancelled`
- `backlog` = not ready to execute yet.
- `todo` = ready to execute, but not actively checked out yet.
- `in_progress` = actively owned work. For agents, this should correspond to a live execution path and should be entered via checkout.
- `in_review` = waiting on review or approval action, not active execution.
- `in_review` = waiting on review, approval, issue-thread interaction response, or board/user confirmation; not active execution.
- `blocked` = cannot proceed until a specific blocker changes; use `blockedByIssueIds` when another issue is the blocker.
- `done` = completed.
- `cancelled` = intentionally abandoned.
@@ -733,6 +734,9 @@ Terminal states: `done`, `cancelled`
- `completed_at` is auto-set on `done`.
- One assignee per task at a time.
- `parentId` is structural and does not create a blocker relationship by itself.
- Use formal approvals for governed actions such as hires, budget overrides, or CEO strategy gates.
- Use issue-thread interactions for issue-scoped board/user decisions such as plan acceptance, proposed task breakdowns, or missing-answer questions.
- Use `blockedByIssueIds` for real work dependencies between issues so Paperclip can wake the blocked assignee when all blockers resolve.
---
+230
View File
@@ -0,0 +1,230 @@
---
name: terminal-bench-loop
description: >
Run a single Terminal-Bench problem through Paperclip in a bounded,
human-in-the-loop improvement cycle until the smoke passes, the board
rejects the next fix, the iteration budget is exhausted, or a real
blocker is named. Each iteration runs a bounded smoke against an
isolated Paperclip App worktree, captures artifacts, diagnoses the
exact stop point with `/diagnose-why-work-stopped`, requests board
confirmation before any product fix, then reruns against the same
worktree. Use whenever an issue asks to "run Terminal-Bench in a
loop", "drive Terminal-Bench until it passes", "loop fix-git through
Paperclip", or otherwise points at a Terminal-Bench task and asks for
bounded iteration with diagnosis.
---
# Terminal-Bench Loop
A repeatable operating skill for driving one Terminal-Bench problem to a passing smoke through Paperclip, with explicit issue topology, bounded runs, board-gated product fixes, and worktree continuity.
This skill is **operational + diagnostic**, not engineering. It coordinates issues, artifacts, and approvals around a Terminal-Bench loop. It does not authorize code changes — every accepted product fix lands as a separate implementation child issue after a board confirmation.
Canonical execution model: read `doc/execution-semantics.md` before starting a loop or moving any loop issue. Every loop issue must rest in a state the doc allows: terminal (`done`/`cancelled`), explicitly live (active run / queued wake), explicitly waiting (`in_review` with participant/interaction/approval), or explicit recovery/blocker (`blocked` with `blockedByIssueIds` and a named owner).
## When to use
Trigger on an assignment whose title or body matches any of:
- "run Terminal-Bench in a loop", "loop \<task-name\> through Paperclip"
- "drive Terminal-Bench fix-git", "iterate on Terminal-Bench until it passes"
- "Terminal-Bench smoke loop", "bench loop", "smoke loop on \<task-name\>"
- An attached link to a Terminal-Bench loop parent issue, plus a request to do another iteration
Also use when the user hands you an existing top-level loop issue and asks for the next iteration, diagnosis, or rerun.
## When NOT to use
- The assignment is to build or change `paperclip-bench` itself (Harbor adapter, wrapper, telemetry). Use normal engineering flow on that repo.
- The assignment is to submit a benchmark result for ranking. This skill produces smoke/non-comparable runs by design — escalate full-suite or comparable runs to BenchmarkQualityManager.
- The assignment is a normal Paperclip product bug not surfaced by a Terminal-Bench loop. Use normal investigation.
- You have not been granted permission to install or assign company skills, and the asker actually wants library mutation. Hand that step to an authorized skill-library owner.
## Three invariants you must preserve
Every loop iteration and every proposed product fix must hold these three invariants together. They come from `/diagnose-why-work-stopped` and the user has restated them across the liveness work:
1. **Productive work continues.** Each loop issue must always have a clear next action owner — agent, board, user, or named blocker. No silent `in_review` with nothing waiting on it.
2. **Only real blockers stop work.** Stops happen when something genuinely cannot proceed (board confirmation, QA, missing credentials, exhausted budget). Pseudo-stops must be detected and routed.
3. **No infinite loops.** Iteration count, wall-clock budget, and a board gate before product fixes are applied keep the loop bounded.
If a proposed iteration violates any of the three, drop it or rework it. State explicitly in the loop issue how each invariant is held this iteration.
## Inputs
Collect these on the top-level loop issue before iteration 1. Any input that cannot be supplied is a blocker — name the unblock owner and stop.
- **Source issue.** The Paperclip issue that asked for the loop. The loop parent links back to it.
- **Terminal-Bench task name.** Single-task identifier (e.g. `terminal-bench/fix-git`). Multi-task suites are out of scope for this skill.
- **Iteration budget.** Maximum number of iterations before the loop must stop without further fixes (typical: 35). Also record a per-iteration wall-clock cap.
- **Paperclip App worktree issue.** The implementation-side issue under the Paperclip App project whose execution workspace owns the isolated worktree. First iteration creates it; later iterations reuse it via `inheritExecutionWorkspaceFromIssueId` or equivalent.
- **Benchmark command.** The exact `paperclip-bench` invocation, including the `PAPERCLIPAI_CMD` (or equivalent) binding pinned to the Paperclip App worktree under test. Record verbatim on the loop issue.
- **Latest artifact root.** Filesystem or storage path under which `paperclip-bench` writes run artifacts (manifest, `results.jsonl`, Harbor raw job folders, redacted telemetry). Each iteration appends; nothing is overwritten.
- **Approval policy.** Who must accept a proposed product fix before implementation (default: board via `request_confirmation`; CTO if delegated; never the loop driver alone).
Record each input on the top-level loop issue (description or a dedicated `inputs` document). If any input changes mid-loop, note the change and the iteration it took effect.
## Issue topology
The loop must be representable as a tree, not as prose in comments:
- **Top-level loop issue.** Long-lived. Holds inputs, iteration counter, current state, links to every iteration child, and the product-rule history. Rests in `in_progress` while an iteration is running, `in_review` only when a typed waiter sits directly on the loop parent (execution-policy participant, `request_confirmation` / `ask_user_questions` / `suggest_tasks` interaction, approval, or named human owner), `blocked` with `blockedByIssueIds` while a child issue is the gating work (iteration child holding the fix-proposal `request_confirmation`, or implementation, QA, or CTO review children), `done` on pass, or `cancelled` on board-rejection / budget exhaustion.
- **Iteration child issues.** One per iteration. Each carries: a bounded run issue (smoke), a diagnosis issue (applies `/diagnose-why-work-stopped`), a fix-proposal document with a `request_confirmation` interaction, and — only after acceptance — implementation, QA, CTO review, and rerun children. Iteration children are blocked by their predecessors so the executor wakes them in order.
- **Paperclip App implementation issue.** The first iteration creates a fresh Paperclip App child whose project policy spawns an isolated worktree. Every later iteration's implementation/rerun child references that same execution workspace via `inheritExecutionWorkspaceFromIssueId` so the same worktree is amended and tested.
Wire dependencies with `blockedByIssueIds`, never with prose like "blocked by X". When a dependent child is `done`, the executor auto-wakes the next.
## Procedure
### 0. Read the current execution contract
Before opening or advancing a loop, read `doc/execution-semantics.md`. Use that document's terms intact when classifying loop-issue state: live path / waiting path / recovery path; post-run disposition; bounded continuation; productivity review; pause-hold; watchdog. Do not invent a new state.
### 1. Open or reuse the top-level loop issue
- If an existing loop issue is supplied, read it: inputs, iteration counter, last iteration's stop reason, current Paperclip App worktree pointer, latest benchmark command.
- If no loop issue exists, create one under the Paperclip App project (or the project the source issue points at). Title: `Terminal-Bench loop: <task-name>`. Description captures the inputs above, the iteration budget, and a link to the source issue.
- Verify the worktree pointer still resolves. If the recorded execution workspace was discarded (worktree pruned, project changed), the loop is blocked — name the unblock owner (CodexCoder or the Paperclip App owner) and stop.
### 2. Open the iteration child
- Increment the iteration counter on the loop issue.
- Create an iteration child titled `Iteration N: <task-name>`. Its description repeats the inputs and references the loop parent. Block it on the prior iteration's terminal child (if any) so the executor cannot start two iterations in parallel.
- If the iteration counter would exceed the budget, do not create the child. Move the loop issue to `cancelled` (budget exhausted) or `in_review` if the user must decide whether to extend the budget.
### 3. Run the bounded smoke
- The benchmark command must use the Paperclip App worktree under test. Set `PAPERCLIPAI_CMD` (or the equivalent command binding) to the CLI entrypoint inside that worktree. Never let the smoke run against the operator's current Paperclip checkout.
- Bound the run by wall-clock and by Paperclip's run-budget controls. If the smoke would exceed the per-iteration cap, kill it and record the truncation reason.
- Capture, in the iteration child or a dedicated `run` document:
- Paperclip run id and heartbeat run ids
- benchmark run id, manifest, `results.jsonl` row, Harbor raw job folder
- the exact stop reason reported by the harness (pass, harness fail, verifier fail, timeout, agent gave up, infrastructure error)
- failure taxonomy bucket (task/model, Paperclip product, harness/setup, verifier/infrastructure, security, unclear)
- artifact paths under the latest artifact root
- Label the iteration as **smoke / non-comparable**. Comparable runs are out of scope for this skill.
### 4. Diagnose the exact stop point
Apply the `/diagnose-why-work-stopped` pattern to the iteration's run, scoped to this loop only — do not pull in unrelated forensic boilerplate. Specifically:
- Walk the Paperclip issue tree the smoke produced under the Paperclip App worktree, node by node, and find the exact `(issue, status)` combination that stopped progress. Quote evidence: run ids, comment timestamps, status transitions.
- Classify every non-progressing issue in that subtree as **truly needs human/board intervention**, **agent-actionable but not currently routed**, or **already covered**.
- State whether the failure is task/model, Paperclip product, harness/setup, verifier/infrastructure, security, or unclear. Be explicit when evidence is inferred (e.g. cross-company API boundary blocks direct reads).
- If the failure is a Paperclip product gap, frame the fix as a **general product rule** stated as a contract, and check it against the three invariants above. If the rule would have blocked a recent productive run, narrow it.
Record the diagnosis on the iteration child as a `diagnosis` document. Do not propose code yet.
### 5. Decide the next move
Based on the diagnosis, the iteration ends in exactly one of these terminal-for-iteration states:
- **Pass.** Smoke verifier reports pass. Move the iteration child and the loop parent toward QA/CTO review (Step 8).
- **Product fix proposed.** A Paperclip product gap was identified. Write the fix proposal as a `plan` document on the iteration child, then go to Step 6.
- **Non-product failure with retry.** Failure is harness/setup/infrastructure or model flakiness, the iteration budget is not exhausted, and the loop driver believes a rerun without code changes has signal (e.g. transient infra). Record the rationale on the iteration child and go to Step 7 with no implementation step.
- **Real blocker.** Named external blocker (credentials, quota, third-party outage, security review). Move the loop issue to `blocked`, set `blockedByIssueIds` to the blocker issue (creating one if needed), and name the unblock owner. Stop.
- **Budget or board stop.** Iteration budget reached, or the board has rejected the next fix proposal. Move the loop issue to `cancelled` with a comment that summarizes the run history and the reason for stopping.
### 6. Request board confirmation before any product fix
When the iteration ends in **product fix proposed**:
- Update the iteration child's `plan` document with the proposed contract, the three-invariant check, the affected Paperclip surfaces, and the phased subtasks (implementation, QA, CTO review, rerun) — but do not create those subtasks.
- Open the `request_confirmation` interaction on the **iteration child** (the same issue that owns the `plan` document), targeting the latest plan revision. Idempotency key: `confirmation:{iterationIssueId}:plan:{revisionId}`. Set `continuationPolicy` to `wake_assignee`.
- Move the **iteration child** to `in_review`. The typed waiter — the `request_confirmation` interaction — sits directly on it, so its `in_review` is healthy. Comment links the plan document and names the pending confirmation.
- Move the **loop parent** to `blocked` with `blockedByIssueIds: [iterationChildId]` and a comment naming the board (or whichever approver the approval policy designates) as the unblock owner. Do not move the loop parent to `in_review` here: the typed waiter lives on the iteration child, not on the parent, so the parent's wait path is the child blocker. This matches the topology rule that the loop parent only sits in `in_review` when a typed waiter is attached directly to the parent.
- Wait for acceptance. If the board posts a superseding comment that changes the plan, revise the document, then open a fresh confirmation tied to the new revision on the iteration child — the prior one is invalidated. The loop parent's `blockedByIssueIds` already points at the iteration child, so it does not need to change.
- On rejection, end the loop per the **Budget or board stop** rule; do not silently retry the same proposal.
- On acceptance, create the implementation, QA, CTO review, and rerun child issues with `blockedByIssueIds` wired in order, and update the loop parent's `blockedByIssueIds` to point at the new gating child (typically the implementation child) so the parent stays `blocked` against real downstream work. The implementation child must inherit the Paperclip App execution workspace (`inheritExecutionWorkspaceFromIssueId` to the worktree-owning issue) so the fix lands in the same isolated worktree the smoke ran against.
### 7. Rerun against the same worktree
After implementation and QA complete (or immediately, in the **non-product failure with retry** case), the rerun child runs the same `paperclip-bench` invocation with `PAPERCLIPAI_CMD` still pinned to the Paperclip App worktree under test.
- The rerun must use the same worktree the fix landed in. If the workspace was reset between iterations, the loop is invalid — open a blocker on the loop issue and stop.
- On completion, the rerun child becomes the next iteration's run record. If the smoke now passes, jump to Step 8. Otherwise return to Step 4 with a new iteration child (subject to the iteration budget).
### 8. Pass: QA, CTO review, close
When the smoke passes:
- Create QA and CTO review children if they are not already in the dependency chain (CTO review blocked by QA, so the chain wakes in order). Move the loop parent to `blocked` with `blockedByIssueIds` set to the QA / CTO review chain, and post a comment that names QA and CTO as the unblock owners and links the children. The loop parent stays `blocked` — not `in_review` — because the typed waiter lives on the children, not on the parent.
- If you instead want the loop parent itself to sit in `in_review` during this phase (for example because a board user has explicitly volunteered to drive the review), put a typed waiter directly on the parent — execution-policy participant, `request_confirmation` / `ask_user_questions` / `suggest_tasks` interaction, approval, or named human owner — and do not rely on the child chain alone. Do not combine `in_review` on the parent with QA/CTO children acting as the blocker; that is the ambiguous review shape this skill exists to prevent.
- QA validates artifacts (manifest, `results.jsonl`, Harbor raw job, redacted telemetry) and the rerun reproducibility against the same worktree.
- CTO reviews the technical scope of any product fixes that landed during the loop.
- On QA + CTO acceptance, close the loop issue with a board-level summary comment: task name, iteration count, stop reason (pass), worktree pointer, link to the final artifact root, and the list of accepted product fixes (each with its implementation issue id).
### 9. Stop rules
The loop **must** stop, with state explicitly recorded on the loop issue, when any of these is true:
- **Pass.** Smoke verifier reports pass and QA + CTO accept (Step 8). Loop issue → `done`.
- **Board rejection.** Board rejects a fix proposal and does not request a revision. Loop issue → `cancelled`. Comment names the rejected proposal and the reason.
- **Iteration budget reached.** Iteration counter reaches the budget without a pass. Loop issue → `cancelled` (or `in_review` if the user must decide whether to extend the budget). Never silently start iteration N+1.
- **Real blocker named.** External blocker (credentials, quota, infra, security, missing skill) cannot be resolved by the loop driver. Loop issue → `blocked` with `blockedByIssueIds` to the blocker issue and the unblock owner named.
A loop must never end on a prose comment alone. Every stop is a status transition with a named next-action owner.
## Worktree rule
The loop must not test whatever Paperclip checkout happens to be current for the heartbeat. It must test the same isolated Paperclip App worktree where proposed fixes are applied.
- The first iteration creates the Paperclip App implementation child; that project's git-worktree policy spawns a fresh worktree.
- The loop issue records the worktree-owning issue id and the workspace path (or workspace id).
- Every later implementation, QA, and rerun child sets `inheritExecutionWorkspaceFromIssueId` to that worktree-owning issue, so all subsequent loop work shares one workspace.
- The benchmark command always sets `PAPERCLIPAI_CMD` (or the equivalent command binding) to the CLI entrypoint inside that worktree. The benchmark command stored on the loop issue is the source of truth — if a heartbeat needs to run the smoke from a different shell, it copies the recorded command verbatim.
- If the workspace is pruned or the worktree path no longer resolves, the loop is invalid until rebuilt. Mark the loop `blocked` and name the unblock owner (typically CodexCoder or the Paperclip App owner).
## Liveness rule
Every loop issue, at the end of every heartbeat, must rest in one of:
- **Terminal:** `done` or `cancelled`. No further action.
- **Explicitly live:** `in_progress` with an active run, an upcoming queued wake, or a child issue actively executing under it.
- **Explicitly waiting:** `in_review` with a typed waiter — execution-policy participant, `request_confirmation` / `ask_user_questions` / `suggest_tasks` interaction, approval, or a named human owner.
- **Explicit recovery / blocker:** `blocked` with `blockedByIssueIds` set to a real blocking issue, plus a comment naming the unblock owner and the action needed.
If a loop issue does not fit one of these on exit, the heartbeat is not done. Fix the state before exiting.
## Pitfalls
- **Running the smoke against the operator's Paperclip checkout.** The whole point of the worktree rule is that the bench tests the worktree the fix lands in. Always set `PAPERCLIPAI_CMD` and verify the path before launching the run.
- **Coding before approval.** No implementation child exists until a board confirmation accepts the iteration's `plan` document. Do not push code in the diagnostic phase.
- **Skipping the recent-work survey.** When proposing a Paperclip product rule, check what already shipped in the affected liveness/execution area in the last few days. A rule that contradicts last-week's accepted contract is rework.
- **Letting `in_review` mean done.** A loop or iteration child sitting in `in_review` with no participant, no interaction, no approval, and no human owner is a stop, not progress. Treat it as a liveness violation and route it.
- **Silent iteration N+1.** If the iteration budget is reached, never start another iteration without an explicit budget extension recorded on the loop issue.
- **Comparable-run drift.** This skill produces smoke runs only. If the asker wants a comparable benchmark submission, hand off to BenchmarkQualityManager and BenchmarkForensics — do not relabel a smoke as comparable.
- **Recursive recovery.** Stranded-work recovery that recovers its own recovery issues is the canonical infinite loop. If a diagnosis surfaces it inside the smoke's subtree, refuse to deepen and route to `/diagnose-why-work-stopped` for a product-rule fix.
- **Skill-library mutation.** This skill never installs, edits, or assigns company skills as part of a loop iteration. Library changes go to an authorized skill-library owner via a separate issue.
- **Hiding the chain.** Do not silently delete or hide failed iteration children, retracted proposals, or rejected confirmations. The audit trail is the loop's evidence.
## Verification checklist (before exiting a heartbeat that touched the loop)
- [ ] All inputs are recorded on the top-level loop issue, including the exact benchmark command and `PAPERCLIPAI_CMD` binding.
- [ ] Iteration counter is up to date and within budget.
- [ ] The Paperclip App worktree pointer still resolves, and the iteration's run/implementation/rerun children share that workspace.
- [ ] The smoke run is captured with run ids, manifest, `results.jsonl`, Harbor raw job folder, and stop reason.
- [ ] Diagnosis applies the `/diagnose-why-work-stopped` pattern, classifies every non-progressing issue, and checks the three invariants.
- [ ] No implementation child exists for an unapproved fix proposal; if one was proposed, a `request_confirmation` is open against the latest plan revision.
- [ ] Every loop and iteration issue rests in a terminal, explicitly-live, explicitly-waiting, or named-blocker state.
- [ ] The stop reason — if the loop stopped this heartbeat — is one of pass, board rejection, budget exhausted, or named real blocker.
- [ ] No company-skill library mutation happened in this heartbeat.
## Deterministic smoke
Run this smoke after installing or changing the skill, before treating it as operational for a live Terminal-Bench loop:
```sh
pnpm smoke:terminal-bench-loop-skill
```
The command uses the current Paperclip API token and company from `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, and `PAPERCLIP_COMPANY_ID`. When `PAPERCLIP_TASK_ID` is set, it attaches the smoke issues under that source issue and inherits its project/goal context. By default it cancels the short-lived smoke issues after verification; pass `-- --keep` to leave the verified `blocked` loop parent, `in_review` iteration child, and pending confirmation available for manual inspection.
The smoke is deterministic and intentionally non-comparable. It does not start Terminal-Bench, Harbor, an agent model, or a provider runtime. It verifies only the control-plane shape:
- local `skills/terminal-bench-loop/SKILL.md` contains the loop contract terms;
- a top-level loop issue can be created and updated into a blocker posture;
- an iteration child issue can be created under the loop parent;
- mocked benchmark artifact paths are recorded on a `run` document;
- a `diagnosis` document names the exact stop point and next-action owner;
- a `request_confirmation` interaction is created and the iteration child rests in `in_review` with a typed waiting path rather than silent review.
+1
View File
@@ -262,6 +262,7 @@ export function App() {
<Route path="board-claim/:token" element={<BoardClaimPage />} />
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
+1
View File
@@ -32,6 +32,7 @@ export const companiesApi = {
| "description"
| "status"
| "budgetMonthlyCents"
| "attachmentMaxBytes"
| "requireBoardApprovalForNewAgents"
| "feedbackDataSharingEnabled"
| "brandColor"
-22
View File
@@ -784,28 +784,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
)}
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
{isLocal && isCreate && (
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={val!.promptTemplate}
onChange={(v) => set!({ promptTemplate: v })}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = "agents/drafts/prompt-template";
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
</div>
</>
)}
{/* Adapter-specific fields are rendered inside Permissions & Configuration */}
</div>
+43 -3
View File
@@ -122,6 +122,9 @@ interface IssueChatMessageContext {
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
stopRunVariant?: "stop" | "pause";
onInterruptQueued?: (runId: string) => Promise<void>;
onCancelQueued?: (commentId: string) => void;
onImageClick?: (src: string) => void;
@@ -137,6 +140,9 @@ interface IssueChatMessageContext {
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
@@ -273,6 +279,9 @@ interface IssueChatThreadProps {
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
onCancelRun?: () => Promise<void>;
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
stopRunVariant?: "stop" | "pause";
imageUploadHandler?: (file: File) => Promise<string>;
onAttachImage?: (file: File) => Promise<IssueAttachment | void>;
draftKey?: string;
@@ -308,6 +317,9 @@ interface IssueChatThreadProps {
interaction: AskUserQuestionsInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void> | void;
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
composerRef?: Ref<IssueChatComposerHandle>;
/**
* Hook for the parent to refetch comments when the user explicitly asks
@@ -1335,6 +1347,9 @@ function IssueChatAssistantMessage({
onVote,
agentMap,
onStopRun,
stopRunLabel = "Stop run",
stoppingRunLabel = "Stopping...",
stopRunVariant = "stop",
} = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@@ -1528,13 +1543,21 @@ function IssueChatAssistantMessage({
{canStopRun && onStopRun && runId ? (
<DropdownMenuItem
disabled={isStoppingRun}
className="text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200"
className={cn(
stopRunVariant === "pause"
? "text-amber-700 focus:text-amber-800 dark:text-amber-300 dark:focus:text-amber-200"
: "text-red-700 focus:text-red-800 dark:text-red-300 dark:focus:text-red-200",
)}
onSelect={() => {
void onStopRun(runId);
}}
>
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
{isStoppingRun ? "Stopping..." : "Stop run"}
{stopRunVariant === "pause" ? (
<PauseCircle className="mr-2 h-3.5 w-3.5" />
) : (
<Square className="mr-2 h-3.5 w-3.5 fill-current" />
)}
{isStoppingRun ? stoppingRunLabel : stopRunLabel}
</DropdownMenuItem>
) : null}
{runHref ? (
@@ -1791,6 +1814,7 @@ function ExpiredRequestConfirmationActivity({
userLabelMap,
onAcceptInteraction,
onRejectInteraction,
onCancelInteraction,
} = useContext(IssueChatCtx);
const [expanded, setExpanded] = useState(false);
const hasResolvedActor = Boolean(interaction.resolvedByAgentId || interaction.resolvedByUserId);
@@ -1869,6 +1893,7 @@ function ExpiredRequestConfirmationActivity({
userLabelMap={userLabelMap}
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onCancelInteraction={onCancelInteraction}
/>
</div>
) : null}
@@ -1884,6 +1909,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
} = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const anchorId = typeof custom.anchorId === "string" ? custom.anchorId : undefined;
@@ -1929,6 +1955,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={onSubmitInteractionAnswers}
onCancelInteraction={onCancelInteraction}
/>
</div>
</div>
@@ -3061,6 +3088,9 @@ export function IssueChatThread({
onAdd,
onCancelRun,
onStopRun,
stopRunLabel,
stoppingRunLabel,
stopRunVariant,
imageUploadHandler,
onAttachImage,
draftKey,
@@ -3087,6 +3117,7 @@ export function IssueChatThread({
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
composerRef,
onRefreshLatestComments,
}: IssueChatThreadProps) {
@@ -3544,6 +3575,7 @@ export function IssueChatThread({
const stableOnAcceptInteraction = useStableEvent(onAcceptInteraction);
const stableOnRejectInteraction = useStableEvent(onRejectInteraction);
const stableOnSubmitInteractionAnswers = useStableEvent(onSubmitInteractionAnswers);
const stableOnCancelInteraction = useStableEvent(onCancelInteraction);
const chatCtx = useMemo<IssueChatMessageContext>(
() => ({
@@ -3555,12 +3587,16 @@ export function IssueChatThread({
userProfileMap,
onVote: stableOnVote,
onStopRun: stableOnStopRun,
stopRunLabel,
stoppingRunLabel,
stopRunVariant,
onInterruptQueued: stableOnInterruptQueued,
onCancelQueued: stableOnCancelQueued,
onImageClick: stableOnImageClick,
onAcceptInteraction: stableOnAcceptInteraction,
onRejectInteraction: stableOnRejectInteraction,
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
onCancelInteraction: stableOnCancelInteraction,
}),
[
feedbackDataSharingPreference,
@@ -3571,12 +3607,16 @@ export function IssueChatThread({
userProfileMap,
stableOnVote,
stableOnStopRun,
stopRunLabel,
stoppingRunLabel,
stopRunVariant,
stableOnInterruptQueued,
stableOnCancelQueued,
stableOnImageClick,
stableOnAcceptInteraction,
stableOnRejectInteraction,
stableOnSubmitInteractionAnswers,
stableOnCancelInteraction,
],
);
+3 -1
View File
@@ -162,6 +162,7 @@ export async function loadRemainingIssueCommentPages<T extends { id: string }>(p
pages: ReadonlyArray<ReadonlyArray<T>> | undefined;
pageParams: ReadonlyArray<string | null> | undefined;
pageSize: number;
maxPages?: number;
fetchPage: (afterCommentId: string) => Promise<ReadonlyArray<T>>;
}): Promise<{ pages: T[][]; pageParams: Array<string | null> }> {
const pages = (params.pages ?? []).map((page) => [...page]);
@@ -176,8 +177,9 @@ export async function loadRemainingIssueCommentPages<T extends { id: string }>(p
if (params.pageSize <= 0) return { pages, pageParams };
let cursor = getNextPageCursor(pages[pages.length - 1], params.pageSize);
const maxPages = Math.max(0, params.maxPages ?? Number.POSITIVE_INFINITY);
const seenCursors = new Set<string>();
while (cursor && !seenCursors.has(cursor)) {
while (cursor && !seenCursors.has(cursor) && seenCursors.size < maxPages) {
seenCursors.add(cursor);
const nextPage = [...await params.fetchPage(cursor)];
pages.push(nextPage);
+153
View File
@@ -0,0 +1,153 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Agents } from "./Agents";
const mockAgentsApi = vi.hoisted(() => ({
list: vi.fn(),
org: vi.fn(),
}));
const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockOpenNewAgent = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
useLocation: () => ({ pathname: "/agents/all", search: "", hash: "", state: null }),
useNavigate: () => vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/DialogContext", () => ({
useDialogActions: () => ({ openNewAgent: mockOpenNewAgent }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false }),
}));
vi.mock("../api/agents", () => ({
agentsApi: mockAgentsApi,
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../adapters/adapter-display-registry", () => ({
getAdapterLabel: (type: string) => type,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function makeAgent(overrides: Partial<Agent>): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "Alpha",
urlKey: "alpha",
role: "engineer",
title: null,
icon: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("Agents", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockAgentsApi.list.mockResolvedValue([
makeAgent({ adapterConfig: { model: "gpt-5.4" } }),
]);
mockAgentsApi.org.mockResolvedValue([
{
id: "agent-1",
name: "Alpha",
role: "engineer",
status: "active",
reports: [],
},
]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
});
afterEach(async () => {
const currentRoot = root;
if (currentRoot) {
await act(async () => {
currentRoot.unmount();
});
}
queryClient.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("shows the configured model beside the adapter on the all agents page", async () => {
root = createRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<Agents />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("codex_local");
expect(container.textContent).toContain("gpt-5.4");
});
});
+21 -2
View File
@@ -41,6 +41,13 @@ function filterAgents(agents: Agent[], tab: FilterTab, showTerminated: boolean):
.sort((a, b) => a.name.localeCompare(b.name));
}
function getConfiguredModel(agent: Agent): string | null {
const value = agent.adapterConfig?.model;
if (typeof value !== "string") return null;
const model = value.trim();
return model.length > 0 ? model : null;
}
function filterOrgTree(nodes: OrgNode[], tab: FilterTab, showTerminated: boolean): OrgNode[] {
return nodes
.reduce<OrgNode[]>((acc, node) => {
@@ -253,9 +260,15 @@ export function Agents() {
liveCount={liveRunByAgent.get(agent.id)!.liveCount}
/>
)}
<span className="w-28 whitespace-nowrap text-right font-mono text-xs text-muted-foreground">
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
{getAdapterLabel(agent.adapterType)}
</span>
<span
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
title={getConfiguredModel(agent) ?? undefined}
>
{getConfiguredModel(agent) ?? "—"}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
@@ -356,9 +369,15 @@ function OrgTreeNode({
)}
{agent && (
<>
<span className="w-28 whitespace-nowrap text-right font-mono text-xs text-muted-foreground">
<span className="w-28 whitespace-nowrap text-left font-mono text-xs text-muted-foreground">
{getAdapterLabel(agent.adapterType)}
</span>
<span
className="w-36 truncate text-left font-mono text-xs text-muted-foreground"
title={getConfiguredModel(agent) ?? undefined}
>
{getConfiguredModel(agent) ?? "—"}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
</span>
+45 -3
View File
@@ -1,5 +1,9 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
} from "@paperclipai/shared";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
@@ -21,6 +25,9 @@ type AgentSnippetInput = {
testResolutionUrl?: string | null;
};
const BYTES_PER_MIB = 1024 * 1024;
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
export function CompanySettings() {
const {
companies,
@@ -34,6 +41,7 @@ export function CompanySettings() {
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
const [brandColor, setBrandColor] = useState("");
const [attachmentMaxMiB, setAttachmentMaxMiB] = useState(String(DEFAULT_COMPANY_ATTACHMENT_MAX_MIB));
const [logoUrl, setLogoUrl] = useState("");
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
@@ -43,6 +51,7 @@ export function CompanySettings() {
setCompanyName(selectedCompany.name);
setDescription(selectedCompany.description ?? "");
setBrandColor(selectedCompany.brandColor ?? "");
setAttachmentMaxMiB(String(Math.round((selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES) / BYTES_PER_MIB)));
setLogoUrl(selectedCompany.logoUrl ?? "");
}, [selectedCompany]);
@@ -51,17 +60,25 @@ export function CompanySettings() {
const [snippetCopied, setSnippetCopied] = useState(false);
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
const attachmentMaxValid =
Number.isInteger(attachmentMaxBytes)
&& attachmentMaxBytes >= BYTES_PER_MIB
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
const generalDirty =
!!selectedCompany &&
(companyName !== selectedCompany.name ||
description !== (selectedCompany.description ?? "") ||
brandColor !== (selectedCompany.brandColor ?? ""));
brandColor !== (selectedCompany.brandColor ?? "") ||
attachmentMaxBytes !== (selectedCompany.attachmentMaxBytes ?? DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES));
const generalMutation = useMutation({
mutationFn: (data: {
name: string;
description: string | null;
brandColor: string | null;
attachmentMaxBytes: number;
}) => companiesApi.update(selectedCompanyId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
@@ -214,7 +231,8 @@ export function CompanySettings() {
generalMutation.mutate({
name: companyName.trim(),
description: description.trim() || null,
brandColor: brandColor || null
brandColor: brandColor || null,
attachmentMaxBytes
});
}
@@ -346,6 +364,30 @@ export function CompanySettings() {
)}
</div>
</Field>
<Field
label="Attachment size limit"
hint={`Accepted range: 1-${MAX_COMPANY_ATTACHMENT_MAX_MIB} MiB.`}
>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<input
type="number"
min={1}
max={MAX_COMPANY_ATTACHMENT_MAX_MIB}
step={1}
value={attachmentMaxMiB}
onChange={(e) => setAttachmentMaxMiB(e.target.value)}
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
/>
<span className="text-xs text-muted-foreground">MiB</span>
</div>
{!attachmentMaxValid && (
<span className="text-xs text-destructive">
Enter a whole number from 1 to {MAX_COMPANY_ATTACHMENT_MAX_MIB}.
</span>
)}
</div>
</Field>
</div>
</div>
</div>
@@ -357,7 +399,7 @@ export function CompanySettings() {
<Button
size="sm"
onClick={handleSaveGeneral}
disabled={generalMutation.isPending || !companyName.trim()}
disabled={generalMutation.isPending || !companyName.trim() || !attachmentMaxValid}
>
{generalMutation.isPending ? "Saving..." : "Save changes"}
</Button>
+174 -1
View File
@@ -68,6 +68,7 @@ const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSetMobileToolbar = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
const mockIssuesListRender = vi.hoisted(() => vi.fn());
const mockIssueChatThreadRender = vi.hoisted(() => vi.fn());
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
@@ -190,7 +191,23 @@ vi.mock("../components/InlineEditor", () => ({
}));
vi.mock("../components/IssueChatThread", () => ({
IssueChatThread: () => <div data-testid="issue-chat-thread">Chat thread</div>,
IssueChatThread: (props: {
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
}) => {
mockIssueChatThreadRender(props);
return (
<div data-testid="issue-chat-thread">
Chat thread
{props.onStopRun ? (
<button type="button" onClick={() => void props.onStopRun?.("run-active-1")}>
{props.stopRunLabel ?? "Stop run"}
</button>
) : null}
</div>
);
},
}));
vi.mock("../components/IssueDocumentsSection", () => ({
@@ -786,6 +803,7 @@ describe("IssueDetail", () => {
feedbackDataSharingPreference: "prompt",
});
mockIssuesListRender.mockClear();
mockIssueChatThreadRender.mockClear();
});
afterEach(async () => {
@@ -1036,6 +1054,161 @@ describe("IssueDetail", () => {
});
});
it("exposes leaf pause controls and routes issue active-run stop through Pause work", async () => {
const pausePreview = createPausePreview();
pausePreview.totals = {
...pausePreview.totals,
totalIssues: 1,
affectedIssues: 1,
skippedIssues: 0,
activeRuns: 1,
};
pausePreview.issues = [pausePreview.issues[0]!];
pausePreview.skippedIssues = [];
const pauseHold = createPauseHold({
id: "leaf-pause-hold-1",
mode: "pause",
reason: "Paused from active run controls.",
releasePolicy: { strategy: "manual", note: "leaf_pause" },
members: [],
});
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "in_progress",
assigneeAgentId: "agent-1",
executionRunId: "run-active-1",
}));
mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview);
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: pauseHold, preview: pausePreview });
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
stopRunLabel: "Pause work",
stoppingRunLabel: "Pausing...",
});
const chatPauseButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Pause work");
expect(chatPauseButton).toBeTruthy();
await act(async () => {
chatPauseButton!.click();
});
await flushReact();
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
mode: "pause",
reason: "Paused from active run controls.",
releasePolicy: { strategy: "manual", note: "leaf_pause" },
metadata: { source: "issue_active_run_control", runId: "run-active-1" },
});
const moreButton = container.querySelector('button[aria-label="More issue actions"]') as HTMLButtonElement | null;
expect(moreButton).toBeTruthy();
await act(async () => {
moreButton!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
await flushReact();
const pauseMenuButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Pause work...");
expect(pauseMenuButton).toBeTruthy();
});
it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => {
const activeHold = createPauseHold();
const releasedHold = createPauseHold({
status: "released",
releasedAt: new Date("2026-04-21T00:01:00.000Z"),
releasedByActorType: "user",
releasedByUserId: "user-1",
releaseReason: "Ready to continue",
updatedAt: new Date("2026-04-21T00:01:00.000Z"),
});
mockIssuesApi.get.mockResolvedValue(createIssue({
status: "in_review",
assigneeAgentId: "agent-1",
}));
mockIssuesApi.getTreeControlState.mockResolvedValue({
activePauseHold: {
holdId: "hold-1",
rootIssueId: "issue-1",
issueId: "issue-1",
isRoot: true,
mode: "pause",
reason: null,
releasePolicy: { strategy: "manual", note: "leaf_pause" },
},
});
mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]);
mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview());
mockIssuesApi.releaseTreeHold.mockResolvedValue(releasedHold);
mockAgentsApi.list.mockResolvedValue([createAgent()]);
mockAuthApi.getSession.mockResolvedValue({
session: { userId: "user-1" },
user: { id: "user-1" },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await waitForAssertion(() => {
expect(container.textContent).toContain("Paused by board.");
expect(container.textContent).toContain("in_review");
expect(container.textContent).not.toContain("Subtree pause is active.");
});
const resumeButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Resume work");
expect(resumeButton).toBeTruthy();
await act(async () => {
resumeButton!.click();
});
await flushReact();
await flushReact();
const wakeCheckbox = container.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
expect(wakeCheckbox?.checked).toBe(true);
const applyResumeButton = Array.from(container.querySelectorAll("button"))
.filter((button) => button.textContent?.trim() === "Resume work")
.at(-1);
expect(applyResumeButton).toBeTruthy();
await act(async () => {
applyResumeButton!.click();
});
await flushReact();
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("PAP-1", "hold-1", {
reason: null,
metadata: { wakeAgents: true },
});
});
it("exposes restore subtree from the issue actions menu", async () => {
const childIssue = createIssue({
id: "child-1",
+293 -83
View File
@@ -48,6 +48,7 @@ import {
flattenIssueCommentPages,
getNextIssueCommentPageParam,
isQueuedIssueComment,
loadRemainingIssueCommentPages,
matchesIssueRef,
mergeIssueComments,
removeIssueCommentFromPages,
@@ -130,6 +131,7 @@ import {
isClosedIsolatedExecutionWorkspace,
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
type AskUserQuestionsAnswer,
type AskUserQuestionsInteraction,
type ActivityEvent,
type Agent,
type FeedbackVote,
@@ -156,18 +158,39 @@ type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
const ISSUE_COMMENT_PAGE_SIZE = 50;
const ISSUE_COMMENT_AUTOLOAD_LIMIT = ISSUE_COMMENT_PAGE_SIZE * 3;
const JUMP_TO_LATEST_MAX_COMMENT_PAGES = 10;
const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
pause: "Pause subtree",
resume: "Resume subtree",
cancel: "Cancel subtree",
restore: "Restore subtree",
};
const LEAF_WORK_CONTROL_MODE_LABEL: Partial<Record<IssueTreeControlMode, string>> = {
pause: "Pause work",
resume: "Resume work",
};
const TREE_CONTROL_MODE_HELP_TEXT: Record<IssueTreeControlMode, string> = {
pause: "Pause active execution in this issue subtree until an explicit resume.",
resume: "Release the active subtree pause hold so held work can continue.",
cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.",
restore: "Restore issues cancelled by this subtree operation so work can resume.",
};
const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial<Record<IssueTreeControlMode, string>> = {
pause: "Pause active execution on this issue until an explicit resume.",
resume: "Release the active pause hold so this issue can continue.",
};
function issueTreeControlLabel(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
return scope === "leaf"
? LEAF_WORK_CONTROL_MODE_LABEL[mode] ?? TREE_CONTROL_MODE_LABEL[mode]
: TREE_CONTROL_MODE_LABEL[mode];
}
function issueTreeControlHelpText(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
return scope === "leaf"
? LEAF_WORK_CONTROL_MODE_HELP_TEXT[mode] ?? TREE_CONTROL_MODE_HELP_TEXT[mode]
: TREE_CONTROL_MODE_HELP_TEXT[mode];
}
function treeControlPreviewErrorCopy(error: unknown): string {
if (error instanceof ApiError) {
@@ -586,8 +609,10 @@ type IssueDetailChatTabProps = {
onImageUpload: (file: File) => Promise<string>;
onAttachImage: (file: File) => Promise<IssueAttachment | void>;
onInterruptQueued: (runId: string) => Promise<void>;
onPauseWorkRun?: (runId: string) => Promise<void>;
onCancelQueued: (commentId: string) => void;
interruptingQueuedRunId: string | null;
pausingWorkRunId: string | null;
onImageClick: (src: string) => void;
onAcceptInteraction: (
interaction: ActionableIssueThreadInteraction,
@@ -598,6 +623,7 @@ type IssueDetailChatTabProps = {
interaction: IssueThreadInteraction,
answers: AskUserQuestionsAnswer[],
) => Promise<void>;
onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise<void>;
};
const IssueDetailChatTab = memo(function IssueDetailChatTab({
@@ -636,12 +662,15 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onImageUpload,
onAttachImage,
onInterruptQueued,
onPauseWorkRun,
onCancelQueued,
interruptingQueuedRunId,
pausingWorkRunId,
onImageClick,
onAcceptInteraction,
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
}: IssueDetailChatTabProps) {
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
@@ -826,16 +855,20 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onInterruptQueued={onInterruptQueued}
onCancelQueued={onCancelQueued}
interruptingQueuedRunId={interruptingQueuedRunId}
stoppingRunId={interruptingQueuedRunId}
onStopRun={onInterruptQueued}
stoppingRunId={pausingWorkRunId}
onStopRun={onPauseWorkRun}
stopRunLabel="Pause work"
stoppingRunLabel="Pausing..."
stopRunVariant="pause"
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
onSubmitInteractionAnswers={(interaction, answers) =>
onSubmitInteractionAnswers(interaction, answers)
}
onCancelRun={runningIssueRun
onCancelInteraction={onCancelInteraction}
onCancelRun={runningIssueRun && onPauseWorkRun
? async () => {
await onInterruptQueued(runningIssueRun.id);
await onPauseWorkRun(runningIssueRun.id);
}
: undefined}
onImageClick={onImageClick}
@@ -902,6 +935,11 @@ function IssueDetailActivityTab({
issueId,
),
});
const { data: issueTreeCostSummary } = useQuery({
queryKey: queryKeys.issues.costSummary(issueId),
queryFn: () => issuesApi.getCostSummary(issueId),
placeholderData: keepPreviousDataForSameQueryTail<Awaited<ReturnType<typeof issuesApi.getCostSummary>>>(issueId),
});
const initialLoading =
(activityLoading && activity === undefined)
|| (linkedRunsLoading && linkedRuns === undefined);
@@ -943,6 +981,16 @@ function IssueDetailActivityTab({
hasTokens,
};
}, [linkedRuns]);
const issueTreeCostTokens =
(issueTreeCostSummary?.inputTokens ?? 0) + (issueTreeCostSummary?.outputTokens ?? 0);
const hasIssueTreeCost =
!!issueTreeCostSummary
&& (issueTreeCostSummary.costCents > 0
|| issueTreeCostTokens > 0
|| issueTreeCostSummary.cachedInputTokens > 0
|| issueTreeCostSummary.issueCount > 1);
const shouldShowCostSummary =
(linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost;
if (initialLoading) {
return <IssueSectionSkeleton titleWidth="w-20" rows={4} />;
@@ -950,6 +998,55 @@ function IssueDetailActivityTab({
return (
<>
{shouldShowCostSummary && (
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !hasIssueTreeCost ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="space-y-1 text-xs text-muted-foreground tabular-nums">
<div className="flex flex-wrap gap-3">
<span className="font-medium text-foreground">This issue</span>
{issueCostSummary.hasCost ? (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}
</span>
) : null}
{issueCostSummary.hasTokens ? (
<span>
Tokens {formatTokens(issueCostSummary.totalTokens)}
{issueCostSummary.cached > 0
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
) : null}
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<span>No direct cost data.</span>
) : null}
</div>
{hasIssueTreeCost && issueTreeCostSummary ? (
<div className="flex flex-wrap gap-3">
<span className="font-medium text-foreground">
Including sub-issues {(issueTreeCostSummary.costCents / 100).toLocaleString(undefined, {
style: "currency",
currency: "USD",
minimumFractionDigits: 4,
maximumFractionDigits: 4,
})}
</span>
<span>
Tokens {formatTokens(issueTreeCostTokens)}
{issueTreeCostSummary.cachedInputTokens > 0
? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})`
: ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`}
</span>
<span>{issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}</span>
</div>
) : null}
</div>
)}
</div>
)}
<div className="mb-3">
<IssueRunLedger
issueId={issueId}
@@ -958,9 +1055,19 @@ function IssueDetailActivityTab({
childIssues={childIssues}
agentMap={agentMap}
hasLiveRuns={hasLiveRuns}
activityEvents={activity ?? []}
renderActivityEvent={(evt) => (
<div className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
)}
/>
</div>
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
{linkedApprovals && linkedApprovals.length > 0 && (
<div className="mb-3 space-y-3">
{linkedApprovals.map((approval) => (
@@ -981,46 +1088,7 @@ function IssueDetailActivityTab({
))}
</div>
)}
{linkedRuns && linkedRuns.length > 0 && (
<div className="mb-3 px-3 py-2 rounded-lg border border-border">
<div className="text-sm font-medium text-muted-foreground mb-1">Cost Summary</div>
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
<div className="text-xs text-muted-foreground">No cost data yet.</div>
) : (
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground tabular-nums">
{issueCostSummary.hasCost && (
<span className="font-medium text-foreground">
${issueCostSummary.cost.toFixed(4)}
</span>
)}
{issueCostSummary.hasTokens && (
<span>
Tokens {formatTokens(issueCostSummary.totalTokens)}
{issueCostSummary.cached > 0
? ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)}, cached ${formatTokens(issueCostSummary.cached)})`
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
)}
</div>
)}
</div>
)}
{!activity || activity.length === 0 ? (
<p className="text-xs text-muted-foreground">No activity yet.</p>
) : (
<div className="space-y-1.5">
{activity.slice(0, 20).map((evt) => (
<div key={evt.id} className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
))}
</div>
)}
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
</>
);
}
@@ -1560,7 +1628,7 @@ export function IssueDetail() {
reason: treeControlReason.trim() || null,
releasePolicy: {
strategy: "manual",
...(treeControlMode === "pause" ? { note: "full_pause" } : {}),
...(treeControlMode === "pause" ? { note: treeControlScope === "leaf" ? "leaf_pause" : "full_pause" } : {}),
},
...(treeControlMode === "restore"
? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } }
@@ -1569,18 +1637,20 @@ export function IssueDetail() {
return { kind: "create" as const, hold: created.hold, preview: created.preview };
},
onSuccess: async (result) => {
const modeLabel = TREE_CONTROL_MODE_LABEL[result.hold.mode];
const modeLabel = issueTreeControlLabel(result.hold.mode, treeControlScope);
const cancelCount = result.preview?.totals.activeRuns ?? 0;
pushToast({
title: result.kind === "release"
? "Subtree resumed"
? treeControlScope === "leaf" ? "Work resumed" : "Subtree resumed"
: result.hold.mode === "pause"
? "Subtree paused"
? treeControlScope === "leaf" ? "Work paused" : "Subtree paused"
: `${modeLabel} applied`,
body: result.kind === "release"
? (result.hold.releaseReason?.trim() || "Active subtree pause released.")
? (result.hold.releaseReason?.trim() || (treeControlScope === "leaf" ? "Active issue pause released." : "Active subtree pause released."))
: result.hold.mode === "pause"
? `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
? treeControlScope === "leaf"
? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
: `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
: result.hold.reason?.trim()
? result.hold.reason
: "Subtree control applied.",
@@ -1591,6 +1661,7 @@ export function IssueDetail() {
setTreeControlCancelConfirmed(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
@@ -1602,7 +1673,10 @@ export function IssueDetail() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }),
...(issue?.id
? [queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) })]
? [
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByParent(selectedCompanyId, issue.id) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByDescendantRoot(selectedCompanyId, issue.id) }),
]
: []),
]);
}
@@ -1615,6 +1689,45 @@ export function IssueDetail() {
});
},
});
const pauseIssueWorkRun = useMutation({
mutationFn: async ({ runId, scope }: { runId: string; scope: "leaf" | "subtree" }) => {
const created = await issuesApi.createTreeHold(issueId!, {
mode: "pause",
reason: "Paused from active run controls.",
releasePolicy: { strategy: "manual", note: scope === "leaf" ? "leaf_pause" : "full_pause" },
metadata: { source: "issue_active_run_control", runId },
});
return created;
},
onSuccess: async (result) => {
const cancelCount = result.preview?.totals.activeRuns ?? 0;
pushToast({
title: "Work paused",
body: cancelCount > 0
? `Work paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
: "Work paused. This issue is held until resume.",
tone: "success",
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) }),
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-state", issueId ?? "pending"] }),
queryClient.invalidateQueries({ queryKey: ["issues", "tree-holds", issueId ?? "pending"] }),
queryClient.invalidateQueries({ queryKey: ["issues", "tree-control-preview", issueId ?? "pending"] }),
]);
invalidateIssueCollections();
},
onError: (err) => {
pushToast({
title: "Unable to pause work",
body: err instanceof Error ? err.message : "Please try again.",
tone: "error",
});
},
});
const handleIssuePropertiesUpdate = useCallback((data: Record<string, unknown>) => {
updateIssue.mutate(data);
}, [updateIssue.mutate]);
@@ -1859,6 +1972,27 @@ export function IssueDetail() {
},
});
const cancelInteraction = useMutation({
mutationFn: ({ interaction }: { interaction: AskUserQuestionsInteraction }) =>
issuesApi.cancelInteraction(issueId!, interaction.id),
onSuccess: (interaction) => {
upsertInteractionInCache(interaction);
invalidateIssueDetail();
invalidateIssueCollections();
pushToast({
title: "Question cancelled",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Cancel failed",
body: err instanceof Error ? err.message : "Unable to cancel the question",
tone: "error",
});
},
});
const addCommentAndReassign = useMutation({
mutationFn: ({
body,
@@ -2561,12 +2695,35 @@ export function IssueDetail() {
void fetchOlderComments();
}, [fetchOlderComments]);
const refetchLatestComments = useCallback(async () => {
// Refetch the entire infinite-query (page 0 first), so any comments that
// arrived after the initial load — including ones live updates may have
// missed during reconnects — are present before we scroll the user to
// the absolute newest.
await refetchComments();
}, [refetchComments]);
// Refetch page 0 first so comments that arrived after initial load are
// visible, then load every remaining older page. The chat thread is
// paginated and virtualized, so "latest" must be resolved against the
// complete comment set rather than the current loaded window.
const refreshed = await refetchComments();
const loaded = await loadRemainingIssueCommentPages<IssueComment>({
pages: refreshed.data?.pages,
pageParams: refreshed.data?.pageParams as Array<string | null> | undefined,
pageSize: ISSUE_COMMENT_PAGE_SIZE,
maxPages: JUMP_TO_LATEST_MAX_COMMENT_PAGES,
fetchPage: (afterCommentId) =>
issuesApi.listComments(issueId!, {
order: "desc",
limit: ISSUE_COMMENT_PAGE_SIZE,
after: afterCommentId,
}),
});
queryClient.setQueryData<InfiniteData<IssueComment[], string | null>>(
queryKeys.issues.comments(issueId!),
loaded,
);
await new Promise<void>((resolve) => {
if (typeof window === "undefined") {
resolve();
return;
}
window.requestAnimationFrame(() => resolve());
});
}, [issueId, queryClient, refetchComments]);
useEffect(() => {
if (!shouldPrefetchOlderComments) return;
void fetchOlderComments();
@@ -2613,6 +2770,9 @@ export function IssueDetail() {
) => {
await answerInteraction.mutateAsync({ interaction, answers });
}, [answerInteraction]);
const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => {
await cancelInteraction.mutateAsync({ interaction });
}, [cancelInteraction]);
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
@@ -2710,16 +2870,25 @@ export function IssueDetail() {
const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0;
const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true;
const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0;
const isTerminalIssue = issue.status === "done" || issue.status === "cancelled";
const isAgentOwnedNonTerminalIssue = Boolean(issue.assigneeAgentId) && !isTerminalIssue;
const canPauseLeafWork = canManageTreeControl && childIssues.length === 0 && !activePauseHold && !isTerminalIssue;
const canResumeLeafWork = canManageTreeControl && childIssues.length === 0 && activePauseHold?.isRoot === true;
const treeControlScope: "leaf" | "subtree" = childIssues.length === 0 ? "leaf" : "subtree";
const previewAffectedIssueCount = treePreviewAffectedIssues.length;
const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0;
const treeControlPrimaryButtonLabel =
treeControlMode === "pause"
? "Pause and stop work"
? treeControlScope === "leaf"
? "Pause work"
: "Pause and stop work"
: treeControlMode === "cancel"
? `Cancel ${previewAffectedIssueCount} issues`
: treeControlMode === "restore"
? `Restore ${previewAffectedIssueCount} issues`
: "Resume subtree";
: treeControlScope === "leaf"
? "Resume work"
: "Resume subtree";
const treePreviewAffectedIssueRows = treePreviewDisplayIssues.map((candidate) => ({
candidate,
issue: {
@@ -2748,7 +2917,7 @@ export function IssueDetail() {
)
: null;
const composerHint = pausedComposerHint;
const queuedCommentReason: "hold" | "active_run" | "other" = "active_run";
const queuedCommentReason: "hold" | "active_run" | "other" = activePauseHold ? "hold" : "active_run";
const canApplyTreeControl =
Boolean(treeControlPreview)
&& !treeControlPreviewLoading
@@ -2823,50 +2992,58 @@ export function IssueDetail() {
{activePauseHold.isRoot ? (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">Subtree pause is active.</span>
<span className="font-medium">
{childIssues.length === 0 ? "Paused by board." : "Subtree pause is active."}
</span>
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
Root and descendant execution is held until resume. Human comments can still wake assignees for triage.
{childIssues.length === 0
? "Issue execution is held until resume. Human comments can still wake the assignee for triage."
: "Root and descendant execution is held until resume. Human comments can still wake assignees for triage."}
</span>
</div>
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
{heldDescendantCount} descendant{heldDescendantCount === 1 ? "" : "s"} held
{childIssues.length === 0
? "1 issue held"
: `${heldDescendantCount} descendant${heldDescendantCount === 1 ? "" : "s"} held`}
{activeRootPauseHold?.createdAt ? ` · started ${relativeTime(activeRootPauseHold.createdAt)}` : ""}
</div>
{canShowSubtreeControls ? (
{canShowSubtreeControls || canResumeLeafWork ? (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(true);
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
setTreeControlOpen(true);
}}
>
Resume subtree
{childIssues.length === 0 ? "Resume work" : "Resume subtree"}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(true);
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue || canShowSubtreeControls);
setTreeControlOpen(true);
}}
>
View affected ({heldDescendantCount})
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
setTreeControlMode("cancel");
setTreeControlCancelConfirmed(false);
setTreeControlOpen(true);
}}
>
Cancel subtree...
View affected ({childIssues.length === 0 ? 1 : heldDescendantCount})
</Button>
{canShowSubtreeControls ? (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => {
setTreeControlMode("cancel");
setTreeControlCancelConfirmed(false);
setTreeControlOpen(true);
}}
>
Cancel subtree...
</Button>
) : null}
</div>
) : null}
</div>
@@ -3045,6 +3222,34 @@ export function IssueDetail() {
</Button>
</PopoverTrigger>
<PopoverContent className="w-52 p-1" align="end">
{canPauseLeafWork ? (
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
setTreeControlMode("pause");
setTreeControlCancelConfirmed(false);
setTreeControlOpen(true);
setMoreOpen(false);
}}
>
<PauseCircle className="h-3 w-3" />
Pause work...
</button>
) : null}
{canResumeLeafWork ? (
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
setTreeControlMode("resume");
setTreeControlWakeAgentsOnResume(isAgentOwnedNonTerminalIssue);
setTreeControlOpen(true);
setMoreOpen(false);
}}
>
<PlayCircle className="h-3 w-3" />
Resume work
</button>
) : null}
{canShowSubtreeControls ? (
<>
<button
@@ -3451,12 +3656,17 @@ export function IssueDetail() {
onImageUpload={handleCommentImageUpload}
onAttachImage={handleCommentAttachImage}
onInterruptQueued={handleInterruptQueuedRun}
onPauseWorkRun={canManageTreeControl
? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined)
: undefined}
onCancelQueued={handleCancelQueuedComment}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
pausingWorkRunId={pauseIssueWorkRun.isPending ? pauseIssueWorkRun.variables?.runId ?? null : null}
onImageClick={handleChatImageClick}
onAcceptInteraction={handleAcceptInteraction}
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
onCancelInteraction={handleCancelInteraction}
/>
) : null}
</TabsContent>
@@ -3504,9 +3714,9 @@ export function IssueDetail() {
<Dialog open={treeControlOpen} onOpenChange={setTreeControlOpen}>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[560px]">
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
<DialogTitle>{TREE_CONTROL_MODE_LABEL[treeControlMode]}</DialogTitle>
<DialogTitle>{issueTreeControlLabel(treeControlMode, treeControlScope)}</DialogTitle>
<DialogDescription>
{TREE_CONTROL_MODE_HELP_TEXT[treeControlMode]}
{issueTreeControlHelpText(treeControlMode, treeControlScope)}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
+2 -2
View File
@@ -317,9 +317,9 @@ export function ProjectWorkspaceDetail() {
request.action === "run"
? "Workspace job completed."
: request.action === "stop"
? "Workspace service stopped."
? "Workspace service stopped. Issue execution is not paused."
: request.action === "restart"
? "Workspace service restarted."
? "Workspace service restarted. Issue execution is not paused."
: "Workspace service started.",
);
},
+92 -1
View File
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, RoutineListItem } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Routines, buildRoutineGroups } from "./Routines";
import { Routines, buildRoutineGroups, sortRoutines } from "./Routines";
let currentSearch = "";
@@ -357,6 +357,97 @@ describe("Routines page", () => {
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
});
it("sorts routines by selected field and direction without mutating the source list", () => {
const routines = [
createRoutine({
id: "routine-1",
title: "Weekly digest",
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-03T00:00:00.000Z"),
lastRun: {
id: "run-1",
companyId: "company-1",
routineId: "routine-1",
triggerId: null,
source: "manual",
status: "succeeded",
triggeredAt: new Date("2026-04-02T00:00:00.000Z"),
idempotencyKey: null,
triggerPayload: null,
dispatchFingerprint: null,
linkedIssueId: null,
coalescedIntoRunId: null,
failureReason: null,
completedAt: null,
createdAt: new Date("2026-04-02T00:00:00.000Z"),
updatedAt: new Date("2026-04-02T00:00:00.000Z"),
linkedIssue: null,
trigger: null,
},
}),
createRoutine({
id: "routine-2",
title: "Morning sync",
createdAt: new Date("2026-04-02T00:00:00.000Z"),
updatedAt: new Date("2026-04-04T00:00:00.000Z"),
lastRun: null,
}),
];
expect(sortRoutines(routines, "title", "asc").map((routine) => routine.title)).toEqual([
"Morning sync",
"Weekly digest",
]);
expect(sortRoutines(routines, "updated", "desc").map((routine) => routine.id)).toEqual([
"routine-2",
"routine-1",
]);
expect(sortRoutines(routines, "lastRun", "desc").map((routine) => routine.id)).toEqual([
"routine-1",
"routine-2",
]);
expect(routines.map((routine) => routine.id)).toEqual(["routine-1", "routine-2"]);
});
it("renders the routines sort control before the group control", async () => {
routinesListMock.mockResolvedValue([]);
issuesListMock.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
await flush();
});
let sortButton = container.querySelector<HTMLButtonElement>('button[title="Sort"]');
let groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
for (let attempts = 0; attempts < 5 && (!sortButton || !groupButton); attempts += 1) {
await act(async () => {
await flush();
});
sortButton = container.querySelector<HTMLButtonElement>('button[title="Sort"]');
groupButton = container.querySelector<HTMLButtonElement>('button[title="Group"]');
}
expect(sortButton).not.toBeNull();
expect(groupButton).not.toBeNull();
expect(sortButton!.compareDocumentPosition(groupButton!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await act(async () => {
root.unmount();
});
});
it("passes company mention options to the routine description editor", async () => {
routinesListMock.mockResolvedValue([]);
issuesListMock.mockResolvedValue([]);
+121 -33
View File
@@ -1,7 +1,7 @@
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
@@ -83,8 +83,12 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) {
type RoutinesTab = "routines" | "runs";
type RoutineGroupBy = "none" | "project" | "assignee";
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
type RoutineSortDir = "asc" | "desc";
type RoutineViewState = {
sortField: RoutineSortField;
sortDir: RoutineSortDir;
groupBy: RoutineGroupBy;
collapsedGroups: string[];
};
@@ -96,6 +100,8 @@ type RoutineGroup = {
};
const defaultRoutineViewState: RoutineViewState = {
sortField: "updated",
sortDir: "desc",
groupBy: "none",
collapsedGroups: [],
};
@@ -114,6 +120,16 @@ function saveRoutineViewState(key: string, state: RoutineViewState) {
localStorage.setItem(key, JSON.stringify(state));
}
function timestampValue(value: Date | string | null | undefined) {
if (!value) return Number.NEGATIVE_INFINITY;
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
}
function compareNullableText(left: string | null | undefined, right: string | null | undefined) {
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
}
function formatRoutineRunStatus(value: string | null | undefined) {
if (!value) return null;
return value.replaceAll("_", " ");
@@ -176,6 +192,31 @@ export function buildRoutineGroups(
}));
}
export function sortRoutines(
routines: RoutineListItem[],
sortField: RoutineSortField,
sortDir: RoutineSortDir,
): RoutineListItem[] {
const direction = sortDir === "asc" ? 1 : -1;
return [...routines].sort((left, right) => {
let result = 0;
if (sortField === "title") {
result = compareNullableText(left.title, right.title);
} else if (sortField === "created") {
result = timestampValue(left.createdAt) - timestampValue(right.createdAt);
} else if (sortField === "lastRun") {
result = timestampValue(left.lastRun?.triggeredAt ?? left.lastTriggeredAt) -
timestampValue(right.lastRun?.triggeredAt ?? right.lastTriggeredAt);
} else {
result = timestampValue(left.updatedAt) - timestampValue(right.updatedAt);
}
if (result !== 0) return result * direction;
return compareNullableText(left.title, right.title);
});
}
function buildRoutinesTabHref(tab: RoutinesTab) {
return tab === "runs" ? "/routines?tab=runs" : "/routines";
}
@@ -509,9 +550,13 @@ export function Routines() {
[projects],
);
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const sortedRoutines = useMemo(
() => sortRoutines(routines ?? [], routineViewState.sortField, routineViewState.sortDir),
[routineViewState.sortDir, routineViewState.sortField, routines],
);
const routineGroups = useMemo(
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, routines],
() => buildRoutineGroups(sortedRoutines, routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, sortedRoutines],
);
const recentRunsIssueLinkState = useMemo(
() =>
@@ -606,36 +651,79 @@ export function Routines() {
<p className="text-sm text-muted-foreground">
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
</p>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.groupBy === value
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
>
<span>{label}</span>
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<div className="flex items-center gap-1">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Sort</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["updated", "Updated"],
["created", "Created"],
["lastRun", "Last run"],
["title", "Title"],
] as const).map(([field, label]) => (
<button
key={field}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.sortField === field
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => {
updateRoutineView(
routineViewState.sortField === field
? { sortDir: routineViewState.sortDir === "asc" ? "desc" : "asc" }
: { sortField: field, sortDir: field === "title" ? "asc" : "desc" },
);
}}
>
<span>{label}</span>
{routineViewState.sortField === field ? (
<span className="text-xs text-muted-foreground">
{routineViewState.sortDir === "asc" ? "Asc" : "Desc"}
</span>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs" title="Group">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.groupBy === value
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
>
<span>{label}</span>
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
</div>
</TabsContent>
<TabsContent value="runs">