[codex] Polish operator UI work state details (#9320)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators use the issue list, blocked-work notices, pipeline body documents, and issue documents to understand what agents are doing > - Some of those surfaces had low-information details: live-work blockers were visually easy to miss, revision history showed generic actor labels, and selected active subtasks carried an extra accent border > - These details matter because Paperclip's default UI should make agent state and work history legible without requiring users to inspect raw logs > - This pull request groups a small set of related operator UI polish changes for those work-state and document-history surfaces > - The benefit is clearer review context for blocked work, better attribution in document revision history, and cleaner active-subtask row styling ## Linked Issues or Issue Description No public GitHub issue found during dedup search. ### Bug Report **Pre-submission checklist** - Searched existing open PRs/issues for `document revision authors` and `blocked notice live work`; no duplicates found. - Reproduced against the current `master` base for this PR. - Confirmed this is core board UI behavior, not adapter/provider/local configuration. **What happened?** Blocked live-work notices, document revision history, pipeline body document revision history, and active subtask rows exposed technically correct but low-signal UI details. Revision menus could show generic `Board`/`Agent` labels instead of the specific author, and selected active subtasks had an extra accent border. **Expected behavior** Operators should see clear blocked-live-work copy, recognizable revision authors using available agent/user profile data, and visually consistent selected task rows. **Steps to reproduce** 1. Open an issue with a live-work blocker and inspect the blocked notice. 2. Open an issue document revision menu with agent/user-authored revisions. 3. Open a pipeline item body document revision menu with agent/user-authored revisions. 4. Inspect a selected active subtask row in the issue list. **Paperclip version or commit** `8b6a06ee2` (`origin/master` at branch creation). **Deployment mode** Local dev / self-hosted board UI. **Installation method** Built from source. **Agent adapter(s) involved** Not adapter-specific (core board UI). **Database mode** Not database-related. **Access context** Board operator UI. **Relevant logs or output** Not applicable. **Relevant config** Not applicable. **Additional context** This PR is a small polish/fix contribution. `ROADMAP.md` allows tightly scoped bugs and polish without roadmap-level coordination. **Privacy checklist** Reviewed the PR body for private instance references and omitted internal ticket links. ## What Changed - Polished the live-work blocked notice copy and tests so operators get a clearer explanation of what is blocking progress. - Show issue document revision authors with agent/user names and avatar treatment instead of generic actor labels. - Show pipeline body document revision authors with the same available agent/user profile data. - Removed the extra left accent border from selected active subtask rows. - Stabilized the document revision test helper for React runtimes where `act` is not exported as a function. ## Verification - `git diff --check origin/master..HEAD` - `git diff --name-only origin/master..HEAD -- .github/workflows pnpm-lock.yaml` produced no output. - `pnpm check:token-gates` passed: all color literal, arbitrary bracket value, and raw font-size gates clean. - `pnpm exec vitest run ui/src/components/IssueBlockedNotice.test.tsx ui/src/components/IssueDocumentsSection.test.tsx ui/src/components/IssueRow.test.tsx` passed: 3 test files, 32 tests. - `pnpm --filter @paperclipai/ui typecheck` passed. - Remote PR checks passed on head `3e18fe3f`: 22 complete, 0 pending, 0 failing. - Greptile completed at 5/5 with no blocking issues after addressing the pipeline revision author feedback. ## Risks Low risk. The change is limited to board UI rendering and component tests. The main risk is a subtle visual regression in issue/document surfaces that are not covered by screenshots; the affected component tests cover the intended copy, attribution, and row-style behavior. > 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 coding agent, GPT-5 model family, tool-enabled CLI session. Exact runtime context window is not exposed by this execution environment. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [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
4856558fd9
commit
9acce52aa5
|
|
@ -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 (
|
||||
<Avatar size="xs" shape={actor.kind === "agent" ? "square" : "circle"} className="shrink-0">
|
||||
{actor.kind === "agent" ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={actor.agentIcon} className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<>
|
||||
{actor.imageUrl ? <AvatarImage src={actor.imageUrl} alt={actor.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(actor.name)}</AvatarFallback>
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentFrameHeader({
|
||||
documentKey,
|
||||
documentLabel,
|
||||
|
|
@ -124,9 +151,12 @@ export function DocumentFrameHeader({
|
|||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{relativeTime(revision.createdAt)} • {revision.actorLabel}
|
||||
</span>
|
||||
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-(length:--text-micro) text-muted-foreground">
|
||||
<RevisionActorAvatar actor={revision.actor} />
|
||||
<span className="truncate">
|
||||
{relativeTime(revision.createdAt)} • {revision.actor.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<IssueBlockedNotice
|
||||
issueStatus="blocked"
|
||||
liveIssueIds={new Set(["terminal-live"])}
|
||||
blockerAttention={{
|
||||
state: "covered",
|
||||
reason: "active_dependency",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "TASK-99",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
}}
|
||||
blockers={[
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "TASK-1",
|
||||
title: "Queued dependency",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
terminalBlockers: [
|
||||
{
|
||||
id: "terminal-live",
|
||||
identifier: "TASK-99",
|
||||
title: "External running task",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
allBlockers={[
|
||||
{
|
||||
id: "blocker-1",
|
||||
identifier: "TASK-1",
|
||||
title: "Queued dependency",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
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", () => {
|
||||
|
|
|
|||
|
|
@ -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<WaitingStepStatus, number> = {
|
|||
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"
|
||||
>
|
||||
<StatusGlyph
|
||||
status={blocker.status}
|
||||
size="sm"
|
||||
title={`${waitingTaskStatusLabel(blocker.status)} status`}
|
||||
/>
|
||||
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
|
||||
<span className="max-w-(--sz-18rem) truncate font-sans text-(length:--text-micro) text-blue-800 dark:text-blue-200">
|
||||
{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<string>();
|
||||
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 }) => (
|
||||
<div key={blocker.id} className="flex items-stretch gap-2">
|
||||
<div className="flex w-3.5 flex-col items-center">
|
||||
<span className="mt-0.5">
|
||||
<span className="flex min-h-6 items-center">
|
||||
<WaitingStepGlyph status={status} />
|
||||
</span>
|
||||
<span
|
||||
|
|
@ -285,20 +297,14 @@ function WaitingOnLiveWorkNotice({
|
|||
/>
|
||||
</div>
|
||||
<div className="min-w-0 pb-1.5">
|
||||
{status === "running" ? (
|
||||
<div className="rounded-md border border-blue-500/60 bg-blue-100/60 p-1 dark:border-blue-400/50 dark:bg-blue-500/15">
|
||||
<WaitingChipLink blocker={blocker} running />
|
||||
</div>
|
||||
) : (
|
||||
<WaitingChipLink blocker={blocker} />
|
||||
)}
|
||||
<WaitingChipLink blocker={blocker} running={status === "running"} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-stretch gap-2">
|
||||
<div className="flex w-3.5 flex-col items-center">
|
||||
<span
|
||||
className="mt-0.5 h-3 w-3 rounded-full border border-dashed border-blue-400/60 dark:border-blue-400/50"
|
||||
className="mt-1.5 h-3 w-3 rounded-full border border-dashed border-blue-400/60 dark:border-blue-400/50"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -313,14 +319,16 @@ function WaitingOnLiveWorkNotice({
|
|||
{nowRunning.length > 0 ? (
|
||||
<div
|
||||
data-testid="issue-blocked-notice-now-running"
|
||||
className="flex flex-wrap items-center gap-1.5 pt-0.5"
|
||||
className="space-y-1 pt-0.5"
|
||||
>
|
||||
<span className="text-xs font-medium text-blue-800 dark:text-blue-200">
|
||||
<div className="text-xs font-medium text-blue-800 dark:text-blue-200">
|
||||
Now running
|
||||
</span>
|
||||
{nowRunning.map((blocker) => (
|
||||
<WaitingChipLink key={blocker.id} blocker={blocker} running />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{nowRunning.map((blocker) => (
|
||||
<WaitingChipLink key={blocker.id} blocker={blocker} running />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<T>(callback: () => T | Promise<T>): Promise<T> {
|
||||
if (typeof reactAct === "function") {
|
||||
return await (reactAct(callback) as T | Promise<T>);
|
||||
}
|
||||
|
||||
let result: T | Promise<T> | 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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDocumentsSection
|
||||
issue={issue}
|
||||
canDeleteDocuments={false}
|
||||
agentMap={new Map([["agent-1", { id: "agent-1", name: "CodexCoder", icon: "code" }]])}
|
||||
userProfileMap={new Map([["user-1", { label: "Dotta", image: "https://example.test/dotta.png" }]])}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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: "",
|
||||
|
|
|
|||
|
|
@ -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<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
},
|
||||
): 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<void>;
|
||||
extraActions?: ReactNode;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
/**
|
||||
* 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,
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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(
|
||||
<IssueRow
|
||||
issue={createIssue({ identifier: "PAP-42" })}
|
||||
checklistStepNumber="2.1"
|
||||
checklistCurrentStep
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
},
|
||||
): 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<string, Pick<Agent, "id" | "name">>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
mentions?: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]). */
|
||||
|
|
|
|||
Loading…
Reference in New Issue