From 67b22d872f9589c169ab8129a05a84b6cef05c3c Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:57:21 -0500 Subject: [PATCH] [codex] Clarify interrupt handoffs and scoped wake semantics (#7855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The issue thread is the operator surface where comments, assignee changes, pauses, resumes, and wakeups turn human intent into agent execution. > - Interrupting a live run and handing work to another assignee needs clear semantics so the product does not accidentally keep work alive, wake the wrong participant, or hide why an agent stopped. > - Comment-driven wakes also need strict boundaries so closed, blocked, and dependency-driven work only resumes when there is real actionable input. > - This pull request codifies the interrupt handoff contract, implements backend scheduling behavior, and gives the UI clearer handoff/pause language. > - The benefit is a more inspectable and predictable task lifecycle for both operators and agents. ## Linked Issues or Issue Description Paperclip issue: `PAP-10664` / `PAP-10751`. Problem: interrupting or reassigning live agent work could be ambiguous in the UI and backend. Operators needed clearer feedback about whether a handoff wakes an agent, what pause/cancel affects, and when comments should revive execution. The backend also needed stronger tests around comment wake boundaries, retry supersession, and structured agent mention dispatch. Related GitHub PR search found broad workflow-adjacent PRs #5082, #6359, and #4083, but no exact duplicate for this head branch or interrupt-handoff scope. ## What Changed - Added an interrupt handoff semantics document covering destination behavior, wake expectations, and live-run interruption states. - Implemented backend interrupt handoff behavior and comment wake/reopen handling in issue routes/services and heartbeat scheduling. - Hardened structured agent mention dispatch so mentions resolve through the intended dispatch path. - Added UI helpers and components for handoff chips, wake rows, interrupt banners, pause-affects summaries, and composer guidance. - Updated the issue properties assignee picker and issue chat/composer surfaces to make interrupt/reassign behavior clearer. - Added backend, UI utility, component, and Storybook coverage for the new behavior. - Stabilized the new UI component tests with a local `flushSync`-backed act helper matching existing repo practice in this dependency set. - Addressed Greptile feedback by threading historical run `errorCode` through issue-run data and operator-interrupted chat labels. - Addressed Greptile's cancel ordering concern by terminating/deleting in-memory heartbeat processes before cancellation status persistence, with regression coverage for DB update failure. ## Verification - `git diff --check $(git merge-base HEAD origin/master)..HEAD` - `pnpm --filter @paperclipai/ui exec vitest run src/lib/interrupt-handoff.test.ts src/lib/issue-chat-messages.test.ts src/components/IssueProperties.test.tsx src/components/interrupt-handoff/InterruptHandoffViews.test.tsx --no-file-parallelism --maxWorkers=1` — 4 files / 91 tests passed before the Greptile follow-ups. - `pnpm run preflight:workspace-links && pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts server/src/__tests__/heartbeat-retry-scheduling.test.ts server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-tree-control-service.test.ts server/src/__tests__/issue-update-comment-wakeup-routes.test.ts server/src/__tests__/issues-service.test.ts --no-file-parallelism --maxWorkers=1` — 6 files / 191 tests passed before the Greptile follow-ups. - `pnpm --filter @paperclipai/ui exec vitest run src/lib/issue-chat-messages.test.ts --no-file-parallelism --maxWorkers=1` — 1 file / 24 tests passed after the historical `errorCode` follow-up. - `pnpm exec vitest run server/src/__tests__/activity-service.test.ts server/src/__tests__/activity-routes.test.ts --no-file-parallelism --maxWorkers=1` — 2 files / 11 tests passed after the historical `errorCode` follow-up. - `pnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts --no-file-parallelism --maxWorkers=1` — 1 file / 52 tests passed after the cancel ordering follow-up. - Greptile is green for head `272647636287d034bab8d981eaf5305865aa0f96`; the old inline P2 is resolved/outdated. - GitHub Actions, Socket, security-review, and Greptile checks are green for head `272647636287d034bab8d981eaf5305865aa0f96`. The external `security/snyk (cryppadotta)` status was still pending at `https://app.snyk.io/org/cryppadotta/pr-checks/85b3e8f4-04e1-4f8e-9362-899c8148c23c` after a bounded wait. ## Risks - Medium: changes touch issue comments, wake scheduling, and live-run interruption semantics, so regressions could affect when agents resume or stay stopped. - Medium: UI copy and state grouping for assignee changes may need reviewer tuning after product review. - Low migration risk: no database schema migration is included. - The branch was created before the latest `origin/master` commits; reviewers should confirm CI merge-base behavior and resolve any merge conflicts if GitHub reports them. > 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-based coding agent, tool use and local command execution enabled. Exact hosted model build and context window were not exposed by the runtime. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] 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] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Screenshot note: this PR includes Storybook coverage for the new interrupt handoff UI states rather than captured before/after browser screenshots in this PR-creation heartbeat. --------- Co-authored-by: Paperclip Co-authored-by: Claude Opus 4.8 --- doc/execution-semantics.md | 20 + .../heartbeat-process-recovery.test.ts | 93 ++++ .../heartbeat-retry-scheduling.test.ts | 108 +++++ .../issue-comment-reopen-routes.test.ts | 20 +- .../issue-tree-control-service.test.ts | 49 ++ ...issue-update-comment-wakeup-routes.test.ts | 227 +++++++++ server/src/__tests__/issues-service.test.ts | 36 +- .../normalize-agent-mention-token.test.ts | 41 -- server/src/routes/issues.ts | 48 +- server/src/services/activity.ts | 1 + server/src/services/heartbeat.ts | 67 ++- server/src/services/issues.ts | 57 +-- ui/src/api/activity.ts | 1 + ui/src/components/IssueChatThread.tsx | 190 +++++--- ui/src/components/IssueProperties.test.tsx | 124 ++++- ui/src/components/IssueProperties.tsx | 277 +++++++---- .../InterruptHandoffViews.test.tsx | 258 ++++++++++ .../InterruptHandoffViews.tsx | 349 ++++++++++++++ ui/src/lib/interrupt-handoff.test.ts | 251 ++++++++++ ui/src/lib/interrupt-handoff.ts | 439 ++++++++++++++++++ ui/src/lib/issue-chat-messages.test.ts | 66 +++ ui/src/lib/issue-chat-messages.ts | 8 + ui/src/pages/IssueDetail.tsx | 13 + .../stories/interrupt-handoff.stories.tsx | 235 ++++++++++ 24 files changed, 2713 insertions(+), 265 deletions(-) delete mode 100644 server/src/__tests__/normalize-agent-mention-token.test.ts create mode 100644 ui/src/components/interrupt-handoff/InterruptHandoffViews.test.tsx create mode 100644 ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx create mode 100644 ui/src/lib/interrupt-handoff.test.ts create mode 100644 ui/src/lib/interrupt-handoff.ts create mode 100644 ui/storybook/stories/interrupt-handoff.stories.tsx diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 72acb2f4..7ebfdee4 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -254,6 +254,26 @@ Document-scoped activity may still route work when it is converted into an expli Freeform document approval text is not auto-acceptance. Plan approval, implementation approval, or review acceptance must flow through the explicit interaction, approval, execution-policy, assignment, or blocker primitives that define who owns the next move. +### Comment interrupts and ownership handoffs + +A board comment can be an interrupt, an ownership change, both, or neither. Paperclip must keep those concepts separate in the product contract. + +An interrupt stops the current live execution path for the issue. It does not, by itself, select the next owner. If an active run is interrupted by the board, the run may still terminate with the underlying `cancelled` status, but the issue activity and wake context should make the operator intent visible as an interruption rather than an unexplained runtime failure. + +An ownership change selects who owns the issue after the comment is committed: + +- setting `assigneeAgentId` makes the named agent the owner +- setting `assigneeUserId`, or clearing `assigneeAgentId`, makes the issue human-owned or unassigned +- leaving assignee fields unchanged preserves the current owner + +A wake is the delivery path for a selected agent owner. If an interrupting update also assigns a non-terminal, non-backlog issue to an agent, Paperclip should enqueue one wake for the new assignee and include the interrupting comment and interrupted run id in the wake payload/context when available. Stale scheduled retries for the previous owner must not run after ownership changes away from that owner. + +If the committed update assigns the issue to a user, clears the agent assignee, or leaves the issue without an agent owner, Paperclip must not imply that an agent handoff happened. The issue is then waiting on the human owner or on a future explicit assignment, blocker, approval, interaction, monitor, or recovery action. + +Plain text is not assignment. Writing an agent's name, role, or team label in a comment does not change ownership and does not create an agent wake. Agent routing from comment text requires a structured agent mention that resolves inside the company, an explicit `assigneeAgentId` mutation, or an existing current agent assignee receiving normal issue-thread feedback. + +Pause and tree-control previews should make the same distinction visible. They should report whether the affected subtree contains live running work, queued wakes, agent-owned work, or only human-owned/static issues, so a pause after a handoff does not look like it interrupted agent execution when no agent execution path existed. + ### Adapter-backed workspace coherence For adapter-backed execution, an active run or queued wake counts as a live path only when Paperclip can also prove that the selected workspace is coherent for that adapter invocation. A wake that cannot start in the intended workspace is only a failed delivery attempt, not a healthy liveness path. diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index b8d5a33b..6e47ecc3 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -43,6 +43,7 @@ import { import { runningProcesses } from "../adapters/index.ts"; const mockTelemetryClient = vi.hoisted(() => ({ track: vi.fn() })); const mockTrackAgentFirstHeartbeat = vi.hoisted(() => vi.fn()); +const mockTerminateLocalService = vi.hoisted(() => vi.fn()); const mockAdapterExecute = vi.hoisted(() => vi.fn(async () => ({ exitCode: 0, @@ -59,6 +60,17 @@ vi.mock("../telemetry.ts", () => ({ getTelemetryClient: () => mockTelemetryClient, })); +vi.mock("../services/local-service-supervisor.js", async () => { + const actual = await vi.importActual( + "../services/local-service-supervisor.js", + ); + mockTerminateLocalService.mockImplementation(actual.terminateLocalService); + return { + ...actual, + terminateLocalService: mockTerminateLocalService, + }; +}); + vi.mock("@paperclipai/shared/telemetry", async () => { const actual = await vi.importActual( "@paperclipai/shared/telemetry", @@ -277,6 +289,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { afterEach(async () => { vi.clearAllMocks(); + const localServiceSupervisor = await vi.importActual( + "../services/local-service-supervisor.js", + ); + mockTerminateLocalService.mockImplementation(localServiceSupervisor.terminateLocalService); mockAdapterExecute.mockImplementation(async () => ({ exitCode: 0, signal: null, @@ -1893,6 +1909,35 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ); }); + it("terminates the in-memory process before persisting cancellation status", async () => { + const { runId } = await seedRunFixture({ + agentStatus: "running", + includeIssue: false, + }); + const heartbeat = heartbeatService(db); + runningProcesses.set(runId, { + child: { pid: 12345 } as ChildProcess, + graceSec: 1, + processGroupId: null, + }); + mockTerminateLocalService.mockResolvedValueOnce(undefined); + const updateSpy = vi.spyOn(db, "update"); + updateSpy.mockImplementationOnce((() => { + throw new Error("db update unavailable"); + }) as typeof db.update); + + try { + await expect(heartbeat.cancelRun(runId)).rejects.toThrow("db update unavailable"); + expect(mockTerminateLocalService).toHaveBeenCalledWith( + expect.objectContaining({ pid: 12345, processGroupId: null }), + { forceAfterMs: 1000 }, + ); + expect(runningProcesses.has(runId)).toBe(false); + } finally { + updateSpy.mockRestore(); + } + }); + it("records manual cancellation stop metadata", async () => { const { runId } = await seedRunFixture({ agentStatus: "running", @@ -1910,6 +1955,54 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); }); + it("records operator interrupt cancellation metadata without changing terminal status", async () => { + const { runId, issueId } = await seedRunFixture({ + agentStatus: "running", + includeIssue: true, + }); + const heartbeat = heartbeatService(db); + + const cancelled = await heartbeat.cancelRun(runId, "Interrupted by board comment", { + errorCode: "operator_interrupted", + resultJson: { + operatorInterrupted: true, + interruptionSource: "issue_comment_interrupt", + interruptedIssueId: issueId, + }, + eventMessage: "run interrupted by board comment", + eventPayload: { + issueId, + source: "issue_comment_interrupt", + }, + }); + + expect(cancelled?.status).toBe("cancelled"); + expect(cancelled?.errorCode).toBe("operator_interrupted"); + expect(cancelled?.error).toBe("Interrupted by board comment"); + expect(cancelled?.resultJson).toMatchObject({ + stopReason: "cancelled", + operatorInterrupted: true, + interruptionSource: "issue_comment_interrupt", + interruptedIssueId: issueId, + }); + + const events = await db + .select() + .from(heartbeatRunEvents) + .where(eq(heartbeatRunEvents.runId, runId)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + eventType: "lifecycle", + stream: "system", + level: "warn", + message: "run interrupted by board comment", + payload: expect.objectContaining({ + issueId, + source: "issue_comment_interrupt", + }), + }); + }); + it("dispatches assigned todo work with no prior run as a normal assignment wake", async () => { const { companyId, agentId, issueId } = await seedAssignedTodoNoRunFixture(); const heartbeat = heartbeatService(db); diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts index 6a742264..4ff58f50 100644 --- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -992,6 +992,114 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { expect(issue?.executionRunId).toBeNull(); }); + it("does not promote a scheduled retry after the issue is handed to a human owner", async () => { + const companyId = randomUUID(); + const oldAgentId = randomUUID(); + const issueId = randomUUID(); + const sourceRunId = randomUUID(); + const now = new Date("2026-04-20T14:30:00.000Z"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: oldAgentId, + companyId, + name: "ClaudeCoder", + role: "engineer", + status: "active", + adapterType: "claude_local", + adapterConfig: {}, + runtimeConfig: { + heartbeat: { + wakeOnDemand: true, + maxConcurrentRuns: 1, + }, + }, + permissions: {}, + }); + + await db.insert(heartbeatRuns).values({ + id: sourceRunId, + companyId, + agentId: oldAgentId, + invocationSource: "assignment", + triggerDetail: "system", + status: "failed", + error: "upstream overload", + errorCode: "adapter_failed", + finishedAt: now, + contextSnapshot: { + issueId, + wakeReason: "issue_assigned", + }, + updatedAt: now, + createdAt: now, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Retry human handoff", + status: "in_progress", + priority: "medium", + assigneeAgentId: oldAgentId, + executionRunId: sourceRunId, + executionAgentNameKey: "claudecoder", + executionLockedAt: now, + issueNumber: 1, + identifier: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}-3`, + }); + + const scheduled = await heartbeat.scheduleBoundedRetry(sourceRunId, { + now, + random: () => 0.5, + }); + expect(scheduled.outcome).toBe("scheduled"); + if (scheduled.outcome !== "scheduled") return; + + await db.update(issues).set({ + assigneeAgentId: null, + assigneeUserId: "local-board", + updatedAt: now, + }).where(eq(issues.id, issueId)); + + const promotion = await heartbeat.promoteDueScheduledRetries(scheduled.dueAt); + expect(promotion).toEqual({ promoted: 0, runIds: [] }); + + const oldRetry = await db + .select({ + status: heartbeatRuns.status, + errorCode: heartbeatRuns.errorCode, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, scheduled.run.id)) + .then((rows) => rows[0] ?? null); + expect(oldRetry).toEqual({ + status: "cancelled", + errorCode: "issue_reassigned", + }); + + const issue = await db + .select({ + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + executionRunId: issues.executionRunId, + }) + .from(issues) + .where(eq(issues.id, issueId)) + .then((rows) => rows[0] ?? null); + expect(issue).toMatchObject({ + assigneeAgentId: null, + assigneeUserId: "local-board", + executionRunId: null, + }); + }); + it("does not promote a scheduled retry after the issue is cancelled", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/issue-comment-reopen-routes.test.ts b/server/src/__tests__/issue-comment-reopen-routes.test.ts index 4043d86a..a304e28a 100644 --- a/server/src/__tests__/issue-comment-reopen-routes.test.ts +++ b/server/src/__tests__/issue-comment-reopen-routes.test.ts @@ -1281,7 +1281,23 @@ describe.sequential("issue comment reopen routes", () => { expect(res.status).toBe(200); expect(mockHeartbeatService.getRun).toHaveBeenCalledWith("run-1"); - expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith("run-1"); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith( + "run-1", + "Interrupted by board comment", + expect.objectContaining({ + errorCode: "operator_interrupted", + resultJson: expect.objectContaining({ + operatorInterrupted: true, + interruptionSource: "issue_comment_interrupt", + interruptedIssueId: "11111111-1111-4111-8111-111111111111", + }), + eventMessage: "run interrupted by board comment", + eventPayload: expect.objectContaining({ + issueId: "11111111-1111-4111-8111-111111111111", + source: "issue_comment_interrupt", + }), + }), + ); expect(mockLogActivity).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ @@ -1289,6 +1305,8 @@ describe.sequential("issue comment reopen routes", () => { details: expect.objectContaining({ source: "issue_comment_interrupt", issueId: "11111111-1111-4111-8111-111111111111", + cancellationKind: "operator_interrupted", + operatorInterrupted: true, }), }), ); diff --git a/server/src/__tests__/issue-tree-control-service.test.ts b/server/src/__tests__/issue-tree-control-service.test.ts index 17c26f52..afb23add 100644 --- a/server/src/__tests__/issue-tree-control-service.test.ts +++ b/server/src/__tests__/issue-tree-control-service.test.ts @@ -172,6 +172,55 @@ describeEmbeddedPostgres("issueTreeControlService", () => { }); }); + it("previews a human-owned handoff as static work with no affected agent execution", async () => { + const companyId = randomUUID(); + const rootIssueId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(issues).values({ + id: rootIssueId, + companyId, + title: "Human validation handoff", + status: "in_progress", + priority: "medium", + assigneeAgentId: null, + assigneeUserId: "local-board", + createdAt: new Date("2026-04-21T10:10:00.000Z"), + }); + + const svc = issueTreeControlService(db); + const preview = await svc.preview(companyId, rootIssueId, { mode: "pause" }); + + expect(preview.issues).toEqual([ + expect.objectContaining({ + id: rootIssueId, + status: "in_progress", + assigneeAgentId: null, + assigneeUserId: "local-board", + activeRun: null, + skipped: false, + skipReason: null, + }), + ]); + expect(preview.totals).toMatchObject({ + totalIssues: 1, + affectedIssues: 1, + skippedIssues: 0, + activeRuns: 0, + queuedRuns: 0, + affectedAgents: 0, + }); + expect(preview.activeRuns).toEqual([]); + expect(preview.affectedAgents).toEqual([]); + expect(preview.warnings.map((warning) => warning.code)).not.toContain("running_runs_present"); + expect(preview.warnings.map((warning) => warning.code)).not.toContain("queued_runs_present"); + }); + it("creates and releases normalized hold snapshots", async () => { const companyId = randomUUID(); const rootIssueId = randomUUID(); diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 5c1ebe22..41b8c76a 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -3,6 +3,8 @@ import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const ASSIGNEE_AGENT_ID = "11111111-1111-4111-8111-111111111111"; +const PREVIOUS_AGENT_ID = "22222222-2222-4222-8222-222222222222"; +const MENTIONED_AGENT_ID = "33333333-3333-4333-8333-333333333333"; const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), @@ -269,6 +271,157 @@ describe("issue update comment wakeups", () => { ); }); + it("interrupts the active run and wakes the newly assigned agent with handoff context", async () => { + const existing = makeIssue({ + assigneeAgentId: PREVIOUS_AGENT_ID, + assigneeUserId: null, + executionRunId: "run-1", + status: "in_progress", + }); + const updated = makeIssue({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + executionRunId: "run-1", + status: "in_progress", + }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-interrupt-agent", + issueId: existing.id, + companyId: existing.companyId, + body: "stop and hand this to CodexCoder", + }); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: existing.companyId, + agentId: PREVIOUS_AGENT_ID, + status: "running", + contextSnapshot: { issueId: existing.id }, + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-1", + companyId: existing.companyId, + agentId: PREVIOUS_AGENT_ID, + status: "cancelled", + }); + + const res = await request(await createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ + assigneeAgentId: ASSIGNEE_AGENT_ID, + assigneeUserId: null, + comment: "stop and hand this to CodexCoder", + interrupt: true, + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith( + "run-1", + "Interrupted by board comment", + expect.objectContaining({ + errorCode: "operator_interrupted", + resultJson: expect.objectContaining({ + operatorInterrupted: true, + interruptionSource: "issue_comment_interrupt", + interruptedIssueId: existing.id, + }), + eventMessage: "run interrupted by board comment", + eventPayload: expect.objectContaining({ + issueId: existing.id, + source: "issue_comment_interrupt", + }), + }), + ); + await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1)); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + ASSIGNEE_AGENT_ID, + expect.objectContaining({ + source: "assignment", + reason: "issue_assigned", + payload: expect.objectContaining({ + issueId: existing.id, + commentId: "comment-interrupt-agent", + interruptedRunId: "run-1", + mutation: "update", + }), + contextSnapshot: expect.objectContaining({ + issueId: existing.id, + taskId: existing.id, + commentId: "comment-interrupt-agent", + wakeCommentId: "comment-interrupt-agent", + interruptedRunId: "run-1", + source: "issue.update", + }), + }), + ); + }); + + it("interrupts the active run without waking an agent when the handoff assigns a user", async () => { + const existing = makeIssue({ + assigneeAgentId: PREVIOUS_AGENT_ID, + assigneeUserId: null, + executionRunId: "run-2", + status: "in_progress", + }); + const updated = makeIssue({ + assigneeAgentId: null, + assigneeUserId: "local-board", + executionRunId: "run-2", + status: "in_progress", + }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.update.mockResolvedValue(updated); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-interrupt-user", + issueId: existing.id, + companyId: existing.companyId, + body: "stop here, I will take it", + }); + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-2", + companyId: existing.companyId, + agentId: PREVIOUS_AGENT_ID, + status: "running", + contextSnapshot: { issueId: existing.id }, + }); + mockHeartbeatService.cancelRun.mockResolvedValue({ + id: "run-2", + companyId: existing.companyId, + agentId: PREVIOUS_AGENT_ID, + status: "cancelled", + }); + + const res = await request(await createApp()) + .patch(`/api/issues/${existing.id}`) + .send({ + assigneeAgentId: null, + assigneeUserId: "local-board", + comment: "stop here, I will take it", + interrupt: true, + }); + + expect(res.status).toBe(200); + expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith( + "run-2", + "Interrupted by board comment", + expect.objectContaining({ + errorCode: "operator_interrupted", + resultJson: expect.objectContaining({ + operatorInterrupted: true, + interruptionSource: "issue_comment_interrupt", + interruptedIssueId: existing.id, + }), + eventMessage: "run interrupted by board comment", + }), + ); + await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith( + existing.companyId, + "stop here, I will take it", + )); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + it("wakes the assignee on comment-only issue updates", async () => { const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, @@ -358,4 +511,78 @@ describe("issue update comment wakeups", () => { }), ); }); + + it("does not route a plain-text agent name on a human-owned issue comment", async () => { + const existing = makeIssue({ + assigneeAgentId: null, + assigneeUserId: "local-board", + status: "in_progress", + }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-plain-agent-name", + issueId: existing.id, + companyId: existing.companyId, + body: "QA please take the screenshot", + }); + mockIssueService.findMentionedAgents.mockResolvedValue([]); + + const res = await request(await createApp()) + .post(`/api/issues/${existing.id}/comments`) + .send({ + body: "QA please take the screenshot", + }); + + expect(res.status).toBe(201); + await vi.waitFor(() => expect(mockIssueService.findMentionedAgents).toHaveBeenCalledWith( + existing.companyId, + "QA please take the screenshot", + )); + expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled(); + }); + + it("routes a structured mentioned agent without making that agent the issue owner", async () => { + const existing = makeIssue({ + assigneeAgentId: null, + assigneeUserId: "local-board", + status: "in_progress", + }); + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.addComment.mockResolvedValue({ + id: "comment-structured-mention", + issueId: existing.id, + companyId: existing.companyId, + body: "[@QA](/agents/33333333-3333-4333-8333-333333333333) please inspect this", + }); + mockIssueService.findMentionedAgents.mockResolvedValue([MENTIONED_AGENT_ID]); + + const res = await request(await createApp()) + .post(`/api/issues/${existing.id}/comments`) + .send({ + body: "[@QA](/agents/33333333-3333-4333-8333-333333333333) please inspect this", + }); + + expect(res.status).toBe(201); + await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1)); + expect(mockIssueService.update).not.toHaveBeenCalled(); + expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith( + MENTIONED_AGENT_ID, + expect.objectContaining({ + source: "automation", + reason: "issue_comment_mentioned", + payload: { + issueId: existing.id, + commentId: "comment-structured-mention", + }, + contextSnapshot: expect.objectContaining({ + issueId: existing.id, + taskId: existing.id, + commentId: "comment-structured-mention", + wakeCommentId: "comment-structured-mention", + wakeReason: "issue_comment_mentioned", + source: "comment.mention", + }), + }), + ); + }); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index a74b438c..6569815c 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -36,7 +36,7 @@ import { ISSUE_LIST_MAX_LIMIT, issueService, } from "../services/issues.ts"; -import { buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared"; +import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH } from "@paperclipai/shared"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -345,6 +345,40 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { }); }); + it("resolves only structured same-company agent mentions", async () => { + const companyId = await seedAssignableAgentCompany(); + const otherCompanyId = await seedAssignableAgentCompany(); + const localAgentId = randomUUID(); + const foreignAgentId = randomUUID(); + + await db.insert(agents).values([ + agentRow(companyId, { id: localAgentId, name: "LocalAgent" }), + agentRow(otherCompanyId, { id: foreignAgentId, name: "ForeignAgent" }), + ]); + + const mentions = await svc.findMentionedAgents( + companyId, + [ + `hello [@LocalAgent](${buildAgentMentionHref(localAgentId)})`, + `and [@ForeignAgent](${buildAgentMentionHref(foreignAgentId)})`, + ].join(" "), + ); + + expect(mentions).toEqual([localAgentId]); + }); + + it("does not wake agents from raw @name text without a structured mention", async () => { + const companyId = await seedAssignableAgentCompany(); + const localAgentId = randomUUID(); + + await db.insert(agents).values([ + agentRow(companyId, { id: localAgentId, name: "LocalAgent" }), + ]); + + await expect(svc.findMentionedAgents(companyId, "@LocalAgent please inspect this")) + .resolves.toEqual([]); + }); + it("returns issues an agent participated in across the supported signals", async () => { const companyId = randomUUID(); const agentId = randomUUID(); diff --git a/server/src/__tests__/normalize-agent-mention-token.test.ts b/server/src/__tests__/normalize-agent-mention-token.test.ts deleted file mode 100644 index b8a33d1d..00000000 --- a/server/src/__tests__/normalize-agent-mention-token.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeAgentMentionToken } from "../services/issues.ts"; - -describe("normalizeAgentMentionToken", () => { - it("decodes hex numeric entities such as space ( )", () => { - expect(normalizeAgentMentionToken("Baba ")).toBe("Baba"); - }); - - it("decodes decimal numeric entities", () => { - expect(normalizeAgentMentionToken("Baba ")).toBe("Baba"); - }); - - it("decodes common named whitespace entities", () => { - expect(normalizeAgentMentionToken("Baba ")).toBe("Baba"); - }); - - // Mid-token entity (review asked for this shape); we decode &→&, not strip to "Baba" (that broke M&M). - it("decodes a named entity in the middle of the token", () => { - expect(normalizeAgentMentionToken("Ba&ba")).toBe("Ba&ba"); - }); - - it("decodes & so agent names with ampersands still match", () => { - expect(normalizeAgentMentionToken("M&M")).toBe("M&M"); - }); - - it("decodes additional named entities used in rich text (e.g. ©)", () => { - expect(normalizeAgentMentionToken("Agent©Name")).toBe("Agent©Name"); - }); - - it("leaves unknown semicolon-terminated named references unchanged", () => { - expect(normalizeAgentMentionToken("Baba¬arealentity;")).toBe("Baba¬arealentity;"); - }); - - it("returns plain names unchanged", () => { - expect(normalizeAgentMentionToken("Baba")).toBe("Baba"); - }); - - it("trims after decoding entities", () => { - expect(normalizeAgentMentionToken("Baba ")).toBe("Baba"); - }); -}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 6241b79f..e600266e 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2106,6 +2106,26 @@ export function issueRoutes( return runToInterrupt?.status === "running" ? runToInterrupt : null; } + function operatorInterruptCancelOptions(input: { issueId: string; actor: ReturnType }) { + return { + errorCode: "operator_interrupted", + resultJson: { + operatorInterrupted: true, + interruptionSource: "issue_comment_interrupt", + interruptedIssueId: input.issueId, + interruptedByActorType: input.actor.actorType, + interruptedByActorId: input.actor.actorId, + }, + eventMessage: "run interrupted by board comment", + eventPayload: { + issueId: input.issueId, + source: "issue_comment_interrupt", + interruptedByActorType: input.actor.actorType, + interruptedByActorId: input.actor.actorId, + }, + }; + } + async function normalizeIssueAssigneeAgentReference( companyId: string, rawAssigneeAgentId: string | null | undefined, @@ -4761,7 +4781,11 @@ export function issueRoutes( const runToInterrupt = await resolveActiveIssueRun(existing); if (runToInterrupt) { - const cancelled = await heartbeat.cancelRun(runToInterrupt.id); + const cancelled = await heartbeat.cancelRun( + runToInterrupt.id, + "Interrupted by board comment", + operatorInterruptCancelOptions({ issueId: existing.id, actor }), + ); if (cancelled) { interruptedRunId = cancelled.id; await logActivity(db, { @@ -4773,7 +4797,13 @@ export function issueRoutes( action: "heartbeat.cancelled", entityType: "heartbeat_run", entityId: cancelled.id, - details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: existing.id }, + details: { + agentId: cancelled.agentId, + source: "issue_comment_interrupt", + issueId: existing.id, + cancellationKind: "operator_interrupted", + operatorInterrupted: true, + }, }); } } @@ -6554,7 +6584,11 @@ export function issueRoutes( const runToInterrupt = await resolveActiveIssueRun(currentIssue); if (runToInterrupt) { - const cancelled = await heartbeat.cancelRun(runToInterrupt.id); + const cancelled = await heartbeat.cancelRun( + runToInterrupt.id, + "Interrupted by board comment", + operatorInterruptCancelOptions({ issueId: currentIssue.id, actor }), + ); if (cancelled) { interruptedRunId = cancelled.id; await logActivity(db, { @@ -6566,7 +6600,13 @@ export function issueRoutes( action: "heartbeat.cancelled", entityType: "heartbeat_run", entityId: cancelled.id, - details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: currentIssue.id }, + details: { + agentId: cancelled.agentId, + source: "issue_comment_interrupt", + issueId: currentIssue.id, + cancellationKind: "operator_interrupted", + operatorInterrupted: true, + }, }); } } diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index 266a448c..260d501d 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -387,6 +387,7 @@ export function activityService(db: Db) { finishedAt: heartbeatRuns.finishedAt, createdAt: heartbeatRuns.createdAt, invocationSource: heartbeatRuns.invocationSource, + errorCode: heartbeatRuns.errorCode, usageJson: summarizedUsageJson, resultJson: summarizedResultJson, logBytes: heartbeatRuns.logBytes, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index a74369ef..88f02451 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -10649,41 +10649,58 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) return wakeupIds.length; } - async function cancelRunInternal(runId: string, reason = "Cancelled by control plane") { + type CancelRunOptions = { + errorCode?: string; + resultJson?: Record; + eventMessage?: string; + eventPayload?: Record; + }; + + async function cancelRunInternal(runId: string, reason = "Cancelled by control plane", options: CancelRunOptions = {}) { const run = await getRun(runId); if (!run) throw notFound("Heartbeat run not found"); if (!CANCELLABLE_HEARTBEAT_RUN_STATUSES.includes(run.status as (typeof CANCELLABLE_HEARTBEAT_RUN_STATUSES)[number])) return run; const agent = await getAgent(run.agentId); + const errorCode = options.errorCode ?? "cancelled"; + const resultJson = agent + ? { + ...mergeRunStopMetadataForAgent(agent, "cancelled", { + resultJson: parseObject(run.resultJson), + errorCode, + errorMessage: reason, + }), + ...(options.resultJson ?? {}), + } + : options.resultJson; const running = runningProcesses.get(run.id); - if (running) { - await terminateHeartbeatRunProcess({ - pid: running.child.pid ?? run.processPid, - processGroupId: running.processGroupId ?? run.processGroupId, - graceMs: Math.max(1, running.graceSec) * 1000, - }); - } else if (run.processPid || run.processGroupId) { - await terminateHeartbeatRunProcess({ - pid: run.processPid, - processGroupId: run.processGroupId, - }); + try { + if (running) { + await terminateHeartbeatRunProcess({ + pid: running.child.pid ?? run.processPid, + processGroupId: running.processGroupId ?? run.processGroupId, + graceMs: Math.max(1, running.graceSec) * 1000, + }); + } else if (run.processPid || run.processGroupId) { + await terminateHeartbeatRunProcess({ + pid: run.processPid, + processGroupId: run.processGroupId, + }); + } + } finally { + runningProcesses.delete(run.id); } + const finishedAt = new Date(); const cancelled = await setRunStatus(run.id, "cancelled", { - finishedAt: new Date(), + finishedAt, error: reason, - errorCode: "cancelled", - ...(agent ? { - resultJson: mergeRunStopMetadataForAgent(agent, "cancelled", { - resultJson: parseObject(run.resultJson), - errorCode: "cancelled", - errorMessage: reason, - }), - } : {}), + errorCode, + ...(resultJson ? { resultJson } : {}), }); await setWakeupStatus(run.wakeupRequestId, "cancelled", { - finishedAt: new Date(), + finishedAt, error: reason, }); @@ -10692,12 +10709,12 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) eventType: "lifecycle", stream: "system", level: "warn", - message: "run cancelled", + message: options.eventMessage ?? "run cancelled", + ...(options.eventPayload ? { payload: options.eventPayload } : {}), }); await releaseIssueExecutionAndPromote(cancelled); } - runningProcesses.delete(run.id); await finalizeAgentStatus(run.agentId, "cancelled"); await startNextQueuedRunForAgent(run.agentId); return cancelled; @@ -11137,7 +11154,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) }; }, - cancelRun: (runId: string, reason?: string) => cancelRunInternal(runId, reason), + cancelRun: (runId: string, reason?: string, options?: CancelRunOptions) => cancelRunInternal(runId, reason, options), cancelActiveForAgent: (agentId: string, reason?: string) => cancelActiveForAgentInternal(agentId, reason), diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index e4c01818..d422f4d9 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1006,41 +1006,6 @@ function shouldIncludePluginOperationIssues(filters: IssueFilters | undefined) { ); } -/** Named entities commonly emitted in saved issue bodies; unknown `&name;` sequences are left unchanged. */ -const WELL_KNOWN_NAMED_HTML_ENTITIES: Readonly> = { - amp: "&", - apos: "'", - copy: "\u00A9", - gt: ">", - lt: "<", - nbsp: "\u00A0", - quot: '"', - ensp: "\u2002", - emsp: "\u2003", - thinsp: "\u2009", -}; - -function decodeNumericHtmlEntity(digits: string, radix: 16 | 10): string | null { - const n = Number.parseInt(digits, radix); - if (Number.isNaN(n) || n < 0 || n > 0x10ffff) return null; - try { - return String.fromCodePoint(n); - } catch { - return null; - } -} - -/** Decodes HTML character references in a raw @mention capture so UI-encoded bodies match agent names. */ -export function normalizeAgentMentionToken(raw: string): string { - let s = raw.replace(/&#x([0-9a-fA-F]+);/gi, (full, hex: string) => decodeNumericHtmlEntity(hex, 16) ?? full); - s = s.replace(/&#([0-9]+);/g, (full, dec: string) => decodeNumericHtmlEntity(dec, 10) ?? full); - s = s.replace(/&([a-z][a-z0-9]*);/gi, (full, name: string) => { - const decoded = WELL_KNOWN_NAMED_HTML_ENTITIES[name.toLowerCase()]; - return decoded !== undefined ? decoded : full; - }); - return s.trim(); -} - export function deriveIssueUserContext( issue: IssueUserContextInput, userId: string, @@ -6063,25 +6028,13 @@ export function issueService(db: Db) { }), findMentionedAgents: async (companyId: string, body: string) => { - const re = /\B@([^\s@,!?.]+)/g; - const tokens = new Set(); - let m: RegExpExecArray | null; - while ((m = re.exec(body)) !== null) { - const normalized = normalizeAgentMentionToken(m[1]); - if (normalized) tokens.add(normalized.toLowerCase()); - } - const explicitAgentMentionIds = extractAgentMentionIds(body); - if (tokens.size === 0 && explicitAgentMentionIds.length === 0) return []; - const rows = await db.select({ id: agents.id, name: agents.name }) + if (explicitAgentMentionIds.length === 0) return []; + + const rows = await db.select({ id: agents.id }) .from(agents).where(eq(agents.companyId, companyId)); - const resolved = new Set(explicitAgentMentionIds); - for (const agent of rows) { - if (tokens.has(agent.name.toLowerCase())) { - resolved.add(agent.id); - } - } - return [...resolved]; + const companyAgentIds = new Set(rows.map((agent) => agent.id)); + return explicitAgentMentionIds.filter((agentId) => companyAgentIds.has(agentId)); }, findMentionedProjectIds: async ( diff --git a/ui/src/api/activity.ts b/ui/src/api/activity.ts index b245ad31..e5eaf070 100644 --- a/ui/src/api/activity.ts +++ b/ui/src/api/activity.ts @@ -12,6 +12,7 @@ export interface RunForIssue { finishedAt: string | null; createdAt: string; invocationSource: string; + errorCode?: string | null; usageJson: Record | null; resultJson: Record | null; logBytes?: number | null; diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 4412153f..14561f00 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -95,6 +95,20 @@ import { Identity } from "./Identity"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard"; import { AgentIcon } from "./AgentIconPicker"; +import { + AssigneeChip, + ComposerHandoffPreviewRow, + ComposerMentionCoach, + HandoffWakeRow, + RunStatusBadge, + type HandoffChipResolvers, +} from "./interrupt-handoff/InterruptHandoffViews"; +import { + computeComposerHandoffPreview, + extractAgentMentionIds, + findPlainAgentNameCandidate, + type HandoffAgentMention, +} from "../lib/interrupt-handoff"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { captureComposerViewportSnapshot, @@ -132,6 +146,7 @@ import { summarizeToolInput, summarizeToolResult, } from "../lib/transcriptPresentation"; +import { buildAgentMentionHref } from "@paperclipai/shared"; import { cn, formatDateTime, formatShortDate } from "../lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -302,6 +317,10 @@ interface IssueChatComposerProps { suggestedAssigneeValue?: string; mentions?: MentionOption[]; agentMap?: Map; + /** Whether an agent run is currently in flight, so the composer can preview an interrupt. */ + hasActiveRun?: boolean; + currentUserId?: string | null; + userLabelMap?: ReadonlyMap | null; composerDisabledReason?: string | null; composerHint?: string | null; issueStatus?: string; @@ -721,21 +740,6 @@ function humanizeValue(value: string | null) { return value.replace(/_/g, " "); } -function formatTimelineAssigneeLabel( - assignee: IssueTimelineAssignee, - agentMap?: Map, - currentUserId?: string | null, - userLabelMap?: ReadonlyMap | null, -) { - if (assignee.agentId) { - return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8); - } - if (assignee.userId) { - return formatAssigneeUserLabel(assignee.userId, currentUserId, userLabelMap) ?? "Board"; - } - return "Unassigned"; -} - function initialsForName(name: string) { const parts = name.trim().split(/\s+/); if (parts.length >= 2) { @@ -781,36 +785,6 @@ export function resolveIssueChatHumanAuthor(args: { }; } -function formatRunStatusLabel(status: string) { - switch (status) { - case "timed_out": - return "timed out"; - default: - return status.replace(/_/g, " "); - } -} - -function runStatusClass(status: string) { - switch (status) { - case "succeeded": - return "text-green-700 dark:text-green-300"; - case "failed": - case "error": - return "text-red-700 dark:text-red-300"; - case "timed_out": - return "text-orange-700 dark:text-orange-300"; - case "running": - return "text-cyan-700 dark:text-cyan-300"; - case "queued": - case "pending": - return "text-amber-700 dark:text-amber-300"; - case "cancelled": - return "text-muted-foreground"; - default: - return "text-foreground"; - } -} - function toolCountSummary(toolParts: ToolCallMessagePart[]): string | null { if (toolParts.length === 0) return null; let commands = 0; @@ -2552,6 +2526,11 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId; const isAgent = actorType === "agent"; const agentIcon = isAgent && actorId ? agentMap?.get(actorId)?.icon : undefined; + const handoffResolvers: HandoffChipResolvers = { + agentMap, + currentUserId, + resolveUserLabel: (userId) => formatAssigneeUserLabel(userId, null, userLabelMap), + }; const eventContent = (
@@ -2580,17 +2559,22 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { ) : null} {assigneeChange ? ( -
- - Assignee - - - {formatTimelineAssigneeLabel(assigneeChange.from, agentMap, currentUserId, userLabelMap)} - - - - {formatTimelineAssigneeLabel(assigneeChange.to, agentMap, currentUserId, userLabelMap)} - +
+
+ + Assignee + + + + +
+
+ +
) : null} @@ -2665,9 +2649,10 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) { > {runId.slice(0, 8)} - - {formatRunStatusLabel(runStatus)} - + (null); const resolvedIssueWorkMode: IssueWorkMode = issueWorkMode ?? "standard"; const [pendingWorkMode, setPendingWorkMode] = useState(resolvedIssueWorkMode); const [workModeMenuOpen, setWorkModeMenuOpen] = useState(false); @@ -3548,6 +3537,67 @@ const IssueChatComposer = forwardRef( + () => + mentions + .filter((m) => (m.kind ?? "agent") === "agent" && (m.agentId ?? m.id)) + .map((m) => ({ agentId: m.agentId ?? m.id.replace(/^agent:/, ""), name: m.name })), + [mentions], + ); + const handoffResolvers = useMemo( + () => ({ + agentMap, + currentUserId, + resolveUserLabel: (userId: string) => formatAssigneeUserLabel(userId, null, userLabelMap), + }), + [agentMap, currentUserId, userLabelMap], + ); + const mentionedAgentIds = useMemo(() => extractAgentMentionIds(body), [body]); + const plainNameCandidate = useMemo( + () => (mentionedAgentIds.length > 0 ? null : findPlainAgentNameCandidate(body, agentMentionOptions)), + [body, mentionedAgentIds, agentMentionOptions], + ); + const handoffPreview = useMemo( + () => + computeComposerHandoffPreview({ + reassignTarget, + currentAssigneeValue, + hasActiveRun, + bodyHasAgentMention: mentionedAgentIds.length > 0, + mentionedAgentId: mentionedAgentIds[0] ?? null, + plainNameCandidate, + }), + [reassignTarget, currentAssigneeValue, hasActiveRun, mentionedAgentIds, plainNameCandidate], + ); + const coachVisible = Boolean( + plainNameCandidate && plainNameCandidate.matchedText !== dismissedCoachToken, + ); + const coachAgentName = plainNameCandidate + ? agentMap?.get(plainNameCandidate.agentId)?.name ?? plainNameCandidate.matchedText + : ""; + + function insertCoachMention() { + if (!plainNameCandidate) return; + const option = mentions.find( + (m) => (m.agentId ?? m.id.replace(/^agent:/, "")) === plainNameCandidate.agentId, + ); + const agentId = plainNameCandidate.agentId; + const name = option?.name ?? plainNameCandidate.matchedText; + const markdown = `[@${name}](${buildAgentMentionHref(agentId, option?.agentIcon ?? null)}) `; + // Replace the first bare occurrence of the matched token (outside links). + const tokenRe = new RegExp( + `(? { + if (tokenRe.test(current)) return current.replace(tokenRe, markdown.trimEnd()); + return current ? `${current} ${markdown}` : markdown; + }); + setDismissedCoachToken(plainNameCandidate.matchedText); + } + if (composerDisabledReason) { return (
@@ -3605,6 +3655,17 @@ const IssueChatComposer = forwardRef + {coachVisible && plainNameCandidate ? ( +
+ setDismissedCoachToken(plainNameCandidate.matchedText)} + /> +
+ ) : null} + {composerHint ? (
{composerHint} @@ -3656,6 +3717,10 @@ const IssueChatComposer = forwardRef ) : null} + {body.trim() ? ( + + ) : null} +
{(onImageUpload || onAttachImage) ? ( @@ -3900,6 +3965,10 @@ export function IssueChatThread({ } return ids; }, [displayLiveRuns]); + const hasActiveRun = useMemo( + () => displayLiveRuns.some((run) => run.status === "running") || activeRun?.status === "running", + [displayLiveRuns, activeRun], + ); const clearLatestSettleTimeouts = useCallback(() => { for (const timeout of latestSettleTimeoutsRef.current) { window.clearTimeout(timeout); @@ -4532,6 +4601,9 @@ export function IssueChatThread({ suggestedAssigneeValue={suggestedAssigneeValue} mentions={mentions} agentMap={agentMap} + hasActiveRun={!!hasActiveRun} + currentUserId={currentUserId} + userLabelMap={userLabelMap} composerDisabledReason={composerDisabledReason} composerHint={composerHint} issueStatus={issueStatus} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index 4ee63932..63f8d1eb 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; import type { ComponentProps, ReactNode } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import type { ExecutionWorkspace, @@ -115,6 +115,14 @@ vi.mock("@/components/ui/popover", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); @@ -385,6 +393,120 @@ describe("IssueProperties", () => { document.body.innerHTML = ""; }); + it("groups the assignee picker and gates a live-run reassign behind an interrupt confirm", async () => { + const minimalAgent = (id: string, name: string) => + ({ + id, + name, + role: "", + title: null, + icon: null, + status: "active", + orgChainHealth: { status: "ok" }, + } as unknown as Parameters[0][number]); + mockAgentsApi.list.mockResolvedValue([minimalAgent("agent-1", "ClaudeCoder"), minimalAgent("agent-2", "QA")]); + const onUpdate = vi.fn(); + const root = renderProperties(container, { + issue: createIssue({ assigneeAgentId: "agent-1" }), + childIssues: [], + onUpdate, + inline: true, + hasActiveRun: true, + }); + await flush(); + + // Wait for the agents query to resolve so the current assignee renders. + let trigger: HTMLButtonElement | undefined; + await waitForAssertion(() => { + trigger = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("ClaudeCoder"), + ); + expect(trigger).toBeTruthy(); + }); + await act(async () => { + trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + // Live-run banner + grouped section headers are present. + expect(container.querySelector("[data-testid='assignee-running-banner']")?.textContent).toContain( + "ClaudeCoder is running", + ); + expect(container.textContent).toContain("Agents"); + expect(container.textContent).toContain("Board users"); + + // Picking a different agent mid-run stages a confirm rather than applying. + const qaOption = Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "QA", + ); + expect(qaOption).toBeTruthy(); + await act(async () => { + qaOption!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + expect(container.querySelector("[data-testid='interrupt-assign-confirm']")).not.toBeNull(); + expect(onUpdate).not.toHaveBeenCalled(); + + // Confirming applies the reassignment. + const confirmBtn = container.querySelector( + "[data-testid='interrupt-assign-confirm-action']", + )!; + await act(async () => { + confirmBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + expect(onUpdate).toHaveBeenCalledWith({ assigneeAgentId: "agent-2", assigneeUserId: null }); + + act(() => root.unmount()); + }); + + it("filters the no-assignee option with assignee search", async () => { + mockAgentsApi.list.mockResolvedValue([ + { + id: "agent-1", + name: "ClaudeCoder", + role: "", + title: null, + icon: null, + status: "active", + orgChainHealth: { status: "ok" }, + } as unknown as Parameters[0][number], + ]); + const root = renderProperties(container, { + issue: createIssue(), + childIssues: [], + onUpdate: vi.fn(), + }); + await flush(); + + const searchInput = container.querySelector( + 'input[placeholder="Search assignees..."]', + ) as HTMLInputElement | null; + expect(searchInput).not.toBeNull(); + + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + nativeSetter?.call(searchInput, "no"); + searchInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flush(); + + expect(container.textContent).toContain("No assignee"); + expect(container.textContent).not.toContain("No matches."); + + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + nativeSetter?.call(searchInput, "zzzz"); + searchInput!.dispatchEvent(new Event("input", { bubbles: true })); + }); + await flush(); + + expect(container.textContent).not.toContain("No assignee"); + expect(container.textContent).toContain("No matches."); + + act(() => root.unmount()); + }); + it("always exposes the add sub-issue action", async () => { const onAddSubIssue = vi.fn(); const root = renderProperties(container, { diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 02f3d129..a3d41925 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -16,7 +16,6 @@ import { ISSUE_OVERRIDE_ADAPTER_TYPES, type IssueModelLane } from "../lib/issue- import { useProjectOrder } from "../hooks/useProjectOrder"; import { getRecentAssigneeIds, - getRecentAssigneeSelectionIds, sortAgentsByRecency, trackRecentAssignee, trackRecentAssigneeUser, @@ -52,6 +51,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { User, Hexagon, ArrowUpRight, Tag, Plus, GitBranch, FolderOpen, Check, ExternalLink, X, Clock, RotateCcw, Loader2, CheckCircle2 } from "lucide-react"; import { AgentIcon } from "./AgentIconPicker"; import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector"; +import { + AssigneeRunningBanner, + InterruptAssignConfirm, + type HandoffChipResolvers, +} from "./interrupt-handoff/InterruptHandoffViews"; +import { describeReassignInterrupt } from "../lib/interrupt-handoff"; function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: React.ComponentType<{ className?: string }> }) { const [copied, setCopied] = useState(false); @@ -143,6 +148,9 @@ interface IssuePropertiesProps { onAddSubIssue?: () => void; onUpdate: (data: Record) => void; inline?: boolean; + /** Whether an agent run is currently in flight on this issue, so the assignee + * picker can warn that reassigning will interrupt it. */ + hasActiveRun?: boolean; } const ISSUE_BLOCKER_SEARCH_LIMIT = 50; @@ -381,12 +389,21 @@ export function IssueProperties({ onAddSubIssue, onUpdate, inline, + hasActiveRun = false, }: IssuePropertiesProps) { const { selectedCompanyId } = useCompany(); const queryClient = useQueryClient(); const companyId = issue.companyId ?? selectedCompanyId; const [assigneeOpen, setAssigneeOpen] = useState(false); const [assigneeSearch, setAssigneeSearch] = useState(""); + /** When a run is live, a selection is staged here until the operator confirms + * the interrupt rather than applying it immediately. */ + const [pendingAssignee, setPendingAssignee] = useState<{ + assigneeAgentId: string | null; + assigneeUserId: string | null; + label: string; + track?: () => void; + } | null>(null); const [projectOpen, setProjectOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(""); const [blockedByOpen, setBlockedByOpen] = useState(false); @@ -543,15 +560,10 @@ export function IssueProperties({ }; const recentAssigneeIds = useMemo(() => getRecentAssigneeIds(), [assigneeOpen]); - const recentAssigneeSelectionIds = useMemo(() => getRecentAssigneeSelectionIds(), [assigneeOpen]); const sortedAgents = useMemo( () => sortAgentsByRecency((agents ?? []).filter(isAgentTaskTarget), recentAssigneeIds), [agents, recentAssigneeIds], ); - const recentAssigneeValues = useMemo( - () => recentAssigneeSelectionIds, - [recentAssigneeSelectionIds], - ); const recentProjectIds = useMemo(() => getRecentProjectIds(), [projectOpen]); const userLabelMap = useMemo( () => buildCompanyUserLabelMap(companyMembers?.users), @@ -788,6 +800,51 @@ export function IssueProperties({ : issue.assigneeUserId ? `user:${issue.assigneeUserId}` : ""; + + // --- Interrupt-handoff clarity for the assignee picker (design surface 2) --- + const handoffResolvers: HandoffChipResolvers = useMemo( + () => ({ + agentMap: new Map((agents ?? []).map((agent) => [agent.id, { name: agent.name, icon: agent.icon }])), + resolveUserLabel: (id) => userLabel(id), + }), + // userLabel closes over userLabelMap + currentUserId, both reflected here. + [agents, userLabelMap, currentUserId], + ); + const reassignInterruptCopy = useMemo( + () => describeReassignInterrupt({ runningAgentName: assignee?.name ?? null }), + [assignee?.name], + ); + const closeAssigneePicker = () => { + setAssigneeOpen(false); + setAssigneeSearch(""); + setPendingAssignee(null); + }; + const applyAssignee = (next: { assigneeAgentId: string | null; assigneeUserId: string | null }, track?: () => void) => { + track?.(); + onUpdate(next); + closeAssigneePicker(); + }; + /** Apply a selection immediately, or stage it for confirmation while a run is live. */ + const selectAssignee = ( + next: { assigneeAgentId: string | null; assigneeUserId: string | null }, + label: string, + track?: () => void, + ) => { + const nextValue = next.assigneeAgentId + ? `agent:${next.assigneeAgentId}` + : next.assigneeUserId + ? `user:${next.assigneeUserId}` + : ""; + if (nextValue === selectedAssigneeValue) { + closeAssigneePicker(); + return; + } + if (hasActiveRun) { + setPendingAssignee({ ...next, label, track }); + return; + } + applyAssignee(next, track); + }; const updateExecutionPolicy = (nextReviewers: string[], nextApprovers: string[]) => { onUpdate({ executionPolicy: buildExecutionPolicy({ @@ -1287,48 +1344,122 @@ export function IssueProperties({ ); - const assigneePickerOptions = orderItemsBySelectedAndRecent( - [ - { id: "", kind: "none" as const, label: "No assignee", searchText: "" }, - ...(currentUserId - ? [{ - id: `user:${currentUserId}`, - kind: "user" as const, - userId: currentUserId, - label: "Assign to me", - searchText: userLabel(currentUserId) ?? "", - }] - : []), - ...(issue.createdByUserId && issue.createdByUserId !== currentUserId - ? [{ - id: `user:${issue.createdByUserId}`, - kind: "user" as const, - userId: issue.createdByUserId, - label: creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester", - searchText: creatorUserLabel ?? "requester", - }] - : []), - ...otherUserOptions.map((option) => ({ - id: option.id, - kind: "user" as const, - userId: option.id.slice("user:".length), - label: option.label, - searchText: option.searchText ?? "", - })), - ...sortedAgents.map((agent) => ({ - id: `agent:${agent.id}`, - kind: "agent" as const, - agent, - label: agent.name, - searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, - })), - ], - selectedAssigneeValue, - recentAssigneeValues, + // Grouped picker options (design surface 2): a board-users section and an + // agents section, plus the "No assignee" reset. Agents stay recency-sorted + // within their group via `sortedAgents`. + const userAssigneeOptions = [ + ...(currentUserId + ? [{ + kind: "user" as const, + value: `user:${currentUserId}`, + userId: currentUserId, + label: "Assign to me", + searchText: userLabel(currentUserId) ?? "", + }] + : []), + ...(issue.createdByUserId && issue.createdByUserId !== currentUserId + ? [{ + kind: "user" as const, + value: `user:${issue.createdByUserId}`, + userId: issue.createdByUserId, + label: creatorUserLabel ? `Assign to ${creatorUserLabel}` : "Assign to requester", + searchText: creatorUserLabel ?? "requester", + }] + : []), + ...otherUserOptions.map((option) => ({ + kind: "user" as const, + value: option.id, + userId: option.id.slice("user:".length), + label: option.label, + searchText: option.searchText ?? "", + })), + ]; + const agentAssigneeOptions = sortedAgents.map((agent) => ({ + kind: "agent" as const, + value: `agent:${agent.id}`, + agent, + label: agent.name, + searchText: `${agent.name} ${agent.role} ${agent.title ?? ""}`, + })); + + const matchesAssigneeSearch = (label: string, searchText: string) => { + if (!assigneeSearch.trim()) return true; + return `${label} ${searchText}`.toLowerCase().includes(assigneeSearch.toLowerCase()); + }; + + type AssigneeOptionLike = + | { kind: "none"; value: string; label: string; searchText: string } + | { kind: "user"; value: string; userId: string; label: string; searchText: string } + | { kind: "agent"; value: string; agent: (typeof agentAssigneeOptions)[number]["agent"]; label: string; searchText: string }; + + const renderAssigneeOption = (option: AssigneeOptionLike) => ( + ); - const assigneeContent = ( + const visibleUserOptions = userAssigneeOptions.filter((option) => + matchesAssigneeSearch(option.label, option.searchText), + ); + const visibleAgentOptions = agentAssigneeOptions.filter((option) => + matchesAssigneeSearch(option.label, option.searchText), + ); + const showNoAssigneeOption = matchesAssigneeSearch("No assignee", ""); + const sectionHeader = (text: string) => ( +
+ {text} +
+ ); + + const assigneeContent = pendingAssignee ? ( +
+ + applyAssignee( + { assigneeAgentId: pendingAssignee.assigneeAgentId, assigneeUserId: pendingAssignee.assigneeUserId }, + pendingAssignee.track, + ) + } + onCancel={() => setPendingAssignee(null)} + /> +
+ ) : ( <> + {hasActiveRun ? ( +
+ +
+ ) : null} setAssigneeSearch(e.target.value)} autoFocus={!inline} /> -
- {assigneePickerOptions - .filter((option) => { - if (!assigneeSearch.trim()) return true; - const q = assigneeSearch.toLowerCase(); - return `${option.label} ${option.searchText}`.toLowerCase().includes(q); - }) - .map((option) => ( - - ))} +
+ {showNoAssigneeOption + ? renderAssigneeOption({ kind: "none", value: "", label: "No assignee", searchText: "" }) + : null} + {visibleAgentOptions.length > 0 ? ( + <> + {sectionHeader("Agents")} + {visibleAgentOptions.map((option) => renderAssigneeOption(option))} + + ) : null} + {visibleUserOptions.length > 0 ? ( + <> + {sectionHeader("Board users")} + {visibleUserOptions.map((option) => renderAssigneeOption(option))} + + ) : null} + {!showNoAssigneeOption && visibleAgentOptions.length === 0 && visibleUserOptions.length === 0 ? ( +
No matches.
+ ) : null}
); @@ -1789,7 +1904,7 @@ export function IssueProperties({ inline={inline} label="Assignee" open={assigneeOpen} - onOpenChange={(open) => { setAssigneeOpen(open); if (!open) setAssigneeSearch(""); }} + onOpenChange={(open) => { setAssigneeOpen(open); if (!open) { setAssigneeSearch(""); setPendingAssignee(null); } }} triggerContent={assigneeTrigger} popoverClassName="w-52" extra={issue.assigneeAgentId ? ( diff --git a/ui/src/components/interrupt-handoff/InterruptHandoffViews.test.tsx b/ui/src/components/interrupt-handoff/InterruptHandoffViews.test.tsx new file mode 100644 index 00000000..2d080840 --- /dev/null +++ b/ui/src/components/interrupt-handoff/InterruptHandoffViews.test.tsx @@ -0,0 +1,258 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + AssigneeChip, + AssigneeRunningBanner, + ComposerHandoffPreviewRow, + ComposerMentionCoach, + HandoffWakeRow, + InterruptAssignConfirm, + PauseAffectsSummaryView, + RunStatusBadge, + type HandoffChipResolvers, +} from "./InterruptHandoffViews"; +import { + computeComposerHandoffPreview, + computePauseAffectsSummary, + describeReassignInterrupt, +} from "../../lib/interrupt-handoff"; + +const resolvers: HandoffChipResolvers = { + agentMap: new Map([ + ["agent-qa", { name: "QA", icon: null }], + ["agent-claude", { name: "ClaudeCoder", icon: null }], + ]), + resolveUserLabel: (id) => (id === "user-board" ? "Riley Board" : null), + currentUserId: "user-board", +}; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +let activeRoot: Root | null = null; +let activeHost: HTMLDivElement | null = null; + +function mount(node: ReactNode): HTMLDivElement { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + act(() => { + root.render(node); + }); + activeRoot = root; + activeHost = host; + return host; +} + +afterEach(() => { + if (activeRoot) { + const root = activeRoot; + act(() => root.unmount()); + } + activeHost?.remove(); + activeRoot = null; + activeHost = null; +}); + +describe("AssigneeChip", () => { + it("renders an agent chip with a screen-reader 'Agent' prefix", () => { + const host = mount(); + const chip = host.querySelector("[data-testid='handoff-assignee-chip']")!; + expect(chip.dataset.kind).toBe("agent"); + expect(chip.textContent).toContain("Agent"); + expect(chip.textContent).toContain("QA"); + }); + + it("renders a user chip with '(you)' and a 'User' prefix — never an agent look", () => { + const host = mount(); + const chip = host.querySelector("[data-testid='handoff-assignee-chip']")!; + expect(chip.dataset.kind).toBe("user"); + expect(chip.textContent).toContain("User"); + expect(chip.textContent).toContain("Riley Board (you)"); + }); + + it("renders unassigned as italic text, not a chip", () => { + const host = mount(); + const chip = host.querySelector("[data-testid='handoff-assignee-chip']")!; + expect(chip.dataset.kind).toBe("unassigned"); + expect(chip.textContent).toContain("Unassigned"); + }); +}); + +describe("HandoffWakeRow", () => { + it("A. agent destination = queued wake", () => { + const host = mount( + , + ); + const row = host.querySelector("[data-testid='handoff-wake-row']")!; + expect(row.dataset.kind).toBe("agent_wake"); + expect(row.textContent).toContain("queued for QA"); + expect(row.textContent).toContain("interrupted run attached"); + }); + + it("B. user destination = no wake", () => { + const host = mount(); + const row = host.querySelector("[data-testid='handoff-wake-row']")!; + expect(row.dataset.kind).toBe("user_handoff"); + expect(row.textContent).toContain("not created"); + }); + + it("C. unassigned = no agent selected", () => { + const host = mount(); + const row = host.querySelector("[data-testid='handoff-wake-row']")!; + expect(row.dataset.kind).toBe("unassigned"); + expect(row.textContent).toContain("no agent selected"); + }); +}); + +describe("RunStatusBadge", () => { + it("shows amber 'interrupted' for an operator interrupt", () => { + const host = mount(); + const badge = host.querySelector("[data-testid='run-status-badge']")!; + expect(badge.textContent).toContain("interrupted"); + expect(badge.className).toContain("amber"); + expect(badge.dataset.interrupted).toBe("true"); + }); + + it("shows muted 'cancelled' for a plain cancel", () => { + const host = mount(); + const badge = host.querySelector("[data-testid='run-status-badge']")!; + expect(badge.textContent).toContain("cancelled"); + expect(badge.className).toContain("muted"); + }); +}); + +describe("ComposerHandoffPreviewRow", () => { + it("renders the interrupt+handoff intent with an agent chip", () => { + const preview = computeComposerHandoffPreview({ + reassignTarget: "agent:agent-qa", + currentAssigneeValue: "agent:agent-claude", + hasActiveRun: true, + bodyHasAgentMention: false, + plainNameCandidate: null, + }); + const host = mount(); + const row = host.querySelector("[data-testid='composer-handoff-preview']")!; + expect(row.dataset.kind).toBe("interrupt_handoff_agent"); + expect(row.textContent).toContain("Interrupt current run, hand off to"); + expect(row.textContent).toContain("QA"); + }); + + it("renders the amber plain-text warning with no chip", () => { + const preview = computeComposerHandoffPreview({ + reassignTarget: "agent:agent-claude", + currentAssigneeValue: "agent:agent-claude", + hasActiveRun: true, + bodyHasAgentMention: false, + plainNameCandidate: { agentId: "agent-qa", matchedText: "QA" }, + }); + const host = mount(); + const row = host.querySelector("[data-testid='composer-handoff-preview']")!; + expect(row.dataset.kind).toBe("plain_text_only"); + expect(row.className).toContain("amber"); + expect(host.querySelector("[data-testid='handoff-assignee-chip']")).toBeNull(); + }); + + it("renders nothing for the 'none' state", () => { + const preview = computeComposerHandoffPreview({ + reassignTarget: "agent:agent-claude", + currentAssigneeValue: "agent:agent-claude", + hasActiveRun: true, + bodyHasAgentMention: false, + plainNameCandidate: null, + }); + const host = mount(); + expect(host.querySelector("[data-testid='composer-handoff-preview']")).toBeNull(); + }); +}); + +describe("ComposerMentionCoach", () => { + it("offers an Insert mention action and is dismissible", () => { + const onInsert = vi.fn(); + const onDismiss = vi.fn(); + const host = mount( + , + ); + const insertBtn = host.querySelector("[aria-label^='Insert mention for QA']")!; + act(() => insertBtn.click()); + expect(onInsert).toHaveBeenCalledOnce(); + const dismissBtn = host.querySelector("[aria-label='Dismiss suggestion']")!; + act(() => dismissBtn.click()); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); + +describe("AssigneeRunningBanner", () => { + it("announces the interrupt as a status with the running agent name", () => { + const host = mount( + , + ); + const banner = host.querySelector("[data-testid='assignee-running-banner']")!; + expect(banner.getAttribute("role")).toBe("status"); + expect(banner.textContent).toContain("ClaudeCoder is running"); + }); +}); + +describe("InterruptAssignConfirm", () => { + it("renders the target chip and wires confirm/cancel", () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const host = mount( + , + ); + expect(host.querySelector("[data-testid='handoff-assignee-chip']")?.textContent).toContain("QA"); + const confirmBtn = host.querySelector("[data-testid='interrupt-assign-confirm-action']")!; + act(() => confirmBtn.click()); + expect(onConfirm).toHaveBeenCalledOnce(); + const cancelBtn = Array.from(host.querySelectorAll("button")).find( + (b) => b.textContent === "Cancel", + )!; + act(() => cancelBtn.click()); + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); + +describe("PauseAffectsSummaryView", () => { + it("renders only non-zero buckets with counts", () => { + const summary = computePauseAffectsSummary([ + { assigneeAgentId: "a1", assigneeUserId: null, activeRun: { status: "running" } }, + { assigneeAgentId: null, assigneeUserId: "u1", activeRun: null }, + ]); + const host = mount(); + expect(host.querySelector("[data-bucket='live_runs']")?.textContent).toContain("1"); + expect(host.querySelector("[data-bucket='human_owned']")?.textContent).toContain("1"); + // Empty buckets are hidden. + expect(host.querySelector("[data-bucket='static']")).toBeNull(); + expect(host.querySelector("[data-testid='pause-nothing-live']")).toBeNull(); + }); + + it("shows the 'Nothing live to pause' status when no run is live", () => { + const summary = computePauseAffectsSummary([ + { assigneeAgentId: null, assigneeUserId: "u1", activeRun: null }, + ]); + const host = mount(); + const note = host.querySelector("[data-testid='pause-nothing-live']")!; + expect(note.getAttribute("role")).toBe("status"); + expect(note.textContent).toContain("Nothing live to pause"); + }); +}); diff --git a/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx b/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx new file mode 100644 index 00000000..38b14e34 --- /dev/null +++ b/ui/src/components/interrupt-handoff/InterruptHandoffViews.tsx @@ -0,0 +1,349 @@ +import { AlertTriangle, Info, PauseCircle, User, X } from "lucide-react"; +import { cn } from "../../lib/utils"; +import { AgentIcon } from "../AgentIconPicker"; +import { + classifyAssigneeHandoff, + resolveRunStatusPresentation, + type ComposerHandoffPreview, + type PauseAffectsSummary, + type PlainAgentNameCandidate, + type ReassignInterruptCopy, + type TimelineAssigneeLike, +} from "../../lib/interrupt-handoff"; + +/** + * Presentational views for the interrupt-handoff UX clarity surfaces (PAP-10669). + * All logic lives in `lib/interrupt-handoff.ts`; these components only render it, + * so they can be exercised in isolation by component tests and Storybook. + */ + +export interface HandoffAgentLike { + name: string; + icon?: string | null; +} + +export interface HandoffChipResolvers { + agentMap?: ReadonlyMap | null; + resolveUserLabel?: (userId: string) => string | null; + currentUserId?: string | null; +} + +function agentName(agentId: string, resolvers: HandoffChipResolvers): string { + return resolvers.agentMap?.get(agentId)?.name ?? agentId.slice(0, 8); +} + +function agentIcon(agentId: string, resolvers: HandoffChipResolvers): string | null { + return resolvers.agentMap?.get(agentId)?.icon ?? null; +} + +function userLabel(userId: string, resolvers: HandoffChipResolvers): string { + const label = resolvers.resolveUserLabel?.(userId) ?? null; + const base = label ?? "Board"; + return resolvers.currentUserId && resolvers.currentUserId === userId ? `${base} (you)` : base; +} + +const CHIP_CLASS = + "inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 px-2 py-0.5 text-xs"; + +/** A labelled assignee chip — agent, user, or unassigned — that never lets a + * user owner read like an agent. */ +export function AssigneeChip({ + assignee, + resolvers, + className, +}: { + assignee: TimelineAssigneeLike; + resolvers: HandoffChipResolvers; + className?: string; +}) { + if (assignee.agentId) { + return ( + + Agent + + {agentName(assignee.agentId, resolvers)} + + ); + } + if (assignee.userId) { + return ( + + User + + {userLabel(assignee.userId, resolvers)} + + ); + } + return ( + + No assignee — + Unassigned + + ); +} + +/** The "Wake" sub-row that makes each handoff state self-describing: a queued + * agent wake, a board-user handoff with no wake, or no agent selected. */ +export function HandoffWakeRow({ + to, + resolvers, + interruptedRunAttached = false, +}: { + to: TimelineAssigneeLike; + resolvers: HandoffChipResolvers; + interruptedRunAttached?: boolean; +}) { + const info = classifyAssigneeHandoff(to, { + agentName: to.agentId ? agentName(to.agentId, resolvers) : null, + interruptedRunAttached, + }); + return ( +
+ Wake + + {info.wakeText} + +
+ ); +} + +/** Run status text that distinguishes an intentional operator interrupt + * (amber "interrupted") from a generic muted "cancelled". */ +export function RunStatusBadge({ + status, + operatorInterrupted = false, + className, +}: { + status: string; + operatorInterrupted?: boolean; + className?: string; +}) { + const p = resolveRunStatusPresentation(status, { operatorInterrupted }); + return ( + + {p.label} + {p.srHint ? — {p.srHint} : null} + + ); +} + +function PreviewChip({ + chip, + resolvers, +}: { + chip: NonNullable; + resolvers: HandoffChipResolvers; +}) { + return ( + + ); +} + +/** One-line interpretation of what submitting the comment will durably do. */ +export function ComposerHandoffPreviewRow({ + preview, + resolvers, +}: { + preview: ComposerHandoffPreview; + resolvers: HandoffChipResolvers; +}) { + if (preview.kind === "none") return null; + return ( +
+ {preview.text} + {preview.chip ? : null} + {preview.suffix ? {preview.suffix} : null} +
+ ); +} + +/** Inline coach shown when the body contains a plain agent name without a chip, + * offering a one-click upgrade to a real mention. */ +export function ComposerMentionCoach({ + candidate, + agentDisplayName, + onInsert, + onDismiss, +}: { + candidate: PlainAgentNameCandidate; + agentDisplayName: string; + onInsert: () => void; + onDismiss: () => void; +}) { + return ( +
+ + + Did you mean @{candidate.matchedText}? Plain text won't + notify or assign an agent. + + + +
+ ); +} + +/** Live banner shown at the top of the assignee picker while a run is in flight, + * warning that reassigning will interrupt it. (design surface 2) */ +export function AssigneeRunningBanner({ + copy, + className, +}: { + copy: ReassignInterruptCopy; + className?: string; +}) { + return ( +
+ + {copy.banner} +
+ ); +} + +/** "Interrupt & assign" confirm step shown when an operator picks a different + * target while a run is live. (design surface 2) */ +export function InterruptAssignConfirm({ + copy, + to, + resolvers, + onConfirm, + onCancel, +}: { + copy: ReassignInterruptCopy; + /** The target the operator selected. */ + to: TimelineAssigneeLike; + resolvers: HandoffChipResolvers; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( +
+
+ +
+

{copy.confirmTitle}

+

+ Hand off to + +

+
+
+
+ + +
+
+ ); +} + +/** "What this affects" bucket summary for the pause/hold dialog. (design surface 4) */ +export function PauseAffectsSummaryView({ + summary, + className, +}: { + summary: PauseAffectsSummary; + className?: string; +}) { + const visibleBuckets = summary.buckets.filter((bucket) => bucket.count > 0); + return ( +
+
+ + What this affects +
+ {summary.nothingLive ? ( +

+ Nothing live to pause — no agent run is in flight or queued. This records a hold so new work + won't start until you resume. +

+ ) : null} + {visibleBuckets.length > 0 ? ( +
    + {visibleBuckets.map((bucket) => ( +
  • + {bucket.label}: + {bucket.count} + — {bucket.detail} +
  • + ))} +
+ ) : ( +

No tasks are affected.

+ )} +
+ ); +} diff --git a/ui/src/lib/interrupt-handoff.test.ts b/ui/src/lib/interrupt-handoff.test.ts new file mode 100644 index 00000000..45c1ff20 --- /dev/null +++ b/ui/src/lib/interrupt-handoff.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; +import { buildAgentMentionHref } from "@paperclipai/shared"; +import { + bodyHasAgentMention, + classifyAssigneeHandoff, + computePauseAffectsSummary, + computeComposerHandoffPreview, + describeReassignInterrupt, + extractAgentMentionIds, + findPlainAgentNameCandidate, + isOperatorInterruptedRun, + resolveRunStatusPresentation, + type PauseAffectsIssueLike, +} from "./interrupt-handoff"; + +const QA_ID = "agent-qa-1111"; +const QA_HREF = buildAgentMentionHref(QA_ID, null); +const qaMention = `[@QA](${QA_HREF})`; + +describe("isOperatorInterruptedRun", () => { + it("detects operator interrupts via errorCode", () => { + expect(isOperatorInterruptedRun(null, "operator_interrupted")).toBe(true); + }); + + it("detects operator interrupts via resultJson flag", () => { + expect(isOperatorInterruptedRun({ operatorInterrupted: true })).toBe(true); + expect(isOperatorInterruptedRun({ interruptionSource: "issue_comment_interrupt" })).toBe(true); + }); + + it("does not flag ordinary cancels or failures", () => { + expect(isOperatorInterruptedRun({ stopReason: "cancelled" }, "cancelled")).toBe(false); + expect(isOperatorInterruptedRun(null, "claude_exit_143")).toBe(false); + expect(isOperatorInterruptedRun(null)).toBe(false); + }); +}); + +describe("resolveRunStatusPresentation", () => { + it("renders an operator-interrupted cancel as amber 'interrupted'", () => { + const p = resolveRunStatusPresentation("cancelled", { operatorInterrupted: true }); + expect(p.label).toBe("interrupted"); + expect(p.className).toContain("amber"); + expect(p.srHint).toMatch(/board comment/); + }); + + it("renders a plain cancel as muted 'cancelled'", () => { + const p = resolveRunStatusPresentation("cancelled"); + expect(p.label).toBe("cancelled"); + expect(p.className).toContain("muted"); + expect(p.srHint).toBeNull(); + }); + + it("leaves failed/succeeded untouched", () => { + expect(resolveRunStatusPresentation("failed").className).toContain("red"); + expect(resolveRunStatusPresentation("timed_out").label).toBe("timed out"); + }); +}); + +describe("structured mention vs plain text", () => { + it("extracts agent ids only from structured agent:// links", () => { + expect(extractAgentMentionIds(`hey ${qaMention} please look`)).toEqual([QA_ID]); + expect(bodyHasAgentMention(`hey ${qaMention}`)).toBe(true); + }); + + it("treats a plain QA name as NOT a mention", () => { + expect(bodyHasAgentMention("QA you take the screenshot")).toBe(false); + expect(extractAgentMentionIds("ask QA to confirm")).toEqual([]); + }); + + it("flags a plain agent-name candidate for the coach", () => { + const candidate = findPlainAgentNameCandidate("ask QA to confirm", [ + { agentId: QA_ID, name: "QA" }, + ]); + expect(candidate?.agentId).toBe(QA_ID); + expect(candidate?.matchedText).toBe("QA"); + }); + + it("does not flag a name that is already a structured chip", () => { + const candidate = findPlainAgentNameCandidate(`hey ${qaMention} thanks`, [ + { agentId: QA_ID, name: "QA" }, + ]); + expect(candidate).toBeNull(); + }); + + it("matches by role as well as display name", () => { + const candidate = findPlainAgentNameCandidate("can the reviewer check this", [ + { agentId: "agent-x", name: "Casey", role: "reviewer" }, + ]); + expect(candidate?.matchedText).toBe("reviewer"); + }); +}); + +describe("computeComposerHandoffPreview", () => { + const base = { + currentAssigneeValue: "agent:claude-1", + hasActiveRun: true, + bodyHasAgentMention: false, + plainNameCandidate: null, + }; + + it("A. reassign to agent with active run = interrupt + handoff", () => { + const p = computeComposerHandoffPreview({ ...base, reassignTarget: `agent:${QA_ID}` }); + expect(p.kind).toBe("interrupt_handoff_agent"); + expect(p.chip).toEqual({ kind: "agent", id: QA_ID }); + expect(p.tone).toBe("neutral"); + }); + + it("reassign to agent with no active run = wake", () => { + const p = computeComposerHandoffPreview({ + ...base, + hasActiveRun: false, + reassignTarget: `agent:${QA_ID}`, + }); + expect(p.kind).toBe("wake_agent"); + }); + + it("B. reassign to user = handoff, no agent wake", () => { + const p = computeComposerHandoffPreview({ ...base, reassignTarget: "user:user-board" }); + expect(p.kind).toBe("user_handoff"); + expect(p.chip).toEqual({ kind: "user", id: "user-board" }); + expect(p.suffix).toMatch(/no agent/i); + }); + + it("clearing the assignee reads as no agent wake", () => { + const p = computeComposerHandoffPreview({ ...base, reassignTarget: "__none__" }); + expect(p.kind).toBe("clear_assignee"); + expect(p.text).toMatch(/no agent/i); + }); + + it("no reassign + structured mention = notify agent", () => { + const p = computeComposerHandoffPreview({ + ...base, + reassignTarget: base.currentAssigneeValue, + bodyHasAgentMention: true, + mentionedAgentId: QA_ID, + }); + expect(p.kind).toBe("notify_agent"); + expect(p.chip).toEqual({ kind: "agent", id: QA_ID }); + }); + + it("C. no reassign + plain name only = amber warning, no wake", () => { + const p = computeComposerHandoffPreview({ + ...base, + reassignTarget: base.currentAssigneeValue, + plainNameCandidate: { agentId: QA_ID, matchedText: "QA" }, + }); + expect(p.kind).toBe("plain_text_only"); + expect(p.tone).toBe("warn"); + expect(p.chip).toBeUndefined(); + }); + + it("nothing notable = hidden preview", () => { + const p = computeComposerHandoffPreview({ + ...base, + reassignTarget: base.currentAssigneeValue, + }); + expect(p.kind).toBe("none"); + }); +}); + +describe("classifyAssigneeHandoff", () => { + it("A. to agent = queued wake", () => { + const info = classifyAssigneeHandoff({ agentId: QA_ID, userId: null }, { agentName: "QA" }); + expect(info.kind).toBe("agent_wake"); + expect(info.wakeText).toMatch(/queued for QA/); + }); + + it("agent wake notes attached interrupted run when present", () => { + const info = classifyAssigneeHandoff( + { agentId: QA_ID, userId: null }, + { agentName: "QA", interruptedRunAttached: true }, + ); + expect(info.wakeText).toMatch(/interrupted run attached/); + }); + + it("B. to user = no wake, board handoff", () => { + const info = classifyAssigneeHandoff({ agentId: null, userId: "user-board" }); + expect(info.kind).toBe("user_handoff"); + expect(info.wakeText).toMatch(/not created/); + }); + + it("C. unassigned = no wake, needs explicit agent", () => { + const info = classifyAssigneeHandoff({ agentId: null, userId: null }); + expect(info.kind).toBe("unassigned"); + expect(info.wakeText).toMatch(/no agent selected/); + }); +}); + +describe("describeReassignInterrupt", () => { + it("names the running agent in the banner and confirm", () => { + const copy = describeReassignInterrupt({ runningAgentName: "ClaudeCoder" }); + expect(copy.banner).toBe("ClaudeCoder is running — changing the assignee will interrupt this run."); + expect(copy.confirmAction).toBe("Interrupt & assign"); + expect(copy.cancelAction).toBe("Cancel"); + }); + + it("falls back to a generic subject when no name is known", () => { + expect(describeReassignInterrupt().banner).toMatch(/^An agent is running/); + expect(describeReassignInterrupt({ runningAgentName: " " }).banner).toMatch(/^An agent is running/); + }); +}); + +describe("computePauseAffectsSummary", () => { + const issue = (over: Partial): PauseAffectsIssueLike => ({ + assigneeAgentId: null, + assigneeUserId: null, + activeRun: null, + ...over, + }); + const bucket = (summary: ReturnType, key: string) => + summary.buckets.find((b) => b.key === key)!; + + it("partitions affected issues into disjoint buckets", () => { + const summary = computePauseAffectsSummary([ + issue({ assigneeAgentId: "a1", activeRun: { status: "running" } }), + issue({ assigneeAgentId: "a2", activeRun: { status: "queued" } }), + issue({ assigneeAgentId: "a3" }), + issue({ assigneeUserId: "u1" }), + issue({}), + ]); + expect(bucket(summary, "live_runs").count).toBe(1); + expect(bucket(summary, "queued_wakes").count).toBe(1); + expect(bucket(summary, "agent_owned").count).toBe(1); + expect(bucket(summary, "human_owned").count).toBe(1); + expect(bucket(summary, "static").count).toBe(1); + expect(summary.affectedIssueCount).toBe(5); + expect(summary.nothingLive).toBe(false); + }); + + it("ignores skipped issues", () => { + const summary = computePauseAffectsSummary([ + issue({ assigneeAgentId: "a1", activeRun: { status: "running" }, skipped: true }), + issue({ assigneeUserId: "u1" }), + ]); + expect(summary.affectedIssueCount).toBe(1); + expect(bucket(summary, "live_runs").count).toBe(0); + expect(bucket(summary, "human_owned").count).toBe(1); + }); + + it("flags nothingLive when no run is live or queued", () => { + const summary = computePauseAffectsSummary([issue({ assigneeUserId: "u1" }), issue({})]); + expect(summary.nothingLive).toBe(true); + }); + + it("an active run wins over the issue's owner bucket", () => { + const summary = computePauseAffectsSummary([ + issue({ assigneeAgentId: "a1", activeRun: { status: "running" } }), + ]); + expect(bucket(summary, "live_runs").count).toBe(1); + expect(bucket(summary, "agent_owned").count).toBe(0); + }); +}); diff --git a/ui/src/lib/interrupt-handoff.ts b/ui/src/lib/interrupt-handoff.ts new file mode 100644 index 00000000..6d71afc6 --- /dev/null +++ b/ui/src/lib/interrupt-handoff.ts @@ -0,0 +1,439 @@ +import { parseAgentMentionHref } from "@paperclipai/shared"; + +/** + * Shared logic for the "interrupt handoff" UX clarity surfaces (PAP-10669). + * + * The single rule every surface enforces: an *agent* appearance — agent chip, + * "handed to " copy, a queued wake — is reserved for either (a) a durable + * `assigneeAgentId` mutation, or (b) a structured agent mention (`agent://`). + * Plain text such as `QA` or `please get QA on this` never implies an agent wake. + * + * Backend interrupt semantics (see server/src/routes/issues.ts + * `operatorInterruptCancelOptions`): an operator-triggered interrupt cancels the + * active run with `errorCode: "operator_interrupted"` and + * `resultJson.operatorInterrupted = true` / + * `resultJson.interruptionSource = "issue_comment_interrupt"`. + */ + +// --- Run interruption --------------------------------------------------------- + +/** + * Whether a run's terminal record reflects an intentional operator interrupt + * (a board comment that cancelled the active run) rather than an unexplained + * failure or a plain control-plane cancel. + */ +export function isOperatorInterruptedRun( + resultJson: Record | null | undefined, + errorCode?: string | null, +): boolean { + if (errorCode === "operator_interrupted") return true; + if (!resultJson || typeof resultJson !== "object") return false; + if (resultJson.operatorInterrupted === true) return true; + return resultJson.interruptionSource === "issue_comment_interrupt"; +} + +export function runStatusClassName(status: string): string { + switch (status) { + case "succeeded": + return "text-green-700 dark:text-green-300"; + case "failed": + case "error": + return "text-red-700 dark:text-red-300"; + case "timed_out": + return "text-orange-700 dark:text-orange-300"; + case "running": + return "text-cyan-700 dark:text-cyan-300"; + case "queued": + case "pending": + return "text-amber-700 dark:text-amber-300"; + case "cancelled": + return "text-muted-foreground"; + default: + return "text-foreground"; + } +} + +export interface RunStatusPresentation { + label: string; + className: string; + /** Screen-reader-only clarifier, or null. */ + srHint: string | null; +} + +/** + * Resolve the visible run status. A board-triggered interrupt reads as + * "interrupted" (amber, operator-intentional) instead of a muted "cancelled" + * that looks like an adapter failure. + */ +export function resolveRunStatusPresentation( + status: string, + opts: { operatorInterrupted?: boolean } = {}, +): RunStatusPresentation { + if (status === "cancelled" && opts.operatorInterrupted) { + return { + label: "interrupted", + className: "text-amber-700 dark:text-amber-300", + srHint: "interrupted by board comment", + }; + } + return { + label: status === "timed_out" ? "timed out" : status.replace(/_/g, " "), + className: runStatusClassName(status), + srHint: null, + }; +} + +// --- Structured mention vs plain text ----------------------------------------- + +const MARKDOWN_LINK_RE = /\[[^\]]*\]\(([^)]*)\)/g; + +/** Ordered list of agent ids referenced via structured `agent://` mentions. */ +export function extractAgentMentionIds(body: string): string[] { + const ids: string[] = []; + if (!body) return ids; + for (const match of body.matchAll(MARKDOWN_LINK_RE)) { + const href = match[1] ?? ""; + const parsed = parseAgentMentionHref(href); + if (parsed?.agentId && !ids.includes(parsed.agentId)) { + ids.push(parsed.agentId); + } + } + return ids; +} + +export function bodyHasAgentMention(body: string): boolean { + return extractAgentMentionIds(body).length > 0; +} + +/** Strip every markdown link so chip labels/hrefs are not mistaken for plain text. */ +function plainTextOutsideLinks(body: string): string { + return body.replace(MARKDOWN_LINK_RE, " "); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export interface HandoffAgentMention { + agentId: string; + name: string; + /** Optional role label (e.g. "QA"). */ + role?: string | null; +} + +export interface PlainAgentNameCandidate { + agentId: string; + /** The token (agent name or role) that matched in the body. */ + matchedText: string; +} + +/** + * Find a plain-text token in `body` that names a known agent (by display name or + * role) but is *not* a structured `agent://` mention. This is the signal the + * composer coach uses to offer an "Insert mention" upgrade. Returns the + * highest-confidence (longest-token) match, or null. + */ +export function findPlainAgentNameCandidate( + body: string, + mentions: readonly HandoffAgentMention[], +): PlainAgentNameCandidate | null { + if (!body.trim() || mentions.length === 0) return null; + const text = plainTextOutsideLinks(body); + let best: PlainAgentNameCandidate | null = null; + + for (const mention of mentions) { + const tokens = [mention.name, mention.role ?? ""].filter((t) => t && t.trim().length >= 2); + for (const token of tokens) { + const re = new RegExp(`(? best.matchedText.length) { + best = { agentId: mention.agentId, matchedText: token }; + } + } + } + } + + return best; +} + +// --- Composer interpretation preview ------------------------------------------ + +export type HandoffPreviewTone = "neutral" | "warn"; + +export type ComposerHandoffPreviewKind = + | "interrupt_handoff_agent" + | "wake_agent" + | "user_handoff" + | "clear_assignee" + | "notify_agent" + | "plain_text_only" + | "none"; + +export interface ComposerHandoffPreview { + kind: ComposerHandoffPreviewKind; + tone: HandoffPreviewTone; + /** Copy rendered before the optional chip. */ + text: string; + /** Copy rendered after the optional chip. */ + suffix?: string; + /** Entity to render as a mini chip, if any. */ + chip?: { kind: "agent" | "user"; id: string }; +} + +function parseReassignValue(value: string): { kind: "agent" | "user" | "none"; id: string | null } { + if (!value || value === "__none__") return { kind: "none", id: null }; + if (value.startsWith("agent:")) { + const id = value.slice("agent:".length); + return { kind: "agent", id: id || null }; + } + if (value.startsWith("user:")) { + const id = value.slice("user:".length); + return { kind: "user", id: id || null }; + } + return { kind: "none", id: null }; +} + +export interface ComposerHandoffPreviewInput { + /** Current picker value, e.g. "agent:", "user:", "__none__", "". */ + reassignTarget: string; + /** The issue's current assignee value in the same encoding. */ + currentAssigneeValue: string; + /** Whether an agent run is currently in flight on this issue. */ + hasActiveRun: boolean; + /** Whether the comment body contains a structured agent mention. */ + bodyHasAgentMention: boolean; + /** First agent id structurally mentioned in the body, if any. */ + mentionedAgentId?: string | null; + /** A plain-text agent-name candidate detected in the body, if any. */ + plainNameCandidate?: PlainAgentNameCandidate | null; +} + +/** + * Compute the one-line interpretation of what submitting this comment will + * durably do. This is the composer footer preview (design surface 1c) and the + * core of the agent-vs-user disambiguation. + */ +export function computeComposerHandoffPreview( + input: ComposerHandoffPreviewInput, +): ComposerHandoffPreview { + const hasReassignment = input.reassignTarget !== input.currentAssigneeValue; + + if (hasReassignment) { + const target = parseReassignValue(input.reassignTarget); + if (target.kind === "agent" && target.id) { + return input.hasActiveRun + ? { + kind: "interrupt_handoff_agent", + tone: "neutral", + text: "Interrupt current run, hand off to", + chip: { kind: "agent", id: target.id }, + } + : { + kind: "wake_agent", + tone: "neutral", + text: "Wake", + chip: { kind: "agent", id: target.id }, + }; + } + if (target.kind === "user" && target.id) { + return { + kind: "user_handoff", + tone: "neutral", + text: "Hand off to", + chip: { kind: "user", id: target.id }, + suffix: "— no agent will be notified", + }; + } + // Cleared / no target chosen for the mutation. + return { + kind: "clear_assignee", + tone: "neutral", + text: "Clear assignee — no agent will be notified", + }; + } + + if (input.bodyHasAgentMention) { + return { + kind: "notify_agent", + tone: "neutral", + text: "Notify", + chip: input.mentionedAgentId ? { kind: "agent", id: input.mentionedAgentId } : undefined, + suffix: input.mentionedAgentId ? undefined : "the mentioned agent", + }; + } + + if (input.plainNameCandidate) { + return { + kind: "plain_text_only", + tone: "warn", + text: "No agent will be notified. Use @ to mention an agent.", + }; + } + + return { kind: "none", tone: "neutral", text: "" }; +} + +// --- Timeline assignee handoff / wake classification -------------------------- + +export type AssigneeHandoffKind = "agent_wake" | "user_handoff" | "unassigned"; + +export interface TimelineAssigneeLike { + agentId: string | null; + userId: string | null; +} + +export interface AssigneeHandoffInfo { + kind: AssigneeHandoffKind; + /** Copy rendered after the "Wake" label in the activity card. */ + wakeText: string; +} + +/** + * Classify the wake outcome of an assignee change, given the *destination* + * assignee. This drives the timeline "Wake" sub-row so the three required + * states are self-describing in the activity log. + */ +export function classifyAssigneeHandoff( + to: TimelineAssigneeLike, + opts: { agentName?: string | null; interruptedRunAttached?: boolean } = {}, +): AssigneeHandoffInfo { + if (to.agentId) { + const who = opts.agentName ?? "the assigned agent"; + const suffix = opts.interruptedRunAttached ? " (interrupted run attached)" : ""; + return { kind: "agent_wake", wakeText: `queued for ${who}${suffix}` }; + } + if (to.userId) { + return { + kind: "user_handoff", + wakeText: "not created — this is a handoff to a board user", + }; + } + return { + kind: "unassigned", + wakeText: "not created — no agent selected. Mention @agent or pick an assignee to dispatch.", + }; +} + +// --- Standalone assignee picker interrupt (PAP-10675, design surface 2) ------- + +export interface ReassignInterruptCopy { + /** `role=status` banner shown while a run is live and the picker is open. */ + banner: string; + /** Heading for the "Interrupt & assign" confirm step. */ + confirmTitle: string; + /** Primary action label for the confirm step. */ + confirmAction: string; + /** Label for backing out of the confirm step. */ + cancelAction: string; +} + +/** + * Copy for the assignee picker's live-run states: a banner warning that an + * in-flight run will be interrupted, and the confirm step shown when the + * operator picks a *different* target mid-run. Naming the running agent keeps + * the interrupt consequence concrete instead of a bare "are you sure". + */ +export function describeReassignInterrupt(opts: { runningAgentName?: string | null } = {}): ReassignInterruptCopy { + const who = opts.runningAgentName?.trim() || "An agent"; + return { + banner: `${who} is running — changing the assignee will interrupt this run.`, + confirmTitle: "Interrupt the current run?", + confirmAction: "Interrupt & assign", + cancelAction: "Cancel", + }; +} + +// --- Pause/hold "What this affects" buckets (PAP-10675, design surface 4) ------ + +export type PauseAffectsBucketKey = + | "live_runs" + | "queued_wakes" + | "agent_owned" + | "human_owned" + | "static"; + +export interface PauseAffectsIssueLike { + assigneeAgentId: string | null; + assigneeUserId: string | null; + activeRun: { status: "queued" | "running" } | null; + skipped?: boolean; +} + +export interface PauseAffectsBucket { + key: PauseAffectsBucketKey; + label: string; + count: number; + /** One-line clarifier of what pausing does to this bucket. */ + detail: string; +} + +export interface PauseAffectsSummary { + buckets: PauseAffectsBucket[]; + /** Total non-skipped issues the operation affects. */ + affectedIssueCount: number; + /** True when no run is live or queued — there is nothing to interrupt. */ + nothingLive: boolean; +} + +const PAUSE_BUCKET_LABEL: Record = { + live_runs: "Live agent runs", + queued_wakes: "Queued wakes", + agent_owned: "Agent-owned", + human_owned: "Human-owned", + static: "Static", +}; + +const PAUSE_BUCKET_DETAIL: Record = { + live_runs: "interrupted now, re-queued when you resume", + queued_wakes: "held — they won't start until you resume", + agent_owned: "assigned to an agent; no run is live", + human_owned: "owned by a board user; pausing won't notify them", + static: "no assignee; nothing was going to run", +}; + +/** + * Partition the issues an operation affects into the five disjoint buckets the + * pause dialog summarises. Each non-skipped issue lands in exactly one bucket: + * a live run, a queued wake, or — when nothing is in flight — by owner kind. + */ +export function computePauseAffectsSummary( + issues: readonly PauseAffectsIssueLike[], +): PauseAffectsSummary { + const counts: Record = { + live_runs: 0, + queued_wakes: 0, + agent_owned: 0, + human_owned: 0, + static: 0, + }; + let affectedIssueCount = 0; + + for (const issue of issues) { + if (issue.skipped) continue; + affectedIssueCount += 1; + if (issue.activeRun?.status === "running") counts.live_runs += 1; + else if (issue.activeRun?.status === "queued") counts.queued_wakes += 1; + else if (issue.assigneeAgentId) counts.agent_owned += 1; + else if (issue.assigneeUserId) counts.human_owned += 1; + else counts.static += 1; + } + + const order: PauseAffectsBucketKey[] = [ + "live_runs", + "queued_wakes", + "agent_owned", + "human_owned", + "static", + ]; + + return { + buckets: order.map((key) => ({ + key, + label: PAUSE_BUCKET_LABEL[key], + count: counts[key], + detail: PAUSE_BUCKET_DETAIL[key], + })), + affectedIssueCount, + nothingLive: counts.live_runs === 0 && counts.queued_wakes === 0, + }; +} diff --git a/ui/src/lib/issue-chat-messages.test.ts b/ui/src/lib/issue-chat-messages.test.ts index bdf07d3a..786e1b3a 100644 --- a/ui/src/lib/issue-chat-messages.test.ts +++ b/ui/src/lib/issue-chat-messages.test.ts @@ -325,6 +325,39 @@ describe("buildIssueChatMessages", () => { }); }); + it("flags an operator-interrupted historical run so the timeline can read 'interrupted'", () => { + const messages = buildIssueChatMessages({ + comments: [], + timelineEvents: [], + linkedRuns: [ + { + runId: "run-int", + status: "cancelled", + agentId: "agent-1", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + startedAt: new Date("2026-04-06T12:01:00.000Z"), + finishedAt: new Date("2026-04-06T12:02:00.000Z"), + resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt" }, + }, + { + runId: "run-plain", + status: "cancelled", + agentId: "agent-1", + createdAt: new Date("2026-04-06T12:03:00.000Z"), + startedAt: new Date("2026-04-06T12:03:00.000Z"), + finishedAt: new Date("2026-04-06T12:04:00.000Z"), + resultJson: null, + }, + ], + liveRuns: [], + }); + + const interrupted = messages.find((message) => message.id === "run-assistant:run-int"); + const plain = messages.find((message) => message.id === "run-assistant:run-plain"); + expect(interrupted?.metadata?.custom).toMatchObject({ runOperatorInterrupted: true }); + expect(plain?.metadata?.custom).toMatchObject({ runOperatorInterrupted: false }); + }); + it("redacts deleted comment bodies while preserving tombstone metadata", () => { const messages = buildIssueChatMessages({ comments: [ @@ -914,6 +947,39 @@ describe("buildIssueChatMessages", () => { }); }); + it("labels error-code-only operator interruptions as interrupted by board", () => { + const messages = buildIssueChatMessages({ + comments: [], + timelineEvents: [], + linkedRuns: [ + { + runId: "run-interrupted", + status: "cancelled", + agentId: "agent-1", + agentName: "CodexCoder", + createdAt: new Date("2026-04-06T12:01:00.000Z"), + startedAt: new Date("2026-04-06T12:01:00.000Z"), + finishedAt: new Date("2026-04-06T12:02:00.000Z"), + errorCode: "operator_interrupted", + resultJson: null, + }, + ], + liveRuns: [], + transcriptsByRunId: new Map([ + ["run-interrupted", [{ kind: "assistant", ts: "2026-04-06T12:01:05.000Z", text: "Working on it." }]], + ]), + hasOutputForRun: (runId) => runId === "run-interrupted", + currentUserId: "user-1", + }); + + expect(messages).toHaveLength(1); + expect(messages[0]?.metadata.custom).toMatchObject({ + chainOfThoughtLabel: "Interrupted by board after 1 minute", + runOperatorInterrupted: true, + runStatus: "cancelled", + }); + }); + it("can keep succeeded runs without transcript output for embedded run feeds", () => { const messages = buildIssueChatMessages({ comments: [], diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 258c9891..fe2d0c37 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -10,6 +10,7 @@ import type { import type { Agent, IssueComment } from "@paperclipai/shared"; import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { formatAssigneeUserLabel } from "./assignees"; +import { isOperatorInterruptedRun } from "./interrupt-handoff"; import { buildIssueThreadInteractionSummary, type IssueThreadInteraction, @@ -45,6 +46,7 @@ export interface IssueChatLinkedRun { finishedAt?: Date | string | null; hasStoredOutput?: boolean; logBytes?: number | null; + errorCode?: string | null; resultJson?: Record | null; } @@ -595,6 +597,7 @@ function runDurationLabel(run: { createdAt: Date | string; startedAt: Date | string | null; finishedAt?: Date | string | null; + errorCode?: string | null; resultJson?: Record | null; }) { const start = run.startedAt ?? run.createdAt; @@ -611,6 +614,9 @@ function runDurationLabel(run: { case "timed_out": return durationText ? `Timed out after ${durationText}` : "Run timed out"; case "cancelled": + if (isOperatorInterruptedRun(run.resultJson, run.errorCode)) { + return durationText ? `Interrupted by board after ${durationText}` : "Interrupted by board"; + } if (stopReason === "paused") { return durationText ? `Paused by board after ${durationText}` : "Paused by board"; } @@ -639,6 +645,7 @@ function createHistoricalRunMessage(run: IssueChatLinkedRun, agentMap?: Map ); return () => closePanel(); @@ -2844,6 +2847,7 @@ export function IssueDetail() { openPanel, panelChildIssues, panelIssue, + resolvedHasActiveRun, ]); const goToInboxShortcutArmedRef = useRef(false); @@ -3281,6 +3285,11 @@ export function IssueDetail() { () => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped), [treeControlPreview], ); + // "What this affects" buckets for the pause/hold dialog (design surface 4). + const pauseAffectsSummary = useMemo( + () => computePauseAffectsSummary(treeControlPreview?.issues ?? []), + [treeControlPreview], + ); const treePreviewDisplayIssues = useMemo( () => { const previewIssues = treeControlPreview?.issues ?? []; @@ -4328,6 +4337,9 @@ export function IssueDetail() {
) : treeControlPreview ? (
+ {treeControlMode === "pause" ? ( + + ) : null} {treePreviewWarnings.length > 0 ? (
{treePreviewWarnings.map((warning) => ( @@ -4397,6 +4409,7 @@ export function IssueDetail() { onAddSubIssue={openNewSubIssue} onUpdate={(data) => updateIssue.mutate(data)} inline + hasActiveRun={resolvedHasActiveRun} />
diff --git a/ui/storybook/stories/interrupt-handoff.stories.tsx b/ui/storybook/stories/interrupt-handoff.stories.tsx new file mode 100644 index 00000000..9c1f2f0c --- /dev/null +++ b/ui/storybook/stories/interrupt-handoff.stories.tsx @@ -0,0 +1,235 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ArrowRight, User } from "lucide-react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + AssigneeChip, + AssigneeRunningBanner, + ComposerHandoffPreviewRow, + ComposerMentionCoach, + HandoffWakeRow, + InterruptAssignConfirm, + PauseAffectsSummaryView, + RunStatusBadge, + type HandoffChipResolvers, +} from "@/components/interrupt-handoff/InterruptHandoffViews"; +import { + computeComposerHandoffPreview, + computePauseAffectsSummary, + describeReassignInterrupt, +} from "@/lib/interrupt-handoff"; + +/** + * Visual states for the interrupt-handoff UX clarity work (PAP-10669). Each card + * is one row of the design's state matrix: interrupted → handed to QA, + * interrupted → waiting on user, and no agent selected. + */ + +const resolvers: HandoffChipResolvers = { + agentMap: new Map([ + ["agent-claude", { name: "ClaudeCoder", icon: null }], + ["agent-qa", { name: "QA", icon: null }], + ]), + currentUserId: "user-board", + resolveUserLabel: (id) => (id === "user-board" ? "Riley Board" : null), +}; + +const claude = { agentId: "agent-claude", userId: null }; +const qa = { agentId: "agent-qa", userId: null }; +const board = { agentId: null, userId: "user-board" }; +const unassigned = { agentId: null, userId: null }; + +function ActivityCard({ + title, + state, + to, + interruptedRunAttached, + composerTarget, + composerCurrent, + composerHasActiveRun, +}: { + title: string; + state: string; + to: { agentId: string | null; userId: string | null }; + interruptedRunAttached?: boolean; + composerTarget: string; + composerCurrent: string; + composerHasActiveRun: boolean; +}) { + const preview = computeComposerHandoffPreview({ + reassignTarget: composerTarget, + currentAssigneeValue: composerCurrent, + hasActiveRun: composerHasActiveRun, + bodyHasAgentMention: false, + plainNameCandidate: null, + }); + return ( + + + {title} + {state} + + + {/* Timeline run row */} +
+ ClaudeCoder + run + + 3a648d1b + + +
+ + {/* Assignee change row + wake row */} +
+
+ Assignee + + + +
+ +
+ + {/* Composer footer preview */} +
+
Composer preview
+ +
+
+
+ ); +} + +const meta: Meta = { + title: "Surfaces/Interrupt Handoff", +}; +export default meta; + +type Story = StoryObj; + +export const StateMatrix: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const ComposerCoach: Story = { + render: () => ( +
+ {}} + onDismiss={() => {}} + /> +
+ ), +}; + +export const PlainTextWarning: Story = { + render: () => { + const preview = computeComposerHandoffPreview({ + reassignTarget: "agent:agent-claude", + currentAssigneeValue: "agent:agent-claude", + hasActiveRun: true, + bodyHasAgentMention: false, + plainNameCandidate: { agentId: "agent-qa", matchedText: "QA" }, + }); + return ( +
+ +
+ ); + }, +}; + +/** Surface 2 — standalone assignee picker: grouped sections, the live-run + * banner, and the "Interrupt & assign" confirm step shown mid-run. */ +export const AssigneePicker: Story = { + render: () => { + const copy = describeReassignInterrupt({ runningAgentName: "ClaudeCoder" }); + const sectionHeader = (text: string) => ( +
+ {text} +
+ ); + return ( +
+
+
+ +
+ {sectionHeader("Agents")} + + + {sectionHeader("Board users")} + +
+
+ {}} + onCancel={() => {}} + /> +
+
+ ); + }, +}; + +/** Surface 4 — pause/hold dialog "What this affects" bucket summary. */ +export const PauseAffects: Story = { + render: () => { + const live = computePauseAffectsSummary([ + { assigneeAgentId: "agent-claude", assigneeUserId: null, activeRun: { status: "running" } }, + { assigneeAgentId: "agent-qa", assigneeUserId: null, activeRun: { status: "queued" } }, + { assigneeAgentId: "agent-claude", assigneeUserId: null, activeRun: null }, + { assigneeAgentId: null, assigneeUserId: "user-board", activeRun: null }, + { assigneeAgentId: null, assigneeUserId: null, activeRun: null }, + ]); + const idle = computePauseAffectsSummary([ + { assigneeAgentId: null, assigneeUserId: "user-board", activeRun: null }, + { assigneeAgentId: null, assigneeUserId: null, activeRun: null }, + ]); + return ( +
+ + +
+ ); + }, +};