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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1718 additions and 173 deletions

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 />} />

View file

@ -32,6 +32,7 @@ export const companiesApi = {
| "description"
| "status"
| "budgetMonthlyCents"
| "attachmentMaxBytes"
| "requireBoardApprovalForNewAgents"
| "feedbackDataSharingEnabled"
| "brandColor"

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>

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

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

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

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>

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>

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",

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">

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

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

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">