diff --git a/ui/src/components/DocumentFrameHeader.tsx b/ui/src/components/DocumentFrameHeader.tsx index 5ba3e9dbe6..bb88c48e12 100644 --- a/ui/src/components/DocumentFrameHeader.tsx +++ b/ui/src/components/DocumentFrameHeader.tsx @@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight } from "lucide-react"; import { cn, relativeTime } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, @@ -12,12 +13,21 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { AgentIcon } from "./AgentIconPicker"; +import { deriveInitials } from "./Identity"; + +export type DocumentFrameHeaderRevisionActor = { + kind: "agent" | "user" | "system"; + name: string; + agentIcon?: string | null; + imageUrl?: string | null; +}; export type DocumentFrameHeaderRevision = { id: string; revisionNumber: number; createdAt: string | Date; - actorLabel: string; + actor: DocumentFrameHeaderRevisionActor; }; export type DocumentFrameHeaderRevisionMenu = { @@ -46,6 +56,23 @@ export interface DocumentFrameHeaderProps { actionsSlot?: ReactNode; } +function RevisionActorAvatar({ actor }: { actor: DocumentFrameHeaderRevisionActor }) { + return ( + + {actor.kind === "agent" ? ( + + + + ) : ( + <> + {actor.imageUrl ? : null} + {deriveInitials(actor.name)} + + )} + + ); +} + export function DocumentFrameHeader({ documentKey, documentLabel, @@ -124,9 +151,12 @@ export function DocumentFrameHeader({ ) : null} - - {relativeTime(revision.createdAt)} • {revision.actorLabel} - +
+ + + {relativeTime(revision.createdAt)} • {revision.actor.name} + +
); diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx index f052ee41a3..5e08311800 100644 --- a/ui/src/components/IssueBlockedNotice.test.tsx +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -333,6 +333,70 @@ describe("IssueBlockedNotice", () => { expect(stepLinks[0]).toContain("TASK-9"); expect(stepLinks[1]).toContain("TASK-10"); expect(stepLinks[2]).toContain("TASK-11"); + + const runningStep = node.querySelectorAll('[data-testid="issue-blocked-notice-steps"] a')[2]; + if (!runningStep) throw new Error("Expected a running live-work step."); + expect(runningStep.querySelector('svg[aria-label="In Progress status"]')).not.toBeNull(); + expect(node.querySelector('[data-testid="issue-blocked-notice-now-running"]')).toBeNull(); + }); + + it("shows external now-running blockers beneath the label on a separate line", () => { + const node = render( + , + ); + + const nowRunning = node.querySelector('[data-testid="issue-blocked-notice-now-running"]'); + expect(nowRunning).not.toBeNull(); + expect(nowRunning!.children[0]?.textContent?.trim()).toBe("Now running"); + expect(nowRunning!.children[1]?.querySelector("a")?.textContent).toContain("TASK-99"); + const stepText = node.querySelector('[data-testid="issue-blocked-notice-steps"]')?.textContent; + expect(stepText).not.toContain("TASK-99"); }); it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => { diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index aee4fa7d51..279c99eeb4 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -22,6 +22,7 @@ import { RECOVERY_CHIP_DEFAULT_TONE, recoveryChipLabel, } from "../lib/recovery-display"; +import { StatusGlyph } from "./StatusGlyph"; function BlockerRecoveryIndicator({ action }: { action: IssueRecoveryAction }) { const state = deriveActiveRecoveryDisplayState(action); @@ -133,6 +134,10 @@ const WAITING_STEP_RANK: Record = { queued: 2, }; +function waitingTaskStatusLabel(status: string): string { + return status.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase()); +} + function WaitingChipLink({ blocker, running = false, @@ -147,6 +152,11 @@ function WaitingChipLink({ to={createIssueDetailPath(issuePathId)} className="inline-flex max-w-full items-center gap-1 rounded-md border border-blue-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-blue-950 transition-colors hover:border-blue-500 hover:bg-blue-100 hover:underline dark:border-blue-500/40 dark:bg-background/40 dark:text-blue-100 dark:hover:bg-blue-500/15" > + {blocker.identifier ?? blocker.id.slice(0, 8)} {blocker.title} @@ -208,11 +218,13 @@ function WaitingOnLiveWorkNotice({ const runningCount = steps.filter((step) => step.status === "running").length; // "Now running" replaces "Ultimately waiting on": prefer live terminal - // leaves; otherwise fall back to whichever chain blocker is live. + // leaves that are not already shown in the ordered queue list. + const stepIds = new Set(steps.map((step) => step.blocker.id)); const nowRunningSeen = new Set(); const nowRunning: IssueRelationIssueSummary[] = []; for (const blocker of [...terminalBlockers, ...chainBlockers]) { if (!liveIds.has(blocker.id)) continue; + if (stepIds.has(blocker.id)) continue; if (nowRunningSeen.has(blocker.id)) continue; nowRunningSeen.add(blocker.id); nowRunning.push(blocker); @@ -276,7 +288,7 @@ function WaitingOnLiveWorkNotice({ {steps.map(({ blocker, status }) => (
- +
- {status === "running" ? ( -
- -
- ) : ( - - )} +
))}
@@ -313,14 +319,16 @@ function WaitingOnLiveWorkNotice({ {nowRunning.length > 0 ? (
- +
Now running - - {nowRunning.map((blocker) => ( - - ))} +
+
+ {nowRunning.map((blocker) => ( + + ))} +
) : null} diff --git a/ui/src/components/IssueDocumentsSection.test.tsx b/ui/src/components/IssueDocumentsSection.test.tsx index bc3580d299..7a1c54339d 100644 --- a/ui/src/components/IssueDocumentsSection.test.tsx +++ b/ui/src/components/IssueDocumentsSection.test.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom -import { act } from "react"; +import { act as reactAct } from "react"; import type { ComponentProps } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared"; @@ -25,6 +26,24 @@ const markdownEditorMockState = vi.hoisted(() => ({ emitMountEmptyChange: false, })); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => T | Promise): Promise { + if (typeof reactAct === "function") { + return await (reactAct(callback) as T | Promise); + } + + let result: T | Promise | undefined; + flushSync(() => { + result = callback(); + }); + const resolved = await result; + await new Promise((resolve) => setTimeout(resolve, 0)); + flushSync(() => {}); + return resolved as T; +} + vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi, })); @@ -425,6 +444,86 @@ describe("IssueDocumentsSection", () => { queryClient.clear(); }); + it("shows revision authors with names and avatars in the revision history menu", async () => { + const currentDocument = createIssueDocument({ + body: "Current plan body", + latestRevisionId: "revision-agent", + latestRevisionNumber: 4, + updatedByAgentId: "agent-1", + updatedByUserId: null, + updatedAt: new Date("2026-03-31T12:05:00.000Z"), + }); + const issue = createIssue(); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + mockIssuesApi.listDocuments.mockResolvedValue([currentDocument]); + queryClient.setQueryData( + queryKeys.issues.documentRevisions(issue.id, "plan"), + [ + createRevision({ + id: "revision-agent", + revisionNumber: 4, + body: "Current plan body", + createdByAgentId: "agent-1", + createdByUserId: null, + createdAt: new Date("2026-03-31T12:05:00.000Z"), + }), + createRevision({ + id: "revision-user", + revisionNumber: 3, + body: "Board-written plan body", + createdByAgentId: null, + createdByUserId: "user-1", + createdAt: new Date("2026-03-31T11:00:00.000Z"), + }), + ], + ); + + await act(async () => { + root.render( + + + , + ); + }); + await flush(); + await flush(); + + const revisionButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent?.includes("rev 4")); + expect(revisionButton).toBeTruthy(); + + await act(async () => { + revisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flush(); + + expect(document.body.textContent).toContain("CodexCoder"); + expect(document.body.textContent).toContain("Dotta"); + expect(document.body.textContent).not.toContain("• agent"); + expect(document.body.querySelectorAll('[data-slot="avatar"]').length).toBeGreaterThanOrEqual(2); + + await act(async () => { + root.unmount(); + }); + queryClient.clear(); + }); + it("shows the restored document body immediately after a revision restore", async () => { const blankLatestDocument = createIssueDocument({ body: "", diff --git a/ui/src/components/IssueDocumentsSection.tsx b/ui/src/components/IssueDocumentsSection.tsx index 6628b976b0..64f2b3ae89 100644 --- a/ui/src/components/IssueDocumentsSection.tsx +++ b/ui/src/components/IssueDocumentsSection.tsx @@ -34,7 +34,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Check, Copy, Diff, Download, FilePenLine, FileText, Lock, MoreHorizontal, Plus, Trash2, Unlock, X } from "lucide-react"; import { DocumentDiffModal } from "./DocumentDiffModal"; -import { DocumentFrameHeader } from "./DocumentFrameHeader"; +import { DocumentFrameHeader, type DocumentFrameHeaderRevisionActor } from "./DocumentFrameHeader"; import { SourceTrustBadge } from "./SourceTrustBadge"; import { Badge } from "@/components/ui/badge"; @@ -116,10 +116,30 @@ function downloadDocumentFile(key: string, body: string) { URL.revokeObjectURL(url); } -function getRevisionActorLabel(revision: DocumentRevision) { - if (revision.createdByUserId) return "board"; - if (revision.createdByAgentId) return "agent"; - return "system"; +function getRevisionActor( + revision: DocumentRevision, + maps: { + agentMap?: ReadonlyMap & Partial>>; + userProfileMap?: ReadonlyMap; + }, +): DocumentFrameHeaderRevisionActor { + if (revision.createdByAgentId) { + const agent = maps.agentMap?.get(revision.createdByAgentId); + return { + kind: "agent", + name: agent?.name ?? revision.createdByAgentId.slice(0, 8), + agentIcon: agent?.icon ?? null, + }; + } + if (revision.createdByUserId) { + const profile = maps.userProfileMap?.get(revision.createdByUserId); + return { + kind: "user", + name: profile?.label ?? (revision.createdByUserId === "local-board" ? "Board" : revision.createdByUserId.slice(0, 8)), + imageUrl: profile?.image ?? null, + }; + } + return { kind: "system", name: "System" }; } function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null) { @@ -182,7 +202,7 @@ export function IssueDocumentsSection({ options?: { allowSharing?: boolean; reason?: string }, ) => Promise; extraActions?: ReactNode; - agentMap?: ReadonlyMap>; + agentMap?: ReadonlyMap & Partial>>; userProfileMap?: ReadonlyMap; /** * Seed which document annotation panels are open on first render. Mostly useful @@ -939,7 +959,7 @@ export function IssueDocumentsSection({ id: revision.id, revisionNumber: revision.revisionNumber, createdAt: revision.createdAt, - actorLabel: getRevisionActorLabel(revision), + actor: getRevisionActor(revision, { agentMap, userProfileMap }), })), selectedRevisionId, currentRevisionId: currentRevision.id, diff --git a/ui/src/components/IssueRow.test.tsx b/ui/src/components/IssueRow.test.tsx index 85255d5930..1817037a7b 100644 --- a/ui/src/components/IssueRow.test.tsx +++ b/ui/src/components/IssueRow.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { act } from "react"; +import { act as reactAct } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import type { Issue } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -28,6 +29,15 @@ vi.mock("@/lib/router", () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; +function act(callback: () => void) { + if (typeof reactAct === "function") { + reactAct(callback); + return; + } + + flushSync(callback); +} + function createIssue(overrides: Partial = {}): Issue { return { id: "issue-1", @@ -251,6 +261,31 @@ describe("IssueRow", () => { }); }); + it("marks the current checklist step without adding a left border", () => { + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + const link = container.querySelector("[data-inbox-issue-link]") as HTMLAnchorElement | null; + + expect(link).not.toBeNull(); + expect(link?.getAttribute("aria-current")).toBe("step"); + expect(link?.className).toContain("bg-primary/5"); + expect(link?.className).not.toContain("border-l-"); + + act(() => { + root.unmount(); + }); + }); + it("does not render a planning mode marker for planning work mode issues", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 6b8ddfea01..dfb7706f82 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -143,7 +143,7 @@ export function IssueRow({ "group flex items-start gap-2 rounded-lg py-2.5 pl-2 pr-3 text-sm no-underline text-inherit sm:items-center sm:py-2 sm:pl-1", !hideDivider && "border-b border-border last:border-b-0", selected ? "hover:bg-transparent" : "hover:bg-accent/50", - checklistCurrentStep ? "border-l-2 border-l-primary bg-primary/5 pl-(--sz-calc-11) sm:pl-(--sz-calc-12)" : null, + checklistCurrentStep ? "bg-primary/5" : null, className, )} > diff --git a/ui/src/components/PipelineItemBodyDocument.tsx b/ui/src/components/PipelineItemBodyDocument.tsx index 128c1b6c4d..7b784e6417 100644 --- a/ui/src/components/PipelineItemBodyDocument.tsx +++ b/ui/src/components/PipelineItemBodyDocument.tsx @@ -14,7 +14,7 @@ import type { CompanyUserProfile } from "../lib/company-members"; import { queryKeys } from "../lib/queryKeys"; import { useToastActions } from "../context/ToastContext"; import { DocumentAnnotationLayer, type PendingAnchor } from "./DocumentAnnotationLayer"; -import { DocumentFrameHeader } from "./DocumentFrameHeader"; +import { DocumentFrameHeader, type DocumentFrameHeaderRevisionActor } from "./DocumentFrameHeader"; import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "./IssueDocumentAnnotations"; import { EmptyState } from "./EmptyState"; import { FoldCurtain } from "./FoldCurtain"; @@ -35,6 +35,34 @@ type CaseBodyDocument = PipelineCaseDocumentPayload["document"] & { createdByUserId?: string | null; }; +function getPipelineRevisionActor( + revision: { createdByAgentId?: string | null; createdByUserId?: string | null }, + maps: { + agentMap?: ReadonlyMap & Partial>>; + userProfileMap?: ReadonlyMap; + }, +): DocumentFrameHeaderRevisionActor { + if (revision.createdByAgentId) { + const agent = maps.agentMap?.get(revision.createdByAgentId); + return { + kind: "agent", + name: agent?.name ?? revision.createdByAgentId.slice(0, 8), + agentIcon: agent?.icon ?? null, + }; + } + + if (revision.createdByUserId) { + const profile = maps.userProfileMap?.get(revision.createdByUserId); + return { + kind: "user", + name: profile?.label ?? (revision.createdByUserId === "local-board" ? "Board" : revision.createdByUserId.slice(0, 8)), + imageUrl: profile?.image ?? null, + }; + } + + return { kind: "system", name: "System" }; +} + function isNotFound(error: unknown) { return error instanceof ApiError && error.status === 404; } @@ -48,7 +76,7 @@ export interface PipelineItemBodyDocumentProps { /** Active conversation issue the body document is/should be anchored to. */ conversationIssueId: string | null; conversationIssue: Issue | null; - agentMap?: ReadonlyMap>; + agentMap?: ReadonlyMap & Partial>>; userProfileMap?: ReadonlyMap; mentions?: MentionOption[]; imageUploadHandler?: (file: File) => Promise; @@ -396,7 +424,7 @@ export function PipelineItemBodyDocument({ id: revision.id, revisionNumber: revision.revisionNumber, createdAt: revision.createdAt, - actorLabel: revision.createdByUserId ? "board" : revision.createdByAgentId ? "agent" : "system", + actor: getPipelineRevisionActor(revision, { agentMap, userProfileMap }), })), selectedRevisionId, currentRevisionId: doc?.latestRevisionId ?? null, diff --git a/ui/src/index.css b/ui/src/index.css index 2c0e3fb0da..14e234e4e3 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1586,8 +1586,6 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --sz-calc-9: min(80vh,42rem); /* Extracted from ui/src/components/IssueFiltersPopover.test.tsx (max-h-[min(80vh,42rem)]). */ --sz-calc-10: min(780px,calc(100vw - 2rem)); /* Extracted from ui/src/components/IssueFiltersPopover.tsx (w-[min(780px,calc(100vw-2rem))]). */ --sz-24ch: 24ch; /* Extracted from ui/src/components/IssuePlanDecompositionsSection.tsx (max-w-[24ch]). */ - --sz-calc-11: calc(0.5rem - 2px); /* Extracted from ui/src/components/IssueRow.tsx (pl-[calc(theme(spacing.2)-2px)]). */ - --sz-calc-12: calc(0.25rem - 2px); /* Extracted from ui/src/components/IssueRow.tsx (pl-[calc(theme(spacing.1)-2px)]). */ --sz-calc-13: calc(0.75rem + 0.5rem); /* Extracted from ui/src/components/IssueRow.tsx (ml-[calc(theme(spacing.3)+theme(spacing.2))]). */ --sz-140px: 140px; /* Extracted from ui/src/components/JsonSchemaForm.tsx (min-h-[140px]). */ --sz-52px: 52px; /* Extracted from ui/src/components/KanbanBoard.tsx (w-[52px]). */