Show workspace changes and stale notices in issue threads (#5356)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - The issue thread is the operator's durable audit trail for what
changed and why
> - Workspace changes and stale disposition notices need to be visible
in that same timeline without noisy or misleading rendering
> - The local branch already contained backend activity details,
timeline conversion, and UI rendering work for those events
> - This pull request isolates the issue-thread activity work into a
standalone branch against `origin/master`
> - The benefit is a focused audit-trail PR that can merge independently
of the sidebar/operator UI polish branch

## What Changed

- Adds readable workspace-change activity details to issue update
activity events.
- Surfaces workspace-change events in issue chat/timeline rendering.
- Makes the existing issue comment migration idempotent.
- Folds and renders stale disposition notices inline so they match
activity-log styling and spacing.
- Adds focused route, timeline, and issue-thread system notice coverage.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run
server/src/__tests__/issue-activity-events-routes.test.ts
ui/src/lib/issue-timeline-events.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx` — 3 files
passed, 22 tests passed.
- Confirmed the PR changes 9 files and does not include `pnpm-lock.yaml`
or `.github/workflows/*`.
- `pnpm exec vitest run
server/src/__tests__/issue-closed-workspace-routes.test.ts` — 1 file
passed, 4 tests passed.
- `pnpm exec vitest run
server/src/__tests__/issue-activity-events-routes.test.ts
ui/src/lib/issue-timeline-events.test.ts
ui/src/components/IssueChatThreadSystemNotice.test.tsx
server/src/services/recovery/successful-run-handoff.test.ts
packages/shared/src/validators/issue.test.ts` — 5 files passed, 54 tests
passed.
- `pnpm --filter @paperclipai/shared typecheck && pnpm --filter
@paperclipai/server typecheck && pnpm --filter @paperclipai/ui
typecheck`.
- `pnpm --filter @paperclipai/ui typecheck` after adding the Storybook
screenshot fixture.
- Captured Storybook screenshots for the new UI rendering paths:
- Collapsed stale notice + workspace-change row:
`docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png`
- Expanded stale notice details:
`docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png`


### Screenshots

Collapsed stale notice with workspace-change row:

![Collapsed stale notice with workspace-change
row](docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png)

Expanded stale notice details:

![Expanded stale notice
details](docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png)

## Risks

- Moderate risk: this touches issue activity serialization and
issue-thread rendering, both of which are central operator surfaces.
- Migration risk is low: the only migration change makes an existing
migration idempotent.
- No new migrations are introduced, so there is no cross-PR migration
ordering requirement.

> 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 coding agent, shell/tool-use enabled, used to
split the existing branch, verify the isolated PR branch, and create
this PR.

## Checklist

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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-06 09:00:54 -05:00
committed by GitHub
parent 4103978578
commit d0e9cc76f2
17 changed files with 852 additions and 36 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

@@ -1,3 +1,3 @@
ALTER TABLE "issue_comments" ADD COLUMN "author_type" text;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "presentation" jsonb;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "metadata" jsonb;
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "author_type" text;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "presentation" jsonb;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "metadata" jsonb;
+1
View File
@@ -434,6 +434,7 @@ export interface IssueCommentMetadataSection {
export interface IssueCommentMetadata {
version: 1;
sourceRunId?: string | null;
sections: IssueCommentMetadataSection[];
}
@@ -65,6 +65,7 @@ describe("issue validators", () => {
},
metadata: {
version: 1,
sourceRunId: "11111111-1111-4111-8111-111111111111",
sections: [
{
title: "Evidence",
@@ -79,6 +80,7 @@ describe("issue validators", () => {
});
expect(parsed.presentation?.detailsDefaultOpen).toBe(false);
expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111");
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
});
+1
View File
@@ -318,6 +318,7 @@ export const issueCommentMetadataSectionSchema = z.object({
export const issueCommentMetadataSchema = z.object({
version: z.literal(1),
sourceRunId: z.string().uuid().nullable().optional(),
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
}).strict();
@@ -1,5 +1,6 @@
import express from "express";
import request from "supertest";
import { getTableName } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
@@ -266,6 +267,76 @@ describe("issue activity event routes", () => {
});
}, 15_000);
it("logs readable workspace change activity details for issue updates", async () => {
const previousProjectWorkspaceId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const nextExecutionWorkspaceId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const issue = {
...makeIssue(),
projectId: "cccccccc-cccc-4ccc-8ccc-cccccccccccc",
projectWorkspaceId: previousProjectWorkspaceId,
executionWorkspaceId: null,
executionWorkspacePreference: "shared_workspace",
executionWorkspaceSettings: { mode: "shared_workspace" },
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...issue,
...patch,
updatedAt: new Date(),
}));
const dbMock = {
select: vi.fn(() => ({
from: (table: unknown) => ({
where: async () => {
const tableName = getTableName(table as Parameters<typeof getTableName>[0]);
if (tableName === "project_workspaces") {
return [{ id: previousProjectWorkspaceId, name: "Main workspace" }];
}
if (tableName === "execution_workspaces") {
return [{ id: nextExecutionWorkspaceId, name: "Feature workspace" }];
}
return [];
},
}),
})),
};
const res = await request(await createApp(dbMock))
.patch(`/api/issues/${issue.id}`)
.send({ executionWorkspaceId: nextExecutionWorkspaceId });
expect(res.status).toBe(200);
await vi.waitFor(() => {
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "issue.updated",
details: expect.objectContaining({
executionWorkspaceId: nextExecutionWorkspaceId,
workspaceChange: {
from: {
label: "Main workspace",
projectWorkspaceId: previousProjectWorkspaceId,
executionWorkspaceId: null,
mode: "shared_workspace",
},
to: {
label: "Feature workspace",
projectWorkspaceId: previousProjectWorkspaceId,
executionWorkspaceId: nextExecutionWorkspaceId,
mode: "shared_workspace",
},
},
_previous: expect.objectContaining({
executionWorkspaceId: null,
}),
}),
}),
);
});
});
it("logs successful_run_handoff_resolved when an in_progress issue transitions to done with a pending required handoff", async () => {
const issue = { ...makeIssue(), status: "in_progress" };
mockIssueService.getById.mockResolvedValue(issue);
+154 -22
View File
@@ -4,7 +4,7 @@ import multer from "multer";
import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { activityLog, issueExecutionDecisions } from "@paperclipai/db";
import { activityLog, executionWorkspaces, issueExecutionDecisions, projectWorkspaces } from "@paperclipai/db";
import {
addIssueCommentSchema,
acceptIssueThreadInteractionSchema,
@@ -96,6 +96,7 @@ import {
redactIssueMonitorExternalRef,
setIssueExecutionPolicyMonitorScheduledBy,
} from "../services/issue-execution-policy.js";
import { parseIssueExecutionWorkspaceSettings } from "../services/execution-workspace-policy.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
@@ -142,10 +143,148 @@ const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_escalated",
] as const;
const ISSUE_WORKSPACE_AUDIT_FIELDS = new Set([
"projectWorkspaceId",
"executionWorkspaceId",
"executionWorkspacePreference",
"executionWorkspaceSettings",
]);
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function hasIssueWorkspaceAuditChange(previous: Record<string, unknown>) {
return Object.keys(previous).some((key) => ISSUE_WORKSPACE_AUDIT_FIELDS.has(key));
}
function labelIssueWorkspaceMode(mode: string | null) {
switch (mode) {
case "shared_workspace":
return "Project default";
case "isolated_workspace":
return "New isolated workspace";
case "operator_branch":
return "Operator branch";
case "reuse_existing":
return "Reuse existing workspace";
case "agent_default":
return "Agent default";
case "inherit":
return "Inherited workspace";
default:
return "No workspace";
}
}
type IssueWorkspaceAuditInput = {
projectWorkspaceId?: string | null;
executionWorkspaceId?: string | null;
executionWorkspacePreference?: string | null;
executionWorkspaceSettings?: unknown;
};
type WorkspaceNameMaps = {
projectWorkspaceNames: Map<string, string>;
executionWorkspaceNames: Map<string, string>;
};
function emptyWorkspaceNameMaps(): WorkspaceNameMaps {
return {
projectWorkspaceNames: new Map(),
executionWorkspaceNames: new Map(),
};
}
function summarizeIssueWorkspaceForActivity(
issue: IssueWorkspaceAuditInput,
names: WorkspaceNameMaps,
) {
const settings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings);
const mode = settings?.mode ?? issue.executionWorkspacePreference ?? null;
const executionWorkspaceId = issue.executionWorkspaceId ?? null;
const projectWorkspaceId = issue.projectWorkspaceId ?? null;
const label = (() => {
if (executionWorkspaceId) {
return names.executionWorkspaceNames.get(executionWorkspaceId) ?? `Workspace ${executionWorkspaceId.slice(0, 8)}`;
}
if (projectWorkspaceId) {
return names.projectWorkspaceNames.get(projectWorkspaceId) ?? `Workspace ${projectWorkspaceId.slice(0, 8)}`;
}
return labelIssueWorkspaceMode(mode);
})();
return {
label,
projectWorkspaceId,
executionWorkspaceId,
mode,
};
}
async function buildIssueWorkspaceChangeActivityDetails(
db: Db,
companyId: string,
previousIssue: IssueWorkspaceAuditInput,
nextIssue: IssueWorkspaceAuditInput,
) {
const projectWorkspaceIds = [
previousIssue.projectWorkspaceId,
nextIssue.projectWorkspaceId,
].filter((value): value is string => typeof value === "string" && value.length > 0);
const executionWorkspaceIds = [
previousIssue.executionWorkspaceId,
nextIssue.executionWorkspaceId,
].filter((value): value is string => typeof value === "string" && value.length > 0);
const [projectRows, executionRows] = await Promise.all([
projectWorkspaceIds.length > 0
? db
.select({ id: projectWorkspaces.id, name: projectWorkspaces.name })
.from(projectWorkspaces)
.where(and(eq(projectWorkspaces.companyId, companyId), inArray(projectWorkspaces.id, projectWorkspaceIds)))
: Promise.resolve([]),
executionWorkspaceIds.length > 0
? db
.select({ id: executionWorkspaces.id, name: executionWorkspaces.name })
.from(executionWorkspaces)
.where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, executionWorkspaceIds)))
: Promise.resolve([]),
]);
const names: WorkspaceNameMaps = {
projectWorkspaceNames: new Map(projectRows.map((row) => [row.id, row.name])),
executionWorkspaceNames: new Map(executionRows.map((row) => [row.id, row.name])),
};
return {
from: summarizeIssueWorkspaceForActivity(previousIssue, names),
to: summarizeIssueWorkspaceForActivity(nextIssue, names),
};
}
function hasExecutionParticipant(value: unknown) {
const state = parseIssueExecutionState(value);
if (!state || state.status !== "pending") return false;
const participant = state.currentParticipant;
if (!participant) return false;
if (participant.type === "agent") return Boolean(participant.agentId);
if (participant.type === "user") return Boolean(participant.userId);
return false;
}
function hasScheduledMonitor(input: {
existingMonitorNextCheckAt?: Date | null;
patchMonitorNextCheckAt?: unknown;
executionPolicy?: unknown;
}) {
if (input.patchMonitorNextCheckAt instanceof Date && !Number.isNaN(input.patchMonitorNextCheckAt.getTime())) return true;
if (input.patchMonitorNextCheckAt === undefined && input.existingMonitorNextCheckAt) return true;
const policy = normalizeIssueExecutionPolicy(input.executionPolicy ?? null);
return Boolean(policy?.monitor?.nextCheckAt);
}
function successfulRunHandoffStateFromActivity(row: {
action: string;
agentId: string | null;
@@ -236,27 +375,6 @@ const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
"link or request a pending approval, assign a human reviewer with assigneeUserId, set a typed executionState.currentParticipant through an execution policy, " +
"or schedule an issue monitor for an external review/check. After creating one of those review paths, retry the status update.";
function hasExecutionParticipant(value: unknown) {
const state = parseIssueExecutionState(value);
if (!state || state.status !== "pending") return false;
const participant = state.currentParticipant;
if (!participant) return false;
if (participant.type === "agent") return Boolean(participant.agentId);
if (participant.type === "user") return Boolean(participant.userId);
return false;
}
function hasScheduledMonitor(input: {
existingMonitorNextCheckAt?: Date | null;
patchMonitorNextCheckAt?: unknown;
executionPolicy?: unknown;
}) {
if (input.patchMonitorNextCheckAt instanceof Date && !Number.isNaN(input.patchMonitorNextCheckAt.getTime())) return true;
if (input.patchMonitorNextCheckAt === undefined && input.existingMonitorNextCheckAt) return true;
const policy = normalizeIssueExecutionPolicy(input.executionPolicy ?? null);
return Boolean(policy?.monitor?.nextCheckAt);
}
function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null,
right: ParsedExecutionState["currentParticipant"] | null,
@@ -2673,6 +2791,19 @@ export function issueRoutes(
}
const hasFieldChanges = Object.keys(previous).length > 0;
let workspaceChange = null;
if (hasIssueWorkspaceAuditChange(previous)) {
try {
workspaceChange = await buildIssueWorkspaceChangeActivityDetails(db, issue.companyId, existing, issue);
} catch (err) {
logger.warn({ err, issueId: issue.id }, "failed to enrich issue workspace change activity details");
const fallbackNames = emptyWorkspaceNameMaps();
workspaceChange = {
from: summarizeIssueWorkspaceForActivity(existing, fallbackNames),
to: summarizeIssueWorkspaceForActivity(issue, fallbackNames),
};
}
}
const reopened =
commentBody &&
effectiveMoveToTodoRequested &&
@@ -2697,6 +2828,7 @@ export function issueRoutes(
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(interruptedRunId ? { interruptedRunId } : {}),
...(cancelledStatusRunId ? { cancelledStatusRunId } : {}),
...(workspaceChange ? { workspaceChange } : {}),
_previous: hasFieldChanges ? previous : undefined,
...summarizeIssueReferenceActivityDetails(
updateReferenceDiff
@@ -218,6 +218,7 @@ describe("successful run handoff decision", () => {
title: "Missing issue disposition",
detailsDefaultOpen: false,
});
expect(notice.metadata.sourceRunId).toBe("22222222-2222-4222-8222-222222222222");
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({
title: "Required action",
@@ -267,6 +268,7 @@ describe("successful run handoff decision", () => {
tone: "danger",
detailsDefaultOpen: false,
});
expect(notice.metadata.sourceRunId).toBe("22222222-2222-4222-8222-222222222222");
expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({
title: "Recovery owner",
@@ -146,6 +146,7 @@ export function buildSuccessfulRunHandoffRequiredNotice(input: {
}),
metadata: {
version: 1,
sourceRunId: input.run.id,
sections: [
{
title: "Required action",
@@ -193,6 +194,7 @@ export function buildSuccessfulRunHandoffExhaustedNotice(input: {
}),
metadata: {
version: 1,
sourceRunId: input.sourceRun?.id ?? null,
sections: [
{
title: "Recovery owner",
+16 -1
View File
@@ -20,7 +20,7 @@ import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { ApprovalCard } from "./ApprovalCard";
import { AgentIcon } from "./AgentIconPicker";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { formatTimelineWorkspaceLabel, type IssueTimelineAssignee, type IssueTimelineEvent } from "../lib/issue-timeline-events";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
@@ -535,6 +535,21 @@ function TimelineEventCard({
</span>
</div>
) : null}
{event.workspaceChange ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-14 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Workspace
</span>
<span className="text-muted-foreground">
{formatTimelineWorkspaceLabel(event.workspaceChange.from)}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineWorkspaceLabel(event.workspaceChange.to)}
</span>
</div>
) : null}
</div>
</div>
);
+280 -4
View File
@@ -16,6 +16,7 @@ import {
useCallback,
useContext,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
@@ -61,7 +62,12 @@ import type {
} from "../lib/issue-thread-interactions";
import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import {
formatTimelineWorkspaceLabel,
type IssueTimelineAssignee,
type IssueTimelineEvent,
type IssueTimelineWorkspace,
} from "../lib/issue-timeline-events";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -99,8 +105,15 @@ import {
isSuccessfulRunHandoffComment,
isSuccessfulRunHandoffEscalationComment,
} from "../lib/successful-run-handoff";
import { SystemNotice } from "./SystemNotice";
import { buildSystemNoticeProps } from "../lib/system-notice-comment";
import {
SystemNotice,
type SystemNoticeMetadataRow,
type SystemNoticeMetadataSection,
} from "./SystemNotice";
import {
buildSystemNoticeProps,
mapCommentMetadataToSystemNoticeSections,
} from "../lib/system-notice-comment";
import type {
IssueCommentMetadata,
IssueCommentPresentation,
@@ -155,11 +168,15 @@ interface IssueChatMessageContext {
onCancelInteraction?: (
interaction: AskUserQuestionsInteraction,
) => Promise<void> | void;
issueStatus?: string;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
}
const IssueChatCtx = createContext<IssueChatMessageContext>({
feedbackDataSharingPreference: "prompt",
feedbackTermsUrl: null,
issueStatus: undefined,
successfulRunHandoff: null,
});
export function resolveAssistantMessageFoldedState(args: {
@@ -1968,6 +1985,227 @@ function isIssueCommentMetadata(value: unknown): value is IssueCommentMetadata {
return v.version === 1 && Array.isArray(v.sections);
}
function issueStatusIsTerminalDisposition(issueStatus: string | undefined) {
return issueStatus === "done" || issueStatus === "cancelled";
}
function sourceRunIdFromSuccessfulRunHandoffMetadata(metadata: IssueCommentMetadata | null) {
if (metadata?.sourceRunId) return metadata.sourceRunId;
const runLinks = [];
for (const section of metadata?.sections ?? []) {
for (const row of section.rows) {
if (row.type === "run_link") runLinks.push(row.runId);
}
}
return runLinks.length === 1 ? runLinks[0] : null;
}
function isStaleSuccessfulRunHandoffNotice(input: {
bodyText: string;
issueStatus?: string;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
runId?: string | null;
metadata: IssueCommentMetadata | null;
}) {
if (!isSuccessfulRunHandoffComment(input.bodyText)) return false;
const currentHandoff = input.successfulRunHandoff ?? null;
if (currentHandoff?.state === "resolved") return true;
if (issueStatusIsTerminalDisposition(input.issueStatus)) return true;
const noticeSourceRunId = sourceRunIdFromSuccessfulRunHandoffMetadata(input.metadata) ?? input.runId ?? null;
if (
noticeSourceRunId
&& currentHandoff?.sourceRunId
&& noticeSourceRunId !== currentHandoff.sourceRunId
) {
return true;
}
return false;
}
function StaleDispositionWarningMetadataRow({ row }: { row: SystemNoticeMetadataRow }) {
const label = (
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{row.label}
</span>
);
const value = (() => {
switch (row.kind) {
case "text":
return <span>{row.value}</span>;
case "code":
return (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
{row.value}
</code>
);
case "issue": {
const content = (
<>
<span>{row.identifier}</span>
{row.title ? <span className="text-muted-foreground"> - {row.title}</span> : null}
</>
);
return row.href ? (
<a href={row.href} className="font-medium text-foreground underline-offset-2 hover:underline">
{content}
</a>
) : (
<span className="font-medium text-foreground">{content}</span>
);
}
case "agent":
return row.href ? (
<a href={row.href} className="font-medium text-foreground underline-offset-2 hover:underline">
{row.name}
</a>
) : (
<span className="font-medium text-foreground">{row.name}</span>
);
case "run": {
const runShort = row.runId.length > 12 ? `${row.runId.slice(0, 8)}...` : row.runId;
const content = (
<>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground/80">
{runShort}
</code>
{row.status ? <span>{row.status}</span> : null}
</>
);
return row.href ? (
<a href={row.href} className="inline-flex items-center gap-1.5 underline-offset-2 hover:underline">
{content}
</a>
) : (
<span className="inline-flex items-center gap-1.5">{content}</span>
);
}
}
})();
return (
<div className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-2 text-xs leading-5">
{label}
<div className="min-w-0 break-words text-foreground/80">{value}</div>
</div>
);
}
function metadataRowKey(row: SystemNoticeMetadataRow) {
switch (row.kind) {
case "issue":
return `issue:${row.label}:${row.identifier}:${row.href ?? ""}:${row.title ?? ""}`;
case "agent":
return `agent:${row.label}:${row.name}:${row.href ?? ""}`;
case "run":
return `run:${row.label}:${row.runId}:${row.href ?? ""}:${row.status ?? ""}`;
default:
return `${row.kind}:${row.label}:${row.value}`;
}
}
function metadataSectionKey(section: SystemNoticeMetadataSection) {
return `${section.title ?? "details"}:${section.rows.map(metadataRowKey).join("|")}`;
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function isTimelineWorkspace(value: unknown): value is IssueTimelineWorkspace {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const workspace = value as Record<string, unknown>;
return isNullableString(workspace.label)
&& isNullableString(workspace.projectWorkspaceId)
&& isNullableString(workspace.executionWorkspaceId)
&& isNullableString(workspace.mode);
}
function isTimelineWorkspaceChange(value: unknown): value is NonNullable<IssueTimelineEvent["workspaceChange"]> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const change = value as Record<string, unknown>;
return isTimelineWorkspace(change.from) && isTimelineWorkspace(change.to);
}
function StaleDispositionWarningDetails({
sections,
}: {
sections: SystemNoticeMetadataSection[];
}) {
if (sections.length === 0) {
return <div className="text-xs leading-5 text-muted-foreground">No additional details.</div>;
}
return (
<div className="space-y-3 text-left">
{sections.map((section) => (
<div key={metadataSectionKey(section)} className="space-y-1.5">
{section.title ? (
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{section.title}
</div>
) : null}
<div className="space-y-1">
{section.rows.map((row) => (
<StaleDispositionWarningMetadataRow key={metadataRowKey(row)} row={row} />
))}
</div>
</div>
))}
</div>
);
}
function StaleDispositionWarningRow({
anchorId,
message,
metadata,
runAgentId,
}: {
anchorId?: string;
message: ThreadMessage;
metadata: IssueCommentMetadata | null;
runAgentId?: string | null;
}) {
const [open, setOpen] = useState(false);
const detailsId = useId();
const sections = mapCommentMetadataToSystemNoticeSections(metadata, { runAgentId });
return (
<div id={anchorId} data-testid="stale-disposition-warning">
<div className="flex items-start gap-2.5 py-1.5">
<span className="size-6 shrink-0" aria-hidden />
<div className="min-w-0 flex-1">
<button
type="button"
aria-expanded={open}
aria-controls={detailsId}
className="group flex w-full items-center gap-2 py-0.5 text-left"
onClick={() => setOpen((value) => !value)}
>
<span className="text-sm font-medium text-foreground/80">
Stale disposition warning
</span>
<span className="ml-auto flex items-center gap-1.5">
{message.createdAt ? (
<span data-testid="stale-disposition-warning-time" className="text-[11px] text-muted-foreground/50">
{commentDateLabel(message.createdAt)}
</span>
) : null}
<ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground/40 transition-transform", open && "rotate-180")} />
</span>
</button>
<div id={detailsId} hidden={!open} className="space-y-1 py-1">
<StaleDispositionWarningDetails sections={sections} />
</div>
</div>
</div>
</div>
);
}
function SystemNoticeCommentRow({
message,
anchorId,
@@ -1975,7 +2213,7 @@ function SystemNoticeCommentRow({
message: ThreadMessage;
anchorId?: string;
}) {
const { onImageClick, agentMap } = useContext(IssueChatCtx);
const { onImageClick, agentMap, issueStatus, successfulRunHandoff } = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>;
const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null;
const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null;
@@ -1987,6 +2225,13 @@ function SystemNoticeCommentRow({
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("\n\n");
const staleSuccessfulRunHandoffNotice = isStaleSuccessfulRunHandoffNotice({
bodyText,
issueStatus,
successfulRunHandoff,
runId,
metadata: commentMetadata,
});
const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = useState(false);
@@ -2033,6 +2278,17 @@ function SystemNoticeCommentRow({
});
};
if (staleSuccessfulRunHandoffNotice) {
return (
<StaleDispositionWarningRow
anchorId={anchorId}
message={message}
metadata={commentMetadata}
runAgentId={runAgentId}
/>
);
}
return (
<div id={anchorId} className="group">
<div className="py-1">
@@ -2105,6 +2361,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
to: IssueTimelineAssignee;
}
: null;
const workspaceChange = isTimelineWorkspaceChange(custom.workspaceChange) ? custom.workspaceChange : null;
const interaction = isIssueThreadInteraction(custom.interaction)
? custom.interaction
: null;
@@ -2192,6 +2449,21 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
</span>
</div>
) : null}
{workspaceChange ? (
<div className={cn("flex flex-wrap items-center gap-1.5 text-xs", isCurrentUser && "justify-end")}>
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Workspace
</span>
<span className="text-muted-foreground">
{formatTimelineWorkspaceLabel(workspaceChange.from)}
</span>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineWorkspaceLabel(workspaceChange.to)}
</span>
</div>
) : null}
</div>
);
@@ -3855,6 +4127,8 @@ export function IssueChatThread({
onRejectInteraction: stableOnRejectInteraction,
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
onCancelInteraction: stableOnCancelInteraction,
issueStatus,
successfulRunHandoff,
}),
[
feedbackDataSharingPreference,
@@ -3875,6 +4149,8 @@ export function IssueChatThread({
stableOnRejectInteraction,
stableOnSubmitInteractionAnswers,
stableOnCancelInteraction,
issueStatus,
successfulRunHandoff,
],
);
@@ -7,7 +7,7 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread } from "./IssueChatThread";
import type { IssueChatComment } from "../lib/issue-chat-messages";
import type { Agent } from "@paperclipai/shared";
import type { Agent, SuccessfulRunHandoffState } from "@paperclipai/shared";
vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@@ -70,7 +70,14 @@ afterEach(() => {
container.remove();
});
function renderThread(comments: IssueChatComment[], agentMap?: Map<string, Agent>) {
function renderThread(
comments: IssueChatComment[],
options: {
agentMap?: Map<string, Agent>;
issueStatus?: string;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
} = {},
) {
act(() => {
root.render(
<MemoryRouter>
@@ -82,7 +89,9 @@ function renderThread(comments: IssueChatComment[], agentMap?: Map<string, Agent
onAdd={async () => {}}
showComposer={false}
enableLiveTranscriptPolling={false}
agentMap={agentMap}
agentMap={options.agentMap}
issueStatus={options.issueStatus}
successfulRunHandoff={options.successfulRunHandoff}
/>
</MemoryRouter>,
);
@@ -265,7 +274,7 @@ describe("IssueChatThread system notice routing", () => {
...baseTimestamps,
};
renderThread([comment], agentMap);
renderThread([comment], { agentMap });
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
@@ -395,4 +404,80 @@ describe("IssueChatThread system notice routing", () => {
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.toBeNull();
});
it("folds stale successful-run disposition warnings into the activity log disclosure style", () => {
const comment: IssueChatComment = {
id: "comment-stale-disposition-warning",
companyId: "company-1",
issueId: "issue-1",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-stale",
runAgentId: "agent-codex",
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sourceRunId: "run-stale",
sections: [
{
title: "Run evidence",
rows: [
{ type: "run_link", label: "Completed run", runId: "run-stale", title: "succeeded" },
{ type: "key_value", label: "Normalized cause", value: "successful_run_missing_state" },
],
},
],
},
...baseTimestamps,
};
renderThread([comment], {
issueStatus: "done",
successfulRunHandoff: {
state: "resolved",
required: false,
sourceRunId: "run-stale",
correctiveRunId: "run-corrective",
assigneeAgentId: "agent-codex",
detectedProgressSummary: null,
createdAt: new Date("2026-05-04T17:00:00.000Z"),
},
});
const row = container.querySelector('[data-testid="stale-disposition-warning"]');
expect(row).not.toBeNull();
expect(row?.querySelector('span[aria-hidden="true"]')?.className).toContain("size-6");
const toggle = row?.querySelector("button[aria-expanded]") as HTMLButtonElement;
expect(toggle.className).toContain("w-full");
expect(toggle.className).toContain("py-0.5");
expect(row?.querySelector('[role="status"]')).toBeNull();
expect(row?.querySelector(".lucide-triangle-alert")).toBeNull();
expect(row?.querySelector(".lucide-chevron-down")).not.toBeNull();
expect(row?.querySelector('[data-testid="stale-disposition-warning-time"]')?.parentElement?.className).toContain("ml-auto");
expect(row?.textContent).toContain("Stale disposition warning");
expect(row?.textContent).not.toContain("This disposition warning is stale because the issue now has a newer disposition.");
expect(row?.textContent).not.toContain("Paperclip needs a disposition before this issue can continue.");
expect(toggle.getAttribute("aria-expanded")).toBe("false");
const detailsId = toggle.getAttribute("aria-controls");
expect(detailsId).toBeTruthy();
const details = detailsId ? container.ownerDocument.getElementById(detailsId) : null;
expect(details).not.toBeNull();
expect(details?.textContent).toContain("run-stale");
expect(details).toHaveProperty("hidden", true);
act(() => {
toggle.click();
});
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(details).toHaveProperty("hidden", false);
expect(container.textContent).toContain("run-stale");
});
});
+6
View File
@@ -455,6 +455,11 @@ function createTimelineEventMessage(args: {
: (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId, userLabelMap) ?? "Unassigned");
lines.push(`Assignee: ${from} -> ${to}`);
}
if (event.workspaceChange) {
lines.push(
`Workspace: ${event.workspaceChange.from.label ?? "none"} -> ${event.workspaceChange.to.label ?? "none"}`,
);
}
const message: ThreadSystemMessage = {
id: `activity:${event.id}`,
@@ -471,6 +476,7 @@ function createTimelineEventMessage(args: {
actorId: event.actorId,
statusChange: event.statusChange ?? null,
assigneeChange: event.assigneeChange ?? null,
workspaceChange: event.workspaceChange ?? null,
followUpRequested: event.followUpRequested === true,
},
},
+62 -1
View File
@@ -171,6 +171,67 @@ describe("extractIssueTimelineEvents", () => {
]);
});
it("extracts workspace changes from issue update activity", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-workspace",
companyId: "company-1",
actorType: "user",
actorId: "local-board",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: null,
runId: null,
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
projectWorkspaceId: "workspace-2",
workspaceChange: {
from: {
label: "Main workspace",
projectWorkspaceId: "workspace-1",
executionWorkspaceId: null,
mode: "shared_workspace",
},
to: {
label: "Feature branch",
projectWorkspaceId: "workspace-2",
executionWorkspaceId: null,
mode: "shared_workspace",
},
},
_previous: {
projectWorkspaceId: "workspace-1",
},
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-workspace",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "user",
actorId: "local-board",
runId: null,
workspaceChange: {
from: {
label: "Main workspace",
projectWorkspaceId: "workspace-1",
executionWorkspaceId: null,
mode: "shared_workspace",
},
to: {
label: "Feature branch",
projectWorkspaceId: "workspace-2",
executionWorkspaceId: null,
mode: "shared_workspace",
},
},
},
]);
});
it("synthesizes non-status follow-up rows from comment activity", () => {
const events = extractIssueTimelineEvents([
{
@@ -205,7 +266,7 @@ describe("extractIssueTimelineEvents", () => {
]);
});
it("ignores issue updates without visible status or assignee transitions", () => {
it("ignores issue updates without visible status, assignee, or workspace transitions", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-title",
+54 -1
View File
@@ -19,10 +19,26 @@ export interface IssueTimelineEvent {
from: IssueTimelineAssignee;
to: IssueTimelineAssignee;
};
workspaceChange?: {
from: IssueTimelineWorkspace;
to: IssueTimelineWorkspace;
};
commentId?: string | null;
followUpRequested?: boolean;
}
export interface IssueTimelineWorkspace {
label: string | null;
projectWorkspaceId: string | null;
executionWorkspaceId: string | null;
mode: string | null;
}
export function formatTimelineWorkspaceLabel(workspace: IssueTimelineWorkspace) {
const fallbackId = workspace.executionWorkspaceId ?? workspace.projectWorkspaceId;
return workspace.label ?? (fallbackId ? fallbackId.slice(0, 8) : "None");
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
@@ -44,6 +60,33 @@ function sameAssignee(left: IssueTimelineAssignee, right: IssueTimelineAssignee)
return left.agentId === right.agentId && left.userId === right.userId;
}
function sameWorkspace(left: IssueTimelineWorkspace, right: IssueTimelineWorkspace) {
return left.projectWorkspaceId === right.projectWorkspaceId
&& left.executionWorkspaceId === right.executionWorkspaceId
&& left.mode === right.mode
&& left.label === right.label;
}
function workspaceFromRecord(value: unknown): IssueTimelineWorkspace | null {
const record = asRecord(value);
if (!record) return null;
return {
label: nullableString(record.label),
projectWorkspaceId: nullableString(record.projectWorkspaceId),
executionWorkspaceId: nullableString(record.executionWorkspaceId),
mode: nullableString(record.mode),
};
}
function workspaceChangeFromDetails(details: Record<string, unknown>) {
const change = asRecord(details.workspaceChange);
if (!change) return null;
const from = workspaceFromRecord(change.from);
const to = workspaceFromRecord(change.to);
if (!from || !to || sameWorkspace(from, to)) return null;
return { from, to };
}
function sortTimelineEvents<T extends { createdAt: Date | string; id: string }>(events: T[]) {
return [...events].sort((a, b) => {
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
@@ -120,7 +163,17 @@ export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | un
}
}
if (timelineEvent.statusChange || timelineEvent.assigneeChange || timelineEvent.followUpRequested) {
const workspaceChange = workspaceChangeFromDetails(details);
if (workspaceChange) {
timelineEvent.workspaceChange = workspaceChange;
}
if (
timelineEvent.statusChange
|| timelineEvent.assigneeChange
|| timelineEvent.workspaceChange
|| timelineEvent.followUpRequested
) {
events.push(timelineEvent);
}
}
@@ -404,6 +404,7 @@ const issueChatComments: IssueChatComment[] = [
},
metadata: {
version: 1,
sourceRunId: "run-issue-chat-01",
sections: [
{
title: "Required action",
@@ -459,6 +460,73 @@ const issueTimelineEvents: IssueTimelineEvent[] = [
}),
];
const issueThreadNoticeReviewComments: IssueChatComment[] = [
createComment({
id: "comment-notice-board",
body: "The issue thread needs to show workspace routing changes and make old missing-disposition warnings feel resolved.",
createdAt: new Date("2026-04-20T13:44:00.000Z"),
}),
createComment({
id: "comment-notice-system-warning",
authorType: "system",
authorAgentId: null,
authorUserId: null,
runId: "run-notice-source",
runAgentId: codexAgent.id,
body: "Paperclip needs a disposition before this issue can continue.",
presentation: {
kind: "system_notice",
tone: "warning",
title: "Missing issue disposition",
detailsDefaultOpen: false,
},
metadata: {
version: 1,
sourceRunId: "run-notice-source",
sections: [
{
title: "Required action",
rows: [
{ type: "issue_link", label: "Source issue", issueId, identifier: "PAP-3660", title: "Show issue-thread notices" },
{ type: "agent_link", label: "Assignee", agentId: codexAgent.id, name: codexAgent.name },
{ type: "key_value", label: "Missing disposition", value: "clear_next_step" },
],
},
{
title: "Run evidence",
rows: [
{ type: "run_link", label: "Completed run", runId: "run-notice-source", title: "succeeded" },
{ type: "key_value", label: "Normalized cause", value: "successful_run_missing_state" },
],
},
],
},
createdAt: new Date("2026-04-20T13:48:00.000Z"),
}),
];
const issueThreadNoticeReviewTimelineEvents: IssueTimelineEvent[] = [
createSystemEvent({
id: "event-notice-workspace-change",
createdAt: new Date("2026-04-20T13:46:00.000Z"),
statusChange: undefined,
workspaceChange: {
from: {
label: "Project primary workspace",
projectWorkspaceId: "workspace-primary",
executionWorkspaceId: null,
mode: "shared_workspace",
},
to: {
label: "PAP-3660 issue-thread-notices",
projectWorkspaceId: null,
executionWorkspaceId: "execution-workspace-notices",
mode: "isolated_workspace",
},
},
}),
];
const issueLinkedRuns: IssueChatLinkedRun[] = [
{
runId: "run-issue-chat-01",
@@ -701,6 +769,43 @@ function IssueChatMatrix() {
);
}
function IssueThreadNoticeReview() {
return (
<div className="paperclip-story">
<main className="paperclip-story__inner max-w-4xl">
<Section eyebrow="IssueChatThread" title="Workspace changes and stale disposition notices">
<div className="rounded-lg border border-border bg-background/70 p-4">
<IssueChatThread
comments={issueThreadNoticeReviewComments}
timelineEvents={issueThreadNoticeReviewTimelineEvents}
linkedRuns={[]}
liveRuns={[]}
companyId={companyId}
projectId={projectId}
issueStatus="done"
successfulRunHandoff={{
state: "resolved",
required: false,
sourceRunId: "run-notice-source",
correctiveRunId: "run-notice-corrective",
assigneeAgentId: codexAgent.id,
detectedProgressSummary: "Captured screenshots for the issue thread notice states.",
createdAt: new Date("2026-04-20T13:49:00.000Z"),
}}
agentMap={storybookAgentMap}
currentUserId={currentUserId}
userLabelMap={boardUserLabels}
onAdd={async () => {}}
enableLiveTranscriptPolling={false}
showJumpToLatest={false}
/>
</div>
</Section>
</main>
</div>
);
}
function ChatCommentsStories() {
return (
<div className="paperclip-story">
@@ -771,3 +876,7 @@ export const IssueChatWithTimeline: Story = {
</div>
),
};
export const IssueThreadNotices: Story = {
render: () => <IssueThreadNoticeReview />,
};