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]). */