forked from farhoodlabs/paperclip
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:  Expanded stale notice details:  ## 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:
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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user