Files
PaperClipAI/ui/src/lib/recent-tasks.test.ts
T
DottaandClaude Opus 5.5 d807887534 fix(ui): use one status glyph mapping for every task view (#19329)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task status is the main signal on every task surface: sidebar rows,
the task header, the status menu, lists, the inbox, pills and chat
cards.
> - One task state rendered different status icons in different views. A
blocked task that waits on an active sub-task showed the blue "in queue"
glyph in the task header. The status menu, the chat chip and the sidebar
rows showed the red "Blocked" glyph for the same task.
> - The cause: each view passed its own subset of the presentation
fields to `StatusIcon`. Many views dropped `blockerAttention` and some
dropped `externalConversationState`. The status menu rendered its
current row from the bare status. The summaries API did not return
`blockerAttention`, so the recent-task sidebar could not know the state.
> - This pull request puts the state-to-glyph/label mapping in one
resolver and makes every task view pass all of its fields.
> - The benefit is that the same state uses the same icon and label
everywhere.

## Linked Issues or Issue Description

No public issue. Found in a hands-on onboarding test on a staging cloud
workspace.

**What happened?**

The sidebar and the task pane/status menu showed different status icons
for the same state. On the test task (blocked, waiting on an active
sub-task), the task header and the status button showed the blue "in
queue" glyph. The selected "Blocked" row in the status menu showed the
red blocked glyph. The coordinator sidebar "Recents" and project rows
also showed the red blocked glyph, and the chat "Subtask created" chip
showed a red "Blocked" chip.

**Expected behavior**

The same underlying state uses the same icon and label everywhere.

**Steps to reproduce**

1. Create a task with a sub-task. Block the parent on the sub-task, and
let the sub-task's agent run (the parent is "blocked, covered").
2. Open the parent task. The header shows the blue "in queue" glyph.
3. Open the status menu. The current "Blocked" row shows the red glyph.
4. With the coordinator sidebar on, look at the task in Recents or in
its expanded project. It shows the red glyph.

**Paperclip version or commit**

`master` at 03eb49053f.

## What Changed

- Add `ui/src/lib/task-status-presentation.ts`.
`resolveTaskStatusPresentation()` is the one mapping from `status`,
`displayStatus`, `externalConversationState` and `blockerAttention` to
glyph, label and accessible copy. `taskStatusIconProps()` picks all four
fields from a task record.
- `StatusIcon` and `IssueStatusBadge` use the resolver. The status menu
draws the current row with the trigger's presentation (in queue, idle,
monitoring).
- `IssueStatusBadge` now accepts `blockerAttention`.
`taskStatusVar.in_queue` maps to the existing in-progress chip hue, the
same as the existing `--status-task-icon-in_queue` icon token.
- Task call sites spread `taskStatusIconProps(task)`: coordinator
sidebar recents and project rows, classic recent tasks, lists, rows,
columns, inbox, blocked inbox, my issues, dashboard, properties,
relation pills, search, sibling navigation, chat chips, case summaries,
status cards and user profile. Step/check statuses in protocol cards
stay unchanged because they are not task statuses.
- `GET /api/companies/:companyId/issues/summaries` returns
`blockerAttention` for blocked rows (at most five IDs, through the
existing `listBlockerAttention` service). `IssueSummary` gets the
optional field.
- Recent-task snapshots store and refresh `blockerAttention`. Sidebar
project rows request the `rows` projection, which includes blocker state
(the `index` projection skips it).
- Storybook: the Foundations/Status Language story gets a "Same state,
every view" section.

## Verification

Red on `master` (tests written first, then run against the unchanged
code):

```
cd ui && npx vitest run src/components/StatusIcon.picker.test.tsx src/components/CoordinatorSidebar.test.tsx src/lib/recent-tasks.test.ts
     × keeps the covered-blocker state so the sidebar shows the same queued glyph as the task pane (BUG-07)
     × marks the current row with the same glyph as the trigger for a covered-blocked task
     × marks the current row with the same glyph as the trigger for a waiting review and a monitored task
     × shows a covered-blocked recent task with the same queued glyph and label as the task pane
     × shows project tasks with their full status presentation
 FAIL  StatusIcon.picker.test.tsx > ... covered-blocked task
AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)'
 FAIL  StatusIcon.picker.test.tsx > ... waiting review and a monitored task
AssertionError: expected 'var(--status-task-icon-in_review)' to be 'var(--status-task-icon-backlog)'
 FAIL  CoordinatorSidebar.test.tsx > ... covered-blocked recent task ...
AssertionError: expected 'var(--status-task-icon-blocked)' to be 'var(--status-task-icon-in_queue)'
 FAIL  CoordinatorSidebar.test.tsx > ... project tasks with their full status presentation
AssertionError: expected "vi.fn()" to be called with arguments: [ 'company', ObjectContaining{…} ]
 FAIL  recent-tasks.test.ts > ... keeps the covered-blocker state ...
AssertionError: expected undefined to deeply equal { state: 'covered', …(7) }
      Tests  5 failed | 32 passed (37)

cd server && npx vitest run src/__tests__/issue-summaries-routes.test.ts
     × projects the covered-blocker state so every task view picks the same queued glyph (BUG-07)
AssertionError: expected undefined to match object { state: 'covered', …(2) }
      Tests  1 failed | 14 passed (15)
```

Green with this change (same commands):

```
 Test Files  3 passed (3)
      Tests  37 passed (37)
 Test Files  1 passed (1)        # server summaries route
      Tests  15 passed (15)
```

Neighbouring and broader checks:

- `StatusIcon`, `StatusBadge`, `StatusGlyph`, picker, sidebar and
recent-task suites: 73/73 pass.
- Full UI Vitest run: 11106 pass, 1 skipped. Three `IssuesList.test.tsx`
tests timed out at 5 s under full-suite load. The same file passes 73/73
when run alone.
- `tsc --noEmit` for `ui` and `server`: no errors in changed files.
Remaining errors are pre-existing (unbuilt
`@paperclipai/paperclip-runner`, generated Storybook catalog).
- `pnpm check:token-gates`: all gates clean.

Screenshots. The staging task before the fix: the status button is blue,
the current "Blocked" row in the menu is red.

| Staging task (before) | Staging status menu (before) |
| --- | --- |
| ![staging
before](https://raw.githubusercontent.com/paperclipai/paperclip/75e148aa1b100400d406d952e66cfac6c4afa5c3/before-staging-onb1.jpg)
| ![staging menu
before](https://raw.githubusercontent.com/paperclipai/paperclip/75e148aa1b100400d406d952e66cfac6c4afa5c3/before-staging-onb1-menu.png)
|

Storybook "Same state, every view" (Foundations/Status Language). Before
uses the `master` `StatusIcon`/`StatusBadge`; after uses this branch.

| Before (master) | After (this PR) |
| --- | --- |
| ![storybook
before](https://raw.githubusercontent.com/paperclipai/paperclip/75e148aa1b100400d406d952e66cfac6c4afa5c3/before-storybook-menu.jpg)
| ![storybook
after](https://raw.githubusercontent.com/paperclipai/paperclip/75e148aa1b100400d406d952e66cfac6c4afa5c3/after-storybook-menu.jpg)
|

Before: the row glyph is blue, but the chip and the current menu row are
red. After: the row glyph, the chip and the current menu row are all the
blue "in queue" glyph.

Review follow-up (commits 1d3a1a0a9e, d5c798ec53): blocker state on
summaries, search and case responses is glyph-only (state, reason,
counts). Blocker titles and IDs are never returned; a sampled identifier
stays only when the actor can read that blocker. Producer-level tests
failed before the follow-up and pass after it
(\`task-created-items.test.tsx\`,
\`CoordinatorTaskCommitment.test.tsx\`,
\`company-search-service.test.ts\`, \`cases-routes.test.ts\`,
private-blocker case in \`issue-summaries-routes.test.ts\`). Before
merge, the branch was test-merged locally with current \`master\`:
typecheck clean, 1095 UI and 80 server tests in the affected suites
pass.

## Risks

- Low risk. Presentation only. Status mutations still use the canonical
`status`.
- The summaries endpoint now runs the blocker-attention query for
blocked rows. The endpoint accepts at most five IDs and runs the query
only when a row is blocked.
- Sidebar project rows use the `rows` projection instead of `index`.
This adds the blocker and review attention reads for at most 12 rows,
and only when a person expands a project.
- `IssueStatusBadge` shows the blue "in queue" chip for covered-blocked
tasks where callers pass `blockerAttention`. The label stays "Blocked"
and the full reason is in the tooltip.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`, Anthropic), through Claude Code
with tool use (shell, file edits, browser). Default context window.

## 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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:42:11 -06:00

283 lines
14 KiB
TypeScript

import { clearIssueExecutionRun } from "./optimistic-issue-runs";
import type { Issue } from "@paperclipai/shared";
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
RECENT_TASKS_LIMIT,
RECENT_TASKS_UPDATED_EVENT,
getRecentTasksStorageKey,
migrateRecentTasks,
pruneRecentTasks,
readRecentTasks,
recordRecentTask,
updateRecentTaskSnapshots,
writeRecentTasks,
} from "./recent-tasks";
const issue = (id: string, companyId = "company-1") => ({
id,
companyId,
title: `Task ${id}`,
identifier: `PAP-${id}`,
status: "todo" as const,
updatedAt: new Date(0),
});
// Support both jsdom Storage (Node 24) and the setup's storage shim (newer Node).
const storageMethods = window.localStorage instanceof window.Storage ? window.Storage.prototype : window.localStorage;
describe("recent task persistence", () => {
beforeEach(() => window.localStorage.clear());
it("is account and company scoped", () => {
expect(getRecentTasksStorageKey("company-1", "user-1")).not.toBe(
getRecentTasksStorageKey("company-1", "user-2"),
);
expect(getRecentTasksStorageKey("company-1", "user-1")).not.toBe(
getRecentTasksStorageKey("company-2", "user-1"),
);
});
it("deduplicates, only promotes newer activity, and stays bounded", () => {
for (let index = 0; index < RECENT_TASKS_LIMIT + 2; index += 1) {
recordRecentTask(issue(String(index)), "user-1", index);
}
recordRecentTask(issue("4"), "user-1", 4);
let entries = readRecentTasks(getRecentTasksStorageKey("company-1", "user-1"), "company-1");
expect(entries[0]?.id).toBe("6");
recordRecentTask(issue("4"), "user-1", 99);
entries = readRecentTasks(getRecentTasksStorageKey("company-1", "user-1"), "company-1");
expect(entries).toHaveLength(RECENT_TASKS_LIMIT);
expect(entries[0]?.id).toBe("4");
expect(entries.filter((entry) => entry.id === "4")).toHaveLength(1);
});
it("refreshes conversation readiness when review or delivery changes without a task edit", () => {
const task = { ...issue("1"), status: "in_review" as const, externalConversationState: "waiting" as const };
recordRecentTask(task, "user-1");
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...task, externalConversationState: "active" }]);
expect(readRecentTasks(storageKey, "company-1")[0]?.externalConversationState).toBe("active");
});
it("keeps the covered-blocker state so the sidebar shows the same queued glyph as the task pane (BUG-07)", () => {
const covered = {
state: "covered" as const, reason: "active_child" as const, unresolvedBlockerCount: 1, coveredBlockerCount: 1,
attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null,
};
const blocked = { ...issue("1"), status: "blocked" as const, updatedAt: new Date(10) };
recordRecentTask({ ...blocked, blockerAttention: covered }, "user-1");
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
expect(readRecentTasks(storageKey, "company-1")[0]?.blockerAttention).toEqual(covered);
// A same-version summary refresh can change only the derived blocker state.
const attention = { ...covered, state: "needs_attention" as const, reason: "attention_required" as const, coveredBlockerCount: 0, attentionBlockerCount: 1 };
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...blocked, externalConversationState: null, blockerAttention: attention }]);
expect(readRecentTasks(storageKey, "company-1")[0]?.blockerAttention).toEqual(attention);
});
it("accepts server settlement after an optimistic run-lock clear", () => {
const running = { ...issue("1"), status: "in_progress" as const, externalConversationState: "active" as const, executionRunId: "run-1", updatedAt: new Date(10) } as Issue;
recordRecentTask(running, "user-1");
recordRecentTask(clearIssueExecutionRun(running, "run-1")!, "user-1");
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...running, status: "in_review", externalConversationState: "waiting", updatedAt: new Date(20) }]);
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ status: "in_review", externalConversationState: "waiting", snapshotUpdatedAt: 20 });
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...running, updatedAt: new Date(30) }]);
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...running, status: "in_review", externalConversationState: "waiting", updatedAt: new Date(20) }]);
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ status: "in_progress", externalConversationState: "active", snapshotUpdatedAt: 30 });
});
it("keeps readiness and task status together when a turn settles at the same timestamp", () => {
const task = { ...issue("1"), status: "in_progress" as const, externalConversationState: "active" as const };
recordRecentTask(task, "user-1");
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...task, status: "in_review", externalConversationState: "waiting" }]);
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ status: "in_review", externalConversationState: "waiting" });
});
it("refreshes Monitoring when a run starts without a new task version", () => {
const task = { ...issue("1"), status: "in_progress" as const, displayStatus: "monitoring" as const, externalConversationState: null };
recordRecentTask(task, "user-1");
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...task, displayStatus: "in_progress" }]);
expect(readRecentTasks(storageKey, "company-1")[0]?.displayStatus).toBe("in_progress");
});
it("publishes same-tab updates", () => {
const listener = vi.fn();
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
recordRecentTask(issue("1"), "user-1");
expect(listener).toHaveBeenCalledOnce();
listener.mockClear();
recordRecentTask(issue("1"), "user-1");
expect(listener).not.toHaveBeenCalled();
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
});
it("records a new display status without a task edit or newer activity", () => {
const task = { ...issue("1"), status: "in_progress" as const, displayStatus: "monitoring" as const };
recordRecentTask(task, "user-1");
recordRecentTask({ ...task, displayStatus: "in_progress" }, "user-1");
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
expect(readRecentTasks(storageKey, "company-1")[0]?.displayStatus).toBe("in_progress");
});
it("prunes unavailable tasks and refreshes stored snapshots", () => {
recordRecentTask(issue("1"), "user-1", 1);
recordRecentTask(issue("2"), "user-1", 2);
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [{
...issue("2"),
title: "Updated task",
status: "in_progress",
updatedAt: new Date(3),
}]);
pruneRecentTasks(storageKey, "company-1", new Set(["1"]));
expect(readRecentTasks(storageKey, "company-1")).toEqual([
expect.objectContaining({
id: "2",
title: "Updated task",
status: "in_progress",
recordedAt: 3,
}),
]);
});
it("does not demote recent comment activity when older task details arrive", () => {
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
recordRecentTask({ ...issue("1"), updatedAt: new Date(50) }, "user-1", 100);
recordRecentTask({ ...issue("2"), updatedAt: new Date(80) }, "user-1", 90);
const listener = vi.fn();
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
for (let index = 0; index < 10; index += 1) {
recordRecentTask({ ...issue("1"), updatedAt: new Date(50) }, "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [
{ ...issue("1"), updatedAt: new Date(50) },
{ ...issue("2"), updatedAt: new Date(80) },
]);
}
expect(readRecentTasks(storageKey, "company-1").map(({ id, recordedAt }) => ({ id, recordedAt }))).toEqual([
{ id: "1", recordedAt: 100 },
{ id: "2", recordedAt: 90 },
]);
expect(listener).not.toHaveBeenCalled();
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
updateRecentTaskSnapshots(storageKey, "company-1", [{
...issue("1"), title: "Renamed task", status: "done", updatedAt: new Date(60),
}]);
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({
id: "1", title: "Renamed task", status: "done", recordedAt: 100,
});
});
it("ignores malformed and cross-company entries", () => {
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
window.localStorage.setItem(storageKey, JSON.stringify([
issue("other", "company-2"),
{ nonsense: true },
{ ...issue("invalid-version"), recordedAt: 1, snapshotUpdatedAt: "invalid" },
]));
expect(readRecentTasks(storageKey, "company-1")).toEqual([]);
});
it("settles conflicting tab snapshots without further writes or notifications", () => {
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
const older = { ...issue("1"), title: "Old title", updatedAt: new Date(10) };
const newer = { ...issue("1"), title: "New title", status: "done" as const, updatedAt: new Date(20) };
recordRecentTask(older, "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [newer]);
const setItem = vi.spyOn(storageMethods, "setItem");
const listener = vi.fn();
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
try {
for (let index = 0; index < 20; index += 1) {
updateRecentTaskSnapshots(storageKey, "company-1", [older]);
recordRecentTask(older, "user-1");
updateRecentTaskSnapshots(storageKey, "company-1", [newer]);
}
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({
title: "New title", status: "done", snapshotUpdatedAt: 20, recordedAt: 20,
});
expect(setItem).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
} finally {
setItem.mockRestore();
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
}
});
it("promotes a new comment without overwriting newer task details", () => {
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
recordRecentTask({ ...issue("1"), title: "Done", status: "done", updatedAt: new Date(20) }, "user-1");
recordRecentTask({ ...issue("1"), updatedAt: new Date(10) }, "user-1", 30);
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({
title: "Done", status: "done", snapshotUpdatedAt: 20, recordedAt: 30,
});
});
it("retains the current snapshot for equal or invalid versions", () => {
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
recordRecentTask(issue("1"), "user-1");
for (const updatedAt of [new Date(0), new Date("invalid")]) {
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...issue("1"), title: "Conflict", updatedAt }]);
}
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({ title: "Task 1", snapshotUpdatedAt: 0 });
});
it("does not write or publish an unchanged normalized list", () => {
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
recordRecentTask(issue("1"), "user-1", 1);
recordRecentTask(issue("2"), "user-1", 2);
const entries = readRecentTasks(storageKey, "company-1");
const setItem = vi.spyOn(storageMethods, "setItem");
const listener = vi.fn();
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
try {
writeRecentTasks(storageKey, [...entries].reverse());
expect(setItem).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
} finally {
setItem.mockRestore();
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, listener);
}
});
it("migrates legacy tasks once and isolates subsequent old-tab writes", () => {
const legacyKey = "paperclip.recentTasks:company-1:user-1";
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
const legacy = [{ ...issue("1"), recordedAt: 100 }];
window.localStorage.setItem(legacyKey, JSON.stringify(legacy));
expect(readRecentTasks(storageKey, "company-1")[0]?.id).toBe("1");
expect(window.localStorage.getItem(storageKey)).toBeNull();
migrateRecentTasks(storageKey, "company-1");
expect(window.localStorage.getItem(legacyKey)).toBe(JSON.stringify(legacy));
// The first versioned refresh can update legacy metadata, even after a comment.
updateRecentTaskSnapshots(storageKey, "company-1", [{ ...issue("1"), title: "Fresh", updatedAt: new Date(20) }]);
expect(readRecentTasks(storageKey, "company-1")[0]).toMatchObject({
title: "Fresh", snapshotUpdatedAt: 20, recordedAt: 100,
});
window.localStorage.setItem(legacyKey, JSON.stringify([{ ...issue("old-tab"), recordedAt: 200 }]));
migrateRecentTasks(storageKey, "company-1");
expect(readRecentTasks(storageKey, "company-1")[0]?.title).toBe("Fresh");
pruneRecentTasks(storageKey, "company-1", new Set(["1"]));
migrateRecentTasks(storageKey, "company-1");
expect(readRecentTasks(storageKey, "company-1")).toEqual([]);
});
it("does not import legacy tasks that appear after an empty migration", () => {
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
migrateRecentTasks(storageKey, "company-1");
window.localStorage.setItem("paperclip.recentTasks:company-1:user-1", JSON.stringify([{ ...issue("1"), recordedAt: 1 }]));
migrateRecentTasks(storageKey, "company-1");
expect(readRecentTasks(storageKey, "company-1")).toEqual([]);
});
});