diff --git a/docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png b/docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png new file mode 100644 index 00000000..0dcefcc3 Binary files /dev/null and b/docs/pr-screenshots/pr-5356/issue-thread-notices-collapsed.png differ diff --git a/docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png b/docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png new file mode 100644 index 00000000..9213270e Binary files /dev/null and b/docs/pr-screenshots/pr-5356/issue-thread-notices-expanded.png differ diff --git a/packages/db/src/migrations/0078_white_darwin.sql b/packages/db/src/migrations/0078_white_darwin.sql index 34c67985..ddf86bd4 100644 --- a/packages/db/src/migrations/0078_white_darwin.sql +++ b/packages/db/src/migrations/0078_white_darwin.sql @@ -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; \ No newline at end of file +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; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 59d9f169..2f09515a 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -434,6 +434,7 @@ export interface IssueCommentMetadataSection { export interface IssueCommentMetadata { version: 1; + sourceRunId?: string | null; sections: IssueCommentMetadataSection[]; } diff --git a/packages/shared/src/validators/issue.test.ts b/packages/shared/src/validators/issue.test.ts index ad5fbc53..a8d26845 100644 --- a/packages/shared/src/validators/issue.test.ts +++ b/packages/shared/src/validators/issue.test.ts @@ -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); }); diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index d1f9af21..d7e26e76 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -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(); diff --git a/server/src/__tests__/issue-activity-events-routes.test.ts b/server/src/__tests__/issue-activity-events-routes.test.ts index dbd15303..14349614 100644 --- a/server/src/__tests__/issue-activity-events-routes.test.ts +++ b/server/src/__tests__/issue-activity-events-routes.test.ts @@ -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) => ({ + ...issue, + ...patch, + updatedAt: new Date(), + })); + + const dbMock = { + select: vi.fn(() => ({ + from: (table: unknown) => ({ + where: async () => { + const tableName = getTableName(table as Parameters[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); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 69d206d9..4e568c15 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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) { + 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; + executionWorkspaceNames: Map; +}; + +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 diff --git a/server/src/services/recovery/successful-run-handoff.test.ts b/server/src/services/recovery/successful-run-handoff.test.ts index b5e25b74..727512f4 100644 --- a/server/src/services/recovery/successful-run-handoff.test.ts +++ b/server/src/services/recovery/successful-run-handoff.test.ts @@ -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", diff --git a/server/src/services/recovery/successful-run-handoff.ts b/server/src/services/recovery/successful-run-handoff.ts index 2d5b79ff..1b9bbe18 100644 --- a/server/src/services/recovery/successful-run-handoff.ts +++ b/server/src/services/recovery/successful-run-handoff.ts @@ -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", diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index 080b3e97..e80abea7 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -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({ ) : null} + + {event.workspaceChange ? ( +
+ + Workspace + + + {formatTimelineWorkspaceLabel(event.workspaceChange.from)} + + + + {formatTimelineWorkspaceLabel(event.workspaceChange.to)} + +
+ ) : null} ); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 792b129f..9ac472f5 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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; + issueStatus?: string; + successfulRunHandoff?: SuccessfulRunHandoffState | null; } const IssueChatCtx = createContext({ 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 = ( + + {row.label} + + ); + const value = (() => { + switch (row.kind) { + case "text": + return {row.value}; + case "code": + return ( + + {row.value} + + ); + case "issue": { + const content = ( + <> + {row.identifier} + {row.title ? - {row.title} : null} + + ); + return row.href ? ( + + {content} + + ) : ( + {content} + ); + } + case "agent": + return row.href ? ( + + {row.name} + + ) : ( + {row.name} + ); + case "run": { + const runShort = row.runId.length > 12 ? `${row.runId.slice(0, 8)}...` : row.runId; + const content = ( + <> + + {runShort} + + {row.status ? {row.status} : null} + + ); + return row.href ? ( + + {content} + + ) : ( + {content} + ); + } + } + })(); + + return ( +
+ {label} +
{value}
+
+ ); +} + +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; + return isNullableString(workspace.label) + && isNullableString(workspace.projectWorkspaceId) + && isNullableString(workspace.executionWorkspaceId) + && isNullableString(workspace.mode); +} + +function isTimelineWorkspaceChange(value: unknown): value is NonNullable { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const change = value as Record; + return isTimelineWorkspace(change.from) && isTimelineWorkspace(change.to); +} + +function StaleDispositionWarningDetails({ + sections, +}: { + sections: SystemNoticeMetadataSection[]; +}) { + if (sections.length === 0) { + return
No additional details.
; + } + + return ( +
+ {sections.map((section) => ( +
+ {section.title ? ( +
+ {section.title} +
+ ) : null} +
+ {section.rows.map((row) => ( + + ))} +
+
+ ))} +
+ ); +} + +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 ( +
+
+ +
+ + +
+
+
+ ); +} + 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; 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 ( + + ); + } + return (
@@ -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 }) {
) : null} + + {workspaceChange ? ( +
+ + Workspace + + + {formatTimelineWorkspaceLabel(workspaceChange.from)} + + + + {formatTimelineWorkspaceLabel(workspaceChange.to)} + +
+ ) : null}
); @@ -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, ], ); diff --git a/ui/src/components/IssueChatThreadSystemNotice.test.tsx b/ui/src/components/IssueChatThreadSystemNotice.test.tsx index 01453d38..a1173df4 100644 --- a/ui/src/components/IssueChatThreadSystemNotice.test.tsx +++ b/ui/src/components/IssueChatThreadSystemNotice.test.tsx @@ -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 }) =>
{children}
, @@ -70,7 +70,14 @@ afterEach(() => { container.remove(); }); -function renderThread(comments: IssueChatComment[], agentMap?: Map) { +function renderThread( + comments: IssueChatComment[], + options: { + agentMap?: Map; + issueStatus?: string; + successfulRunHandoff?: SuccessfulRunHandoffState | null; + } = {}, +) { act(() => { root.render( @@ -82,7 +89,9 @@ function renderThread(comments: IssueChatComment[], agentMap?: Map {}} showComposer={false} enableLiveTranscriptPolling={false} - agentMap={agentMap} + agentMap={options.agentMap} + issueStatus={options.issueStatus} + successfulRunHandoff={options.successfulRunHandoff} /> , ); @@ -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"); + }); }); diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index ef50a4e6..59d0ac90 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -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, }, }, diff --git a/ui/src/lib/issue-timeline-events.test.ts b/ui/src/lib/issue-timeline-events.test.ts index 3b073954..4458be79 100644 --- a/ui/src/lib/issue-timeline-events.test.ts +++ b/ui/src/lib/issue-timeline-events.test.ts @@ -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", diff --git a/ui/src/lib/issue-timeline-events.ts b/ui/src/lib/issue-timeline-events.ts index d1d12b11..4ade3db4 100644 --- a/ui/src/lib/issue-timeline-events.ts +++ b/ui/src/lib/issue-timeline-events.ts @@ -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 | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -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) { + 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(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); } } diff --git a/ui/storybook/stories/chat-comments.stories.tsx b/ui/storybook/stories/chat-comments.stories.tsx index cb5431da..041293f9 100644 --- a/ui/storybook/stories/chat-comments.stories.tsx +++ b/ui/storybook/stories/chat-comments.stories.tsx @@ -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 ( +
+
+
+
+ {}} + enableLiveTranscriptPolling={false} + showJumpToLatest={false} + /> +
+
+
+
+ ); +} + function ChatCommentsStories() { return (
@@ -771,3 +876,7 @@ export const IssueChatWithTimeline: Story = {
), }; + +export const IssueThreadNotices: Story = { + render: () => , +};