mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
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:
parent
4103978578
commit
d0e9cc76f2
17 changed files with 852 additions and 36 deletions
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue