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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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

View file

@ -1,3 +1,3 @@
ALTER TABLE "issue_comments" ADD COLUMN "author_type" text;--> statement-breakpoint ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "author_type" text;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "presentation" jsonb;--> statement-breakpoint ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "presentation" jsonb;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "metadata" jsonb; ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "metadata" jsonb;

View file

@ -434,6 +434,7 @@ export interface IssueCommentMetadataSection {
export interface IssueCommentMetadata { export interface IssueCommentMetadata {
version: 1; version: 1;
sourceRunId?: string | null;
sections: IssueCommentMetadataSection[]; sections: IssueCommentMetadataSection[];
} }

View file

@ -65,6 +65,7 @@ describe("issue validators", () => {
}, },
metadata: { metadata: {
version: 1, version: 1,
sourceRunId: "11111111-1111-4111-8111-111111111111",
sections: [ sections: [
{ {
title: "Evidence", title: "Evidence",
@ -79,6 +80,7 @@ describe("issue validators", () => {
}); });
expect(parsed.presentation?.detailsDefaultOpen).toBe(false); expect(parsed.presentation?.detailsDefaultOpen).toBe(false);
expect(parsed.metadata?.sourceRunId).toBe("11111111-1111-4111-8111-111111111111");
expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3); expect(parsed.metadata?.sections[0]?.rows).toHaveLength(3);
}); });

View file

@ -318,6 +318,7 @@ export const issueCommentMetadataSectionSchema = z.object({
export const issueCommentMetadataSchema = z.object({ export const issueCommentMetadataSchema = z.object({
version: z.literal(1), version: z.literal(1),
sourceRunId: z.string().uuid().nullable().optional(),
sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20), sections: z.array(issueCommentMetadataSectionSchema).min(1).max(20),
}).strict(); }).strict();

View file

@ -1,5 +1,6 @@
import express from "express"; import express from "express";
import request from "supertest"; import request from "supertest";
import { getTableName } from "drizzle-orm";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts"; import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.ts";
@ -266,6 +267,76 @@ describe("issue activity event routes", () => {
}); });
}, 15_000); }, 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 () => { 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" }; const issue = { ...makeIssue(), status: "in_progress" };
mockIssueService.getById.mockResolvedValue(issue); mockIssueService.getById.mockResolvedValue(issue);

View file

@ -4,7 +4,7 @@ import multer from "multer";
import { z } from "zod"; import { z } from "zod";
import { and, desc, eq, inArray } from "drizzle-orm"; import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { activityLog, issueExecutionDecisions } from "@paperclipai/db"; import { activityLog, executionWorkspaces, issueExecutionDecisions, projectWorkspaces } from "@paperclipai/db";
import { import {
addIssueCommentSchema, addIssueCommentSchema,
acceptIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema,
@ -96,6 +96,7 @@ import {
redactIssueMonitorExternalRef, redactIssueMonitorExternalRef,
setIssueExecutionPolicyMonitorScheduledBy, setIssueExecutionPolicyMonitorScheduledBy,
} from "../services/issue-execution-policy.js"; } from "../services/issue-execution-policy.js";
import { parseIssueExecutionWorkspaceSettings } from "../services/execution-workspace-policy.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
const MAX_ISSUE_COMMENT_LIMIT = 500; const MAX_ISSUE_COMMENT_LIMIT = 500;
@ -142,10 +143,148 @@ const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
"issue.successful_run_handoff_escalated", "issue.successful_run_handoff_escalated",
] as const; ] as const;
const ISSUE_WORKSPACE_AUDIT_FIELDS = new Set([
"projectWorkspaceId",
"executionWorkspaceId",
"executionWorkspacePreference",
"executionWorkspaceSettings",
]);
function readNonEmptyString(value: unknown): string | null { function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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: { function successfulRunHandoffStateFromActivity(row: {
action: string; action: string;
agentId: string | null; 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, " + "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."; "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( function executionPrincipalsEqual(
left: ParsedExecutionState["currentParticipant"] | null, left: ParsedExecutionState["currentParticipant"] | null,
right: ParsedExecutionState["currentParticipant"] | null, right: ParsedExecutionState["currentParticipant"] | null,
@ -2673,6 +2791,19 @@ export function issueRoutes(
} }
const hasFieldChanges = Object.keys(previous).length > 0; 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 = const reopened =
commentBody && commentBody &&
effectiveMoveToTodoRequested && effectiveMoveToTodoRequested &&
@ -2697,6 +2828,7 @@ export function issueRoutes(
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
...(interruptedRunId ? { interruptedRunId } : {}), ...(interruptedRunId ? { interruptedRunId } : {}),
...(cancelledStatusRunId ? { cancelledStatusRunId } : {}), ...(cancelledStatusRunId ? { cancelledStatusRunId } : {}),
...(workspaceChange ? { workspaceChange } : {}),
_previous: hasFieldChanges ? previous : undefined, _previous: hasFieldChanges ? previous : undefined,
...summarizeIssueReferenceActivityDetails( ...summarizeIssueReferenceActivityDetails(
updateReferenceDiff updateReferenceDiff

View file

@ -218,6 +218,7 @@ describe("successful run handoff decision", () => {
title: "Missing issue disposition", title: "Missing issue disposition",
detailsDefaultOpen: false, detailsDefaultOpen: false,
}); });
expect(notice.metadata.sourceRunId).toBe("22222222-2222-4222-8222-222222222222");
expect(notice.metadata.sections).toEqual(expect.arrayContaining([ expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
title: "Required action", title: "Required action",
@ -267,6 +268,7 @@ describe("successful run handoff decision", () => {
tone: "danger", tone: "danger",
detailsDefaultOpen: false, detailsDefaultOpen: false,
}); });
expect(notice.metadata.sourceRunId).toBe("22222222-2222-4222-8222-222222222222");
expect(notice.metadata.sections).toEqual(expect.arrayContaining([ expect(notice.metadata.sections).toEqual(expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
title: "Recovery owner", title: "Recovery owner",

View file

@ -146,6 +146,7 @@ export function buildSuccessfulRunHandoffRequiredNotice(input: {
}), }),
metadata: { metadata: {
version: 1, version: 1,
sourceRunId: input.run.id,
sections: [ sections: [
{ {
title: "Required action", title: "Required action",
@ -193,6 +194,7 @@ export function buildSuccessfulRunHandoffExhaustedNotice(input: {
}), }),
metadata: { metadata: {
version: 1, version: 1,
sourceRunId: input.sourceRun?.id ?? null,
sections: [ sections: [
{ {
title: "Recovery owner", title: "Recovery owner",

View file

@ -20,7 +20,7 @@ import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { ApprovalCard } from "./ApprovalCard"; import { ApprovalCard } from "./ApprovalCard";
import { AgentIcon } from "./AgentIconPicker"; import { AgentIcon } from "./AgentIconPicker";
import { formatAssigneeUserLabel } from "../lib/assignees"; 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 { timeAgo } from "../lib/timeAgo";
import { cn, formatDateTime } from "../lib/utils"; import { cn, formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft"; import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
@ -535,6 +535,21 @@ function TimelineEventCard({
</span> </span>
</div> </div>
) : null} ) : 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>
</div> </div>
); );

View file

@ -16,6 +16,7 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useId,
useImperativeHandle, useImperativeHandle,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
@ -61,7 +62,12 @@ import type {
} from "../lib/issue-thread-interactions"; } from "../lib/issue-thread-interactions";
import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions"; import { buildIssueThreadInteractionSummary, isIssueThreadInteraction } from "../lib/issue-thread-interactions";
import { resolveIssueChatTranscriptRuns } from "../lib/issueChatTranscriptRuns"; 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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@ -99,8 +105,15 @@ import {
isSuccessfulRunHandoffComment, isSuccessfulRunHandoffComment,
isSuccessfulRunHandoffEscalationComment, isSuccessfulRunHandoffEscalationComment,
} from "../lib/successful-run-handoff"; } from "../lib/successful-run-handoff";
import { SystemNotice } from "./SystemNotice"; import {
import { buildSystemNoticeProps } from "../lib/system-notice-comment"; SystemNotice,
type SystemNoticeMetadataRow,
type SystemNoticeMetadataSection,
} from "./SystemNotice";
import {
buildSystemNoticeProps,
mapCommentMetadataToSystemNoticeSections,
} from "../lib/system-notice-comment";
import type { import type {
IssueCommentMetadata, IssueCommentMetadata,
IssueCommentPresentation, IssueCommentPresentation,
@ -155,11 +168,15 @@ interface IssueChatMessageContext {
onCancelInteraction?: ( onCancelInteraction?: (
interaction: AskUserQuestionsInteraction, interaction: AskUserQuestionsInteraction,
) => Promise<void> | void; ) => Promise<void> | void;
issueStatus?: string;
successfulRunHandoff?: SuccessfulRunHandoffState | null;
} }
const IssueChatCtx = createContext<IssueChatMessageContext>({ const IssueChatCtx = createContext<IssueChatMessageContext>({
feedbackDataSharingPreference: "prompt", feedbackDataSharingPreference: "prompt",
feedbackTermsUrl: null, feedbackTermsUrl: null,
issueStatus: undefined,
successfulRunHandoff: null,
}); });
export function resolveAssistantMessageFoldedState(args: { export function resolveAssistantMessageFoldedState(args: {
@ -1968,6 +1985,227 @@ function isIssueCommentMetadata(value: unknown): value is IssueCommentMetadata {
return v.version === 1 && Array.isArray(v.sections); 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({ function SystemNoticeCommentRow({
message, message,
anchorId, anchorId,
@ -1975,7 +2213,7 @@ function SystemNoticeCommentRow({
message: ThreadMessage; message: ThreadMessage;
anchorId?: string; anchorId?: string;
}) { }) {
const { onImageClick, agentMap } = useContext(IssueChatCtx); const { onImageClick, agentMap, issueStatus, successfulRunHandoff } = useContext(IssueChatCtx);
const custom = message.metadata.custom as Record<string, unknown>; const custom = message.metadata.custom as Record<string, unknown>;
const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null; const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null;
const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : 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") .filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text) .map((p) => p.text)
.join("\n\n"); .join("\n\n");
const staleSuccessfulRunHandoffNotice = isStaleSuccessfulRunHandoffNotice({
bodyText,
issueStatus,
successfulRunHandoff,
runId,
metadata: commentMetadata,
});
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [copiedLink, setCopiedLink] = 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 ( return (
<div id={anchorId} className="group"> <div id={anchorId} className="group">
<div className="py-1"> <div className="py-1">
@ -2105,6 +2361,7 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
to: IssueTimelineAssignee; to: IssueTimelineAssignee;
} }
: null; : null;
const workspaceChange = isTimelineWorkspaceChange(custom.workspaceChange) ? custom.workspaceChange : null;
const interaction = isIssueThreadInteraction(custom.interaction) const interaction = isIssueThreadInteraction(custom.interaction)
? custom.interaction ? custom.interaction
: null; : null;
@ -2192,6 +2449,21 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
</span> </span>
</div> </div>
) : null} ) : 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> </div>
); );
@ -3855,6 +4127,8 @@ export function IssueChatThread({
onRejectInteraction: stableOnRejectInteraction, onRejectInteraction: stableOnRejectInteraction,
onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers, onSubmitInteractionAnswers: stableOnSubmitInteractionAnswers,
onCancelInteraction: stableOnCancelInteraction, onCancelInteraction: stableOnCancelInteraction,
issueStatus,
successfulRunHandoff,
}), }),
[ [
feedbackDataSharingPreference, feedbackDataSharingPreference,
@ -3875,6 +4149,8 @@ export function IssueChatThread({
stableOnRejectInteraction, stableOnRejectInteraction,
stableOnSubmitInteractionAnswers, stableOnSubmitInteractionAnswers,
stableOnCancelInteraction, stableOnCancelInteraction,
issueStatus,
successfulRunHandoff,
], ],
); );

View file

@ -7,7 +7,7 @@ import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueChatThread } from "./IssueChatThread"; import { IssueChatThread } from "./IssueChatThread";
import type { IssueChatComment } from "../lib/issue-chat-messages"; 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", () => ({ vi.mock("@assistant-ui/react", () => ({
AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>, AssistantRuntimeProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
@ -70,7 +70,14 @@ afterEach(() => {
container.remove(); 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(() => { act(() => {
root.render( root.render(
<MemoryRouter> <MemoryRouter>
@ -82,7 +89,9 @@ function renderThread(comments: IssueChatComment[], agentMap?: Map<string, Agent
onAdd={async () => {}} onAdd={async () => {}}
showComposer={false} showComposer={false}
enableLiveTranscriptPolling={false} enableLiveTranscriptPolling={false}
agentMap={agentMap} agentMap={options.agentMap}
issueStatus={options.issueStatus}
successfulRunHandoff={options.successfulRunHandoff}
/> />
</MemoryRouter>, </MemoryRouter>,
); );
@ -265,7 +274,7 @@ describe("IssueChatThread system notice routing", () => {
...baseTimestamps, ...baseTimestamps,
}; };
renderThread([comment], agentMap); renderThread([comment], { agentMap });
const status = container.querySelector('[role="status"]'); const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull(); expect(status).not.toBeNull();
@ -395,4 +404,80 @@ describe("IssueChatThread system notice routing", () => {
expect(container.querySelector('[role="status"]')).toBeNull(); expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-message-role="assistant"]')).not.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");
});
}); });

View file

@ -455,6 +455,11 @@ function createTimelineEventMessage(args: {
: (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId, userLabelMap) ?? "Unassigned"); : (formatAssigneeUserLabel(event.assigneeChange.to.userId, currentUserId, userLabelMap) ?? "Unassigned");
lines.push(`Assignee: ${from} -> ${to}`); lines.push(`Assignee: ${from} -> ${to}`);
} }
if (event.workspaceChange) {
lines.push(
`Workspace: ${event.workspaceChange.from.label ?? "none"} -> ${event.workspaceChange.to.label ?? "none"}`,
);
}
const message: ThreadSystemMessage = { const message: ThreadSystemMessage = {
id: `activity:${event.id}`, id: `activity:${event.id}`,
@ -471,6 +476,7 @@ function createTimelineEventMessage(args: {
actorId: event.actorId, actorId: event.actorId,
statusChange: event.statusChange ?? null, statusChange: event.statusChange ?? null,
assigneeChange: event.assigneeChange ?? null, assigneeChange: event.assigneeChange ?? null,
workspaceChange: event.workspaceChange ?? null,
followUpRequested: event.followUpRequested === true, followUpRequested: event.followUpRequested === true,
}, },
}, },

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", () => { it("synthesizes non-status follow-up rows from comment activity", () => {
const events = extractIssueTimelineEvents([ 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([ const events = extractIssueTimelineEvents([
{ {
id: "evt-title", id: "evt-title",

View file

@ -19,10 +19,26 @@ export interface IssueTimelineEvent {
from: IssueTimelineAssignee; from: IssueTimelineAssignee;
to: IssueTimelineAssignee; to: IssueTimelineAssignee;
}; };
workspaceChange?: {
from: IssueTimelineWorkspace;
to: IssueTimelineWorkspace;
};
commentId?: string | null; commentId?: string | null;
followUpRequested?: boolean; 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 { function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null; if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>; 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; 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[]) { function sortTimelineEvents<T extends { createdAt: Date | string; id: string }>(events: T[]) {
return [...events].sort((a, b) => { return [...events].sort((a, b) => {
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt); 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); events.push(timelineEvent);
} }
} }

View file

@ -404,6 +404,7 @@ const issueChatComments: IssueChatComment[] = [
}, },
metadata: { metadata: {
version: 1, version: 1,
sourceRunId: "run-issue-chat-01",
sections: [ sections: [
{ {
title: "Required action", 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[] = [ const issueLinkedRuns: IssueChatLinkedRun[] = [
{ {
runId: "run-issue-chat-01", 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() { function ChatCommentsStories() {
return ( return (
<div className="paperclip-story"> <div className="paperclip-story">
@ -771,3 +876,7 @@ export const IssueChatWithTimeline: Story = {
</div> </div>
), ),
}; };
export const IssueThreadNotices: Story = {
render: () => <IssueThreadNoticeReview />,
};