feat(inbox): rebuild compact cards and share task review controls (#20961)

## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - The inbox collects decisions, requests, and updates for the people
in a company.
> - People need a clear personal queue and a consistent way to answer
each request.
> - Repeated headings, inconsistent card actions, and separate review
flows make that work harder.
> - This pull request connects the inbox UI to the routing foundation in
#20959.
> - Compact cards use shared task controls and open supporting work in a
side panel.
> - People can answer, claim, transfer, archive, and inspect outcomes
from one place.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The inbox cards, personal views, shared task controls, and decision
history.

**Subsystem affected**

UI. This PR targets master and uses the routing foundation merged in
#20959.

**Current behavior**

The inbox repeats headings and counts. Cards have different layouts and
action placement. The requester can repeat the signed-in person's name.
Review and question controls differ between the inbox and task thread.

**Proposed behavior**

Open Assigned to you by default. Show one count line above the cards.
Use a compact card layout with consistent actions and supporting
details. Show the requester's avatar and name only when they differ from
the signed-in person. Share question controls, including a radio option
for Other that reveals a text field when selected.

**Reason and benefit**

People can scan their queue and complete requests without losing the
surrounding task or creating separate drafts for the same review.

**Breaking changes**

The default inbox view becomes personal. The all-assignee and shared
views remain available. Card close menus distinguish personal archive
and snooze from shared withdrawal.

**Additional context**

Refs #20959, #19363, and #19520. Refs #20268 for related
actionable-request work. This stack adds personal routing and shared
controls. ROADMAP.md includes Multiple Human Users and Agent Reviews and
Approvals.

## What Changed

- Add personal, unassigned, company, all-assignee, and archived inbox
views. Add claim, transfer, personal close actions, receipts, and
history controls.
- Use compact decision cards with a single count line, one-pixel
borders, and equal space above, below, and to the right of each primary
action.
- Open artifacts and synchronized confirmation/approval controls in a
shared side panel. Share their revision drafts and action state with the
task thread. Other requests keep one editable form on the inbox card
while reviewing an artifact.
- Make Other match the question options and reveal its field only when
selected. Apply this to inbox and task question cards.
- Hide self-requester labels, space agent avatars, and use the standard
canceled status icon for withdrawn requests.
- Use connector logos in connector cards. Keep the connector promotion
background and sidebar border continuous.
- Add company and project decision-owner controls and document the
updated inbox behavior.

## Verification

- The complete [GitHub Actions CI
matrix](https://github.com/paperclipai/paperclip/actions/runs/43141559641)
passes on `8d81a75e49b518fff6b6ff4c23dd67979b0b97ee` (Linux/Node 24:
typecheck, build, general and serialized tests, Runner support, browser
shards, and aggregate gates). Greptile is 5/5 on that exact head with
zero open findings, and the security scan passes.
- Local UI typechecking passes. Eight focused suites pass 382 tests: 236
inbox/review/question/connector tests plus all 146 composer tests.
Regressions passed red to green for attachment links, prompt rendering,
single-form artifact review, connector scope copy, accessible headings,
and the composer test harness's missing theme provider. The final commit
changes only that test harness; production code is unchanged from
`59d015833f`.
- Browser acceptance on the production implementation confirms preserved
selected answers with one editable question form, accessible Markdown
prompts, inert image references, accurate connector scope copy, native
PDF downloads, image sidebar previews, and Open file requesting the
attachment endpoint while preserving the inbox URL. Request reads and
file responses were intercepted; no question answers were written. The
preview is healthy on the final head.
- Visual and interaction checks cover Other radio behavior in both
themes and on mobile, the loaded Google Calendar logo, and 20px above,
below, and to the right of the primary action across all 23 visible
cards. The affected desktop/mobile navigation and sidebar specs pass
locally and in the final CI browser matrix.
- Real-editor browser checks confirm Ctrl/Cmd+Enter cannot bypass
another person's confirmation. Cancel preserves the draft and unlocks
silently; Continue submits once. Approval write failures remain visible.
- `pnpm verify` returns exit 2 because DESIGN.md requires broader
coverage; the final full CI matrix supplies every requested gate. Maps
and Storybook catalog structural checks pass. The routing foundation
#20959 merged after passing 249 database tests, server typechecking,
full CI, Greptile 5/5, and security scanning. Its backend and SDK
registry are preserved.
- Demo data is retained for testing; database suites and browser specs
use separate disposable databases.

## Risks

- The inbox uses the routing contracts merged in #20959. Personal
filters and history affect shared task entry points; the affected
controls are included in local regression coverage and full CI.
- Personal defaults and close-menu wording change inbox behavior.
Existing resolver permissions still control shared actions.
- Shared revision state and live invalidation affect two surfaces.
Focused tests cover draft synchronization, action locks, and incoming
updates.
- Visual changes use the existing design tokens and component
primitives.

## Model Used

OpenAI GPT-6 through Codex. Used repository tools, code execution, and
browser checks. This session does not expose a more specific runtime
model ID or its context-window size.

## 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 described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [x] I have run focused 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: Scott Tong <scott@scottsmbpm5max.lan>
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
authored and GitHub committed 2026-11-11 21:05:00 -08:00
1 parent 156453fe7c
commit cf1def0ca6
85 files changed
+9492 -1043

No files matched your search

+23
View File
@@ -107,6 +107,29 @@ historical preparation sentence as a fallback. Authored progress text stays visi
## Contextual feedback
The Coordinator inbox follows the Paper **A · Compact cards · All 15 types**
reference. Cards have 20px padding, 12px spacing, a 16px medium ask, an inset
context panel, and 32px actions, expressed through the existing tokens. The
header puts the imperative at the left and the requester, time, and menu at the
right. Prefix another assignee's name before the imperative; omit the viewer's
own name. Keep the task status icon and link at the footer's left and the primary
action at its far right. Keep 20px above, below, and to the right of the primary
action, including on cards with nested forms. Decisions stay neutral; problem
context uses the shared error icon and semantic destructive color. Opening an
artifact highlights its
card and uses the task-detail sidebar tabs. Render prompts with the canonical
Markdown renderer, including reference images, lists, and links.
Ask-user-question cards in the inbox, task thread, and composer use the same
choice control for **Other** as for the listed answers, within their selection
group. Selecting it reveals the custom-answer field. In single-select questions,
it stays selected until another option is picked.
The inbox title appears once in the header bar. Put one line of decision,
problem, and update counts below the toolbar, directly above the cards. Later
sections keep their labels without repeating counts or explanatory hints. Omit
the requester avatar and line for the viewer's own requests, matched by user ID.
Keep the runner turn's avatar, name, status header and current-activity slot
in place from message submission through queued admission and native startup.
Saving the human comment does not end startup feedback. Keep the identity mounted
+16
View File
@@ -450,6 +450,22 @@ Coordinator navigation uses **Chat**, **Tasks**, and **Inbox**. Inbox uses the
inbox icon and keeps the existing decision feed, its count, and decision history
at `/decisions`. The previous Inbox destination under More is removed.
Inbox opens with **Assigned to you**. Its assignee filter can show all accessible
assignees, a particular person, ownerless requests, or company alerts. Cards for
someone else prepend that person's name to the action label; your own cards omit
your name. Routing validates who may reassign a request and whether its recipient
may answer it.
Decision, problem, and update cards use the compact Paper design. Each type keeps
its own context and supported actions, with the primary action at the right of
the footer. Plans, documents, attachments, and approval details open in sidebar
tabs and highlight their source card. Confirmation and approval revision drafts
and pending submissions synchronize across review views. Other requests keep one
editable form on the inbox card while their supporting artifact is open, so a
second form cannot submit an older answer. Claim, transfer,
personal archive/snooze, withdrawal, receipts, and personal history retain their
multiplayer behavior and update open views through live events.
Tasks defaults to **All tasks**, with a saved view per person and company. Mine uses
the previous Inbox's personal participation rules, mixed attention items,
read/unread title weights, keyboard shortcuts, archive and undo, mobile
+4 -3
View File
@@ -114,9 +114,10 @@ API: `present` — representative operation IDs:
and distinct accessible task IDs for Needs attention. It has the same board,
company, privacy, addressee, dismissal, snooze, and archive gates as the feed.
- `GET /api/companies/{companyId}/attention/history` — read-only canonical
interaction/Decisions-v1 projection with cursor, search, outcome, agent, project,
date, and chronological sort filters. Each source and v1 target must pass task
privacy checks. Bounded to 100 records per page; no second decision write store.
interaction/Decisions-v1/approval projection with cursor, search, outcome, agent,
project, date, and chronological sort filters. Each source and v1 target must pass
task privacy checks; an approval linked to a task needs that task to be readable.
Bounded to 100 records per page; no second decision write store.
The history route is **operator-only**: a board user with company access is
required. It does not grant an agent resolver authority. Existing task interaction
+5 -2
View File
@@ -26,10 +26,13 @@ API: `present` — representative operation IDs:
Agent access: `available` — Native create_project/list_projects; REST / CLI / MCP.
Policy: Company/project visibility, protected configuration/runtime policies; some
repository/privacy management requires board.
repository/privacy management requires board. Only a board user may set or clear a
project's `decisionOwnerUserId` (PAP-749 escalation owner); an agent's change is 403,
and a value that is not an active company member is 422.
Evidence: `source-only` — [implementation](../../server/src/routes/projects.ts#L278) at
the reviewed commit above.
the reviewed commit above; the decision-owner rule is covered by
[route tests](../../server/src/__tests__/project-decision-owner-route.test.ts).
Verification: `not-run` — exercise a listed permitted operation with actual agent
credentials/run context; inspect its result and applicable persistence/attribution.
@@ -261,6 +261,15 @@ Verification: `not-run` — Withdraw through each permitted actor, reload termin
Gaps: No agent skip authority or generic manual expiry API.
The board-only attention close route
(`POST /api/companies/{companyId}/attention/{sourceKind}/{sourceId}/close`,
`mode: "withdraw"`) runs this same withdraw flow for every interaction kind and
also lets a person withdraw a pending card that outlived its closed task (no
wake). Its `archive`/`snooze` modes and the `…/transfer` route ("Route to @x",
sets `addresseeUserId` after checking the new owner can answer) are board-only
presentation and routing changes, not agent capabilities. Evidence:
[route tests](../../server/src/__tests__/attention-close.test.ts).
### `skip`
API: `present` — POST /api/issues/{id}/interactions/{interactionId}/skip.
@@ -466,6 +475,14 @@ Verification: `not-run` — Board decisions and revision request, reload audit +
Gaps: Request-revision concurrency/replay not claimed as equivalent to approve/reject; payload integrations remain explicit.
Withdraw (PAP-749 task 2b): POST /api/approvals/{id}/cancel and the attention
close route with `mode: "withdraw"` cancel an open approval as out of date. They
are board-only and never approve: the gate stays closed, `decidedByUserId` and
the reason are recorded, `approval.cancelled` is logged and the requesting agent
is woken. Hire requests return 422 (reject them instead); a resolved approval
returns 409 `attention_already_closed`. Evidence: `source-only` plus
[route tests](../../server/src/__tests__/attention-close.test.ts); no agent path.
### `resubmit`
API: `present` — POST /api/approvals/{id}/resubmit.
+97 -2
View File
@@ -32,6 +32,58 @@ Open `/decisions`, then a queue at `/decisions/queues/:key` and its source item.
In Coordinator, `/decisions` is **Inbox**. Pending cards use the existing attention feed and canonical
resolvers. **Tasks → Needs attention** projects the same feed as distinct tasks.
With **Settings → Experimental → Coordinator workspace** enabled, `/decisions`
is **Needs You**. Pending cards use the existing attention feed and canonical
resolvers. **Tasks → Needs attention** projects the **For you** feed as distinct tasks.
Both `/decisions` surfaces switch between **For you** (default), **Unassigned**,
**Company** and **Archived** (`?view=unassigned|company|archived`); each list is
cached per person and view. Board members (not viewers) also get **Everyone**
(`?view=all`): every person's open cards, with its own tab count that is never
in the badge. **Archived** lists only the open cards you archived
(with the archive date and any Stale chip), then a **Snoozed** group with the
time each card comes back; its Close… menu offers **Restore** (or **Restore
now** and **Change snooze time**) instead of Archive, and the cards stay
answerable. Its tab count is never in the badge, and the other views no longer
show Snoozed/Archived curtains. Your own cards say **Why me?**, a different person's card shows **For
@name**, and an ownerless request card offers **Claim**, which makes you its
addressee and removes it from everyone else's pool (`attention.claimed` activity).
For you shows "N cards need an owner" while the pool has cards; that count is
not in the badge.
Escalation owners catch cards that name no person: a project's **Decision owner**
(Project → Configuration) first, then the company's **Default decision owner**
(Company Settings → Decisions), else Unassigned. The company's **Budget owner**
gets each budget hard stop in For you ("You own budget escalation", with Raise
limit / Keep paused) while Company still shows it to every operator as "For
@owner"; a different person's Raise limit or Keep paused leaves the owner a
receipt. An owner who is no longer an active member is skipped.
Every card carries `closeModes` for one close API
(`POST /companies/:companyId/attention/:sourceKind/:sourceId/close`):
**Withdraw** closes an interaction, decision or approval for everyone through
its own withdraw/cancel, never as consent, and tells the agent; **Archive** and
**Snooze** hide the card for you only. **Transfer** (`…/transfer`) routes an
interaction to another person who can answer it. A late second answer gets 409
"Already answered" naming who answered first. Failed-run, recovery and
agent-error cards offer only actions they can complete (Retry, Open run, Open
recovery, Inspect).
Each card on both surfaces has one **Close…** menu with only its `closeModes`:
**Withdraw request…** (confirms, optional reason, cannot be undone), **Archive
for me**, **Snooze for me** (both with Undo) and, for interactions, **Transfer
to @x**. A card for a different person asks "This is for @x. Continue?" before
its answer controls, Withdraw or Transfer. Failed runs **Retry** inline; budget
stops **Raise budget** or **Keep paused** inline. Stale cards carry a **Stale ·
reason** chip and sit in a collapsed **Stale (N)** group at the bottom of each
view; **Withdraw all stale** lists every card first and withdraws only those
with a real withdraw. "Dismiss — no effects" and the questions-only Cancel are
gone; archived and snoozed cards live in the **Archived** view.
One obligation is one card: a pending approval leads, with the in-review task
that waits on it as a line under it, and a failed run leads (with **Retry**),
with the task it blocks under it; the run then shows where the blocked task
does. A budget hard stop leads its override approval ("Approval this answers"):
Raise limit or Keep paused answers both, and the approval is no longer an
ownerless card in Unassigned. The feed still returns every card, the evidence hides and returns with
its lead, and each view count and the badge count the obligation once. A task
with a configured human review stage keeps its review card next to the approval.
Search, filter by agent/project, sort, and group by task/agent/project apply to
both open decisions and the visible Recent Decisions receipts. Successful
resolutions resize into compact receipts while the same contained Gather Light
@@ -46,8 +98,14 @@ canonical stalled-review item stays in Inbox and Tasks → Needs attention.
Coordinator presents the existing recovery actions with **Mark task done** as
the task-level verdict, distinct from the saved artifact receipt.
Open **Decision history** on this surface for the paginated history sidebar;
it merges answered questions and confirmations with Decisions-v1 outcomes.
Open **Decision history** (on both `/decisions` surfaces) for the paginated
history panel; it merges answered questions and confirmations, Decisions-v1
outcomes and approvals. It opens on **Handled by me or for me** (what you
answered, withdrew or transferred, what someone handled for you, and the cards
that were for you); **Everyone** in the same panel lists every person's.
Coordinator's Recent Decisions opens on the same scope. Outcome filters include **Withdrawn**, **Expired** (not
cards closed with their task), **Closed with task** and **Handled for me**, within either scope; a
withdrawn entry names who withdrew it and the reason.
### `search`
@@ -110,6 +168,41 @@ after an answer with retained history, and company/task-visibility/addressee che
Idle chats without structured requests remain absent. The
[Inbox component test](../../ui/src/components/coordinator/CoordinatorNeedsYou.test.tsx)
checks that a chat-sourced canonical question reaches its original resolver and context.
Idle chats without structured requests remain absent.
[Audience, badge and claim tests](../../server/src/__tests__/attention-audience-service.test.ts)
cover the view partition, the For you badge with per-view counts, the personal
`view=archived` list and its tab count, and the claim service and route
permission matrix. The [canonical history](../../server/src/__tests__/decision-history.test.ts)
tests also cover approvals, the Withdrawn / Expired / Closed with task filters,
and the Handled by me or for me / Everyone scope (instigator walk, addressee and
assignee precedence, receipts, filters and paging inside a scope).
[Close and transfer tests](../../server/src/__tests__/attention-close.test.ts)
cover withdraw for every interaction kind (including on a closed task), approval
and decision cancel, personal archive/snooze, transfer, history labels, and the
400/403/404/409/422 matrix of the close, transfer and approval-cancel routes.
[Current-card tests](../../server/src/__tests__/attention-current-cards.test.ts)
cover the shared active-request predicate with Mine, the 30-day, closed-task,
decision-deadline and 7-day Unassigned stale rules, stale approval escalation
without withdrawal, fingerprint-only resurfacing of dismissed and snoozed cards,
and the idempotent closed-task sweep.
[Escalation owner tests](../../server/src/__tests__/attention-escalation-owners.test.ts)
cover project decision owners ahead of the default owner, skipping an inactive
owner, the budget owner's hard stop in For you and in everyone's Company view
(one badge card), Company only without a budget owner, and the budget owner's
receipt; the [project](../../server/src/__tests__/project-decision-owner-route.test.ts)
and [company](../../server/src/__tests__/company-default-decision-owner-route.test.ts)
route tests cover the active-member 422 and board-only writes.
[Obligation tests](../../server/src/__tests__/attention-obligations.test.ts)
cover the approval/review, budget-stop/override-approval and blocked-task/failed-run joins, the separate human
review gate, placement with the blocked task's person, one count per obligation,
evidence that hides and restores with its lead, and the split after an answer. The
[Needs You component test](../../ui/src/components/coordinator/CoordinatorNeedsYou.test.tsx)
checks that a chat-sourced canonical question reaches its original resolver and context,
and the view switch, banner, Why me?, For @name and Claim, and Everyone for a board member only (with the withdraw confirmation for another person's card); the
[classic page view test](../../ui/src/pages/WhatNeedsMe.views.test.tsx) checks the same on Decisions,
plus the Archived view with Restore, Change time and Decision history on both pages.
The [Decisions views stories](../../ui/storybook/stories/inbox-search/needs-you-views.stories.tsx)
show For you, Unassigned, Company, Archived, Everyone, a different person's cards, one card for each obligation and the escalation owners (including the budget owner's hard stop in another person's Company view).
It also checks simultaneous resize/light and delayed movement to Recent Decisions.
[Shared card-motion tests](../../ui/src/components/coordinator/CoordinatorTaskCommitment.test.tsx)
cover reduced-motion settlement and returning to a resolved card without replay.
@@ -187,3 +280,5 @@ Scope: ui
Storybook: Eligible live task toast, explicit mention, quiet unrelated event, own-action/visible-task suppression, dedupe and company/account switch.
Production: Persisted participation and mentions determine per-viewer socket eligibility; quiet events still refresh caches; reload retains eligibility, unauthorized resources remain denied.
Sources: Source: [ToastViewport.tsx](../../ui/src/components/ToastViewport.tsx), [ToastContext.tsx](../../ui/src/context/ToastContext.tsx), [LiveUpdatesProvider.tsx](../../ui/src/context/LiveUpdatesProvider.tsx), [task-toast-eligibility.ts](../../server/src/realtime/task-toast-eligibility.ts). Tests: [ToastContext.test.tsx](../../ui/src/context/ToastContext.test.tsx), [ToastViewport.test.tsx](../../ui/src/components/ToastViewport.test.tsx), [LiveUpdatesProvider.hook.test.tsx](../../ui/src/context/LiveUpdatesProvider.hook.test.tsx), [task-toast-eligibility.test.ts](../../server/src/__tests__/task-toast-eligibility.test.ts), [live-events-toast.test.ts](../../server/src/__tests__/live-events-toast.test.ts), [multi-user-authenticated.spec.ts](../../tests/e2e/multi-user-authenticated.spec.ts)
Coordinator and classic navigation, the phone bar and both Decisions pages show one badge: the person's current **For you** cards, read from the same per-person feed, with no midnight reset. Unassigned and Company counts stay on their own tabs. Task questions resolve through their original interaction; pending chat projections, counts and recorded receipts invalidate together.
+9 -2
View File
@@ -150,8 +150,15 @@ test.describe("Announcement placement on a new company's first task", () => {
await page.waitForTimeout(500);
const card = await box(announcement, "announcement");
// The docked well spans the sidebar column, so its width is the sidebar width.
expect(Math.round(card.width), "sidebar renders at its 208px minimum").toBe(208);
const sidebar = page.locator(".sidebar-shell-panel");
const sidebarBox = await box(sidebar, "sidebar");
const borderWidth = await sidebar.evaluate(element => {
const style = getComputedStyle(element);
return parseFloat(style.borderInlineStartWidth) + parseFloat(style.borderInlineEndWidth);
});
expect(Math.round(sidebarBox.width), "sidebar renders at its 208px minimum").toBe(208);
expect(card.width + borderWidth, "announcement fills the sidebar inside its continuous border")
.toBeCloseTo(sidebarBox.width, 0);
expect.soft(card.height, "docked card keeps at most half the window height").toBeLessThanOrEqual(viewport.height / 2 + 1);
await expect.soft(page.getByLabel("Workspace navigation").getByRole("link", { name: "Tasks" })).toBeInViewport();
const accountTrigger = page.getByRole("button", { name: "Open account menu" });
+3 -1
View File
@@ -91,7 +91,9 @@ for (const mobile of [false, true]) test(`Mine retains Inbox behavior inside Tas
await page.keyboard.press("u");
await expect(unreadRow).toBeVisible();
await page.getByRole("link", { name: "Inbox", exact: true }).click();
await expect(page.getByRole("region", { name: "Inbox", exact: true }).getByRole("heading", { name: "Inbox", exact: true })).toBeVisible();
const inbox = page.getByRole("region", { name: "Needs You", exact: true });
await expect(inbox.getByRole("toolbar", { name: "Decisions controls" })).toBeVisible();
await expect(inbox.getByLabel("Filter decisions by assignee")).toHaveValue("me");
}
} finally {
await request.put(preferencePath, { data: { coordinatorWorkspaceEnabled: preferences.coordinatorWorkspaceEnabled } });
+3
View File
@@ -16,6 +16,9 @@ export const approvalsApi = {
api.post<Approval>(`/approvals/${id}/reject`, { decisionNote }),
requestRevision: (id: string, decisionNote?: string) =>
api.post<Approval>(`/approvals/${id}/request-revision`, { decisionNote }),
/** Withdraw an out-of-date approval. It never approves; the gate stays closed. */
cancel: (id: string, reason?: string) =>
api.post<Approval>(`/approvals/${id}/cancel`, { reason }),
resubmit: (id: string, payload?: Record<string, unknown>) =>
api.post<Approval>(`/approvals/${id}/resubmit`, { payload }),
listComments: (id: string) => api.get<ApprovalComment[]>(`/approvals/${id}/comments`),
+32 -1
View File
@@ -1,6 +1,23 @@
import type { AttentionFeed, AttentionFeedQuery, AttentionSummary, AttentionTaskIndex, DecisionHistoryPage, DecisionHistoryQuery } from "@paperclipai/shared";
import type {
AttentionClaimResult,
AttentionCloseInput,
AttentionCloseResult,
AttentionFeed,
AttentionSummary,
AttentionTaskIndex,
AttentionFeedQuery,
AttentionSourceKind,
AttentionTransferInput,
AttentionTransferResult,
DecisionHistoryPage,
DecisionHistoryQuery,
} from "@paperclipai/shared";
import { api } from "./client";
function cardPath(companyId: string, sourceKind: AttentionSourceKind, sourceId: string, action: string) {
return `/companies/${companyId}/attention/${encodeURIComponent(sourceKind)}/${encodeURIComponent(sourceId)}/${action}`;
}
export const attentionApi = {
summary: (companyId: string) => api.get<AttentionSummary>(`/companies/${companyId}/attention/summary`),
taskIndex: (companyId: string) => api.get<AttentionTaskIndex>(`/companies/${companyId}/attention/tasks`),
@@ -19,6 +36,7 @@ export const attentionApi = {
if (options.includeDismissed) params.set("includeDismissed", "true");
if (options.archived) params.set("archived", "true");
if (options.all) params.set("all", "true");
if (options.view) params.set("view", options.view);
if (options.activitySince) params.set("activitySince", options.activitySince);
if (options.activityUntil) params.set("activityUntil", options.activityUntil);
if (options.queue) params.set("queue", options.queue);
@@ -28,4 +46,17 @@ export const attentionApi = {
const query = params.toString();
return api.get<AttentionFeed>(`/companies/${companyId}/attention${query ? `?${query}` : ""}`);
},
/** Claim an Unassigned card: you become its addressee (PAP-749 plan §1, Rule 3). */
claim: (companyId: string, sourceKind: AttentionSourceKind, sourceId: string) =>
api.post<AttentionClaimResult>(cardPath(companyId, sourceKind, sourceId, "claim"), {}),
/**
* One Close… menu verb (PAP-749 plan §2, Rule 6): withdraw the request for
* everyone, or archive / snooze the card for yourself. Use the card's
* `closeModes` to know which verbs it supports.
*/
close: (companyId: string, sourceKind: AttentionSourceKind, sourceId: string, input: AttentionCloseInput) =>
api.post<AttentionCloseResult>(cardPath(companyId, sourceKind, sourceId, "close"), input),
/** Route a card to a different person; they become its addressee. */
transfer: (companyId: string, sourceKind: AttentionSourceKind, sourceId: string, input: AttentionTransferInput) =>
api.post<AttentionTransferResult>(cardPath(companyId, sourceKind, sourceId, "transfer"), input),
};
+2
View File
@@ -106,6 +106,8 @@ export const companiesApi = {
| "description"
| "status"
| "budgetMonthlyCents"
| "defaultDecisionOwnerUserId"
| "budgetOwnerUserId"
| "requireBoardApprovalForNewAgents"
| "interactionResolverGovernance"
| "feedbackDataSharingEnabled"
+9 -3
View File
@@ -3,13 +3,19 @@ import { api } from "./client";
export const inboxDismissalsApi = {
list: (companyId: string) => api.get<InboxDismissal[]>(`/companies/${companyId}/inbox-dismissals`),
dismiss: (companyId: string, itemKey: string) =>
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, { itemKey }),
snooze: (companyId: string, itemKey: string, snoozedUntil: string) =>
// `fingerprint` is an attention card's `changeFingerprint`; the card stays
// hidden until that fingerprint changes (PAP-749 Rule 9).
dismiss: (companyId: string, itemKey: string, fingerprint?: string) =>
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, {
itemKey,
...(fingerprint ? { fingerprint } : {}),
}),
snooze: (companyId: string, itemKey: string, snoozedUntil: string, fingerprint?: string) =>
api.post<InboxDismissal>(`/companies/${companyId}/inbox-dismissals`, {
itemKey,
kind: "snooze",
snoozedUntil,
...(fingerprint ? { fingerprint } : {}),
}),
restore: (companyId: string, itemKey: string) =>
api.delete<void>(`/companies/${companyId}/inbox-dismissals/${encodeURIComponent(itemKey)}`),
+68
View File
@@ -0,0 +1,68 @@
import type { ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { FileText, UserRound } from "lucide-react";
import { AGENT_ROLE_LABELS, type AgentRole, type AttentionItem } from "@paperclipai/shared";
import { agentsApi } from "../api/agents";
import { approvalsApi } from "../api/approvals";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { queryKeys } from "../lib/queryKeys";
import { formatCents } from "../lib/utils";
import { NeedsYouPanel, NeedsYouPanelNote, NeedsYouPanelRow } from "./coordinator/NeedsYouCardKit";
function payloadString(payload: Record<string, unknown> | undefined, key: string): string | null {
const value = payload?.[key];
return typeof value === "string" && value.trim() ? value.trim() : null;
}
const APPROVAL_KIND_LABELS: Record<string, string> = {
request_board_approval: "Board approval request",
approve_ceo_strategy: "Strategy approval",
budget_override_required: "Budget override request",
};
const wholeDollars =(cents: number) => formatCents(cents).replace(/\.00$/, "");
/**
* What the approval is for, in the card's inset panel (PAP-775, Paper "06"):
* the agent a hire creates, or the request's own title and summary.
*/
export function ApprovalCardPanel({ item, companyId, artifactRow, onViewDetails }: {
item: AttentionItem;
companyId: string;
artifactRow?: ReactNode;
onViewDetails?: () => void;
}) {
const hire = item.subject.metadata?.type === "hire_agent";
// The feed carries no payload; the side panel reads the same query.
const approval = useQuery({ queryKey: queryKeys.approvals.detail(item.subject.id), queryFn: () => approvalsApi.get(item.subject.id), enabled: !artifactRow });
const payload = approval.data?.payload;
const reportsTo = payloadString(payload, "reportsTo");
const agents = useQuery({ queryKey: queryKeys.agents.list(companyId), queryFn: () => agentsApi.list(companyId), enabled: hire && !!reportsTo });
if (artifactRow) return <NeedsYouPanel>{artifactRow}</NeedsYouPanel>;
if (hire) {
const budget = typeof payload?.budgetMonthlyCents === "number" && payload.budgetMonthlyCents > 0 ? payload.budgetMonthlyCents : null;
const name = payloadString(payload, "name") ?? item.subject.title ?? "New agent";
const role = payloadString(payload, "role");
const adapter = payloadString(payload, "adapterType");
const manager = reportsTo ? agents.data?.find((agent) => agent.id === reportsTo)?.name : null;
const meta = [
manager ? `Reports to ${manager}` : null,
payloadString(payload, "title") ?? (role ? `${AGENT_ROLE_LABELS[role as AgentRole] ?? role} role` : null),
adapter ? getAdapterLabel(adapter) : null,
].filter(Boolean).join(" · ");
return <NeedsYouPanel>
<NeedsYouPanelRow icon={<UserRound />} title={budget ? `${name} · ${wholeDollars(budget)} / month` : name} meta={meta || undefined} onOpen={onViewDetails} label="View configuration" />
<NeedsYouPanelNote>{budget ? "Approval creates the agent with this monthly spending limit." : "Approval creates the agent."}</NeedsYouPanelNote>
</NeedsYouPanel>;
}
// The ask above already says the request's title and summary, so the panel
// names the request and what it recommends.
const kind = APPROVAL_KIND_LABELS[String(item.subject.metadata?.type ?? "")] ?? "Approval request";
const recommended = payloadString(payload, "recommendedAction");
const next = payloadString(payload, "nextActionOnApproval");
return <NeedsYouPanel>
<NeedsYouPanelRow icon={<FileText />} title={recommended ?? kind} meta={recommended ? kind : undefined} onOpen={onViewDetails} label="View details" />
{next ? <NeedsYouPanelNote>{`On approval: ${next}`}</NeedsYouPanelNote> : null}
</NeedsYouPanel>;
}
@@ -0,0 +1,312 @@
// @vitest-environment jsdom
import { act, forwardRef, type AnchorHTMLAttributes } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionAudience, AttentionItem } from "@paperclipai/shared";
import { AttentionCloseMenu, AttentionForOtherGate, AttentionHiddenChip, AttentionSnoozedGroup, AttentionStaleChip, AttentionStaleGroup } from "./AttentionCloseMenu";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.mock("@/lib/router", () => ({
Link: forwardRef<HTMLAnchorElement, AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }>(({ children, to, ...props }, ref) => (
<a ref={ref} href={to} {...props}>{children}</a>
)),
}));
// PAP-749 task 2c: the Close… menu, "This is for @x. Continue?", the Stale
// chip and the Stale (N) group with "Withdraw all stale".
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = "";
});
const SCOTT = "scott";
const labels = new Map([["scott", "Scott"], ["dotta", "Dotta"], ["riley", "Riley"]]);
const forScott: AttentionAudience = { kind: "user", userId: SCOTT, reason: "addressee" };
const forDotta: AttentionAudience = { kind: "user", userId: "dotta", reason: "addressee" };
function card(id: string, overrides: Partial<AttentionItem> = {}) {
return {
id,
sourceKind: "issue_thread_interaction",
subject: { id: `${id}-subject`, kind: "interaction", title: `Card ${id}`, href: `/PAP/issues/PAP-1#${id}`, metadata: {} },
relatedIssue: { id: "issue-1", identifier: "PAP-1", title: "Task", kind: "issue" },
audience: forScott,
stale: null,
closeModes: ["withdraw", "archive", "snooze"], obligationKey: null, obligationLeadId: null,
changeFingerprint: "fp.me",
whyNow: "",
...overrides,
} as unknown as AttentionItem;
}
async function render(node: React.ReactNode) {
await act(async () => root.render(<>{node}</>));
}
async function openMenu() {
const trigger = container.querySelector<HTMLElement>("[data-attention-close-menu]")!;
await act(async () => {
trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
function menuItem(mode: string) {
return document.body.querySelector<HTMLElement>(`[data-attention-close-mode="${mode}"]`);
}
function buttonWithText(text: string, scope: ParentNode = document.body) {
return [...scope.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === text);
}
async function click(element: HTMLElement | undefined | null) {
await act(async () => { element!.click(); });
}
describe("AttentionCloseMenu", () => {
it("lists only the verbs in closeModes", async () => {
await render(<AttentionCloseMenu item={card("a", { closeModes: ["archive", "snooze"] })} currentUserId={SCOTT} userLabelMap={labels}
onWithdraw={vi.fn()} onArchive={vi.fn()} onSnooze={vi.fn()} onTransfer={vi.fn()} />);
await openMenu();
expect(menuItem("withdraw")).toBeNull();
expect(menuItem("archive")?.textContent).toContain("Archive for me");
expect(menuItem("snooze")?.textContent).toContain("Snooze for me");
expect(menuItem("transfer")?.textContent).toContain("Transfer to…");
expect(document.body.textContent).toContain("Open source");
});
it("archives for me in one step", async () => {
const onArchive = vi.fn();
const item = card("a");
await render(<AttentionCloseMenu item={item} currentUserId={SCOTT} userLabelMap={labels} onArchive={onArchive} />);
await openMenu();
await click(menuItem("archive"));
expect(onArchive).toHaveBeenCalledWith(item);
});
it("offers Restore instead of Archive on a card you archived (PAP-749 task 2e)", async () => {
const onRestore = vi.fn();
const item = card("a", { dismissal: { kind: "dismiss", dismissedAt: "2026-10-29T12:00:00.000Z", snoozedUntil: null, isActive: true } });
await render(<AttentionCloseMenu item={item} currentUserId={SCOTT} userLabelMap={labels} onArchive={vi.fn()} onSnooze={vi.fn()} onWithdraw={vi.fn()} onRestore={onRestore} />);
await openMenu();
expect(menuItem("archive")).toBeNull();
expect(menuItem("withdraw")).not.toBeNull();
expect(menuItem("restore")?.textContent).toContain("Restore");
expect(menuItem("snooze")?.textContent).toContain("Snooze for me");
await click(menuItem("restore"));
expect(onRestore).toHaveBeenCalledWith(item);
});
it('offers "Restore now" and "Change snooze time" on a snoozed card', async () => {
const item = card("s", { dismissal: { kind: "snooze", dismissedAt: "2026-10-29T12:00:00.000Z", snoozedUntil: "2026-10-31T09:00:00.000Z", isActive: true } });
await render(<AttentionCloseMenu item={item} currentUserId={SCOTT} userLabelMap={labels} onArchive={vi.fn()} onSnooze={vi.fn()} onRestore={vi.fn()} />);
await openMenu();
expect(menuItem("restore")?.textContent).toContain("Restore now");
expect(menuItem("snooze")?.textContent).toContain("Change snooze time");
});
it("keeps Archive when no restore handler is given, even for a hidden card", async () => {
const item = card("a", { dismissal: { kind: "dismiss", dismissedAt: "2026-10-29T12:00:00.000Z", snoozedUntil: null, isActive: true } });
await render(<AttentionCloseMenu item={item} currentUserId={SCOTT} userLabelMap={labels} onArchive={vi.fn()} />);
await openMenu();
expect(menuItem("restore")).toBeNull();
expect(menuItem("archive")).not.toBeNull();
});
it("confirms a withdraw and sends the reason", async () => {
const onWithdraw = vi.fn();
const item = card("a");
await render(<AttentionCloseMenu item={item} currentUserId={SCOTT} userLabelMap={labels} onWithdraw={onWithdraw} />);
await openMenu();
await click(menuItem("withdraw"));
const dialog = document.body.querySelector<HTMLElement>("[data-attention-withdraw-dialog]")!;
expect(dialog.textContent).toContain("Withdraw this request?");
expect(dialog.textContent).toContain("It is not an approval");
const textarea = dialog.querySelector("textarea")!;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
setter.call(textarea, "Plan changed");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(onWithdraw).not.toHaveBeenCalled();
await click(buttonWithText("Withdraw request", dialog));
expect(onWithdraw).toHaveBeenCalledWith(item, "Plan changed");
});
it('asks "This is for @x. Withdraw anyway?" on a card for a different person', async () => {
await render(<AttentionCloseMenu item={card("a", { audience: forDotta })} currentUserId={SCOTT} userLabelMap={labels} onWithdraw={vi.fn()} />);
await openMenu();
await click(menuItem("withdraw"));
expect(document.body.querySelector("[data-attention-withdraw-dialog]")?.textContent).toContain("This is for @Dotta. Withdraw anyway?");
});
it('asks "This is for @x. Continue?" before transferring a different person\'s card', async () => {
const onTransfer = vi.fn();
const item = card("a", { audience: forDotta });
await render(<AttentionCloseMenu item={item} currentUserId={SCOTT} userLabelMap={labels} onTransfer={onTransfer} />);
await openMenu();
const sub = menuItem("transfer")!;
await act(async () => {
sub.dispatchEvent(new PointerEvent("pointermove", { bubbles: true }));
sub.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
});
const riley = [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((entry) => entry.textContent === "@Riley");
// Dotta owns the card, and Scott is the viewer, so only Riley is offered.
expect([...document.body.querySelectorAll('[role="menuitem"]')].map((entry) => entry.textContent)).not.toContain("@Dotta");
await click(riley);
expect(onTransfer).not.toHaveBeenCalled();
expect(document.body.textContent).toContain("This is for @Dotta. Continue?");
await click(buttonWithText("Transfer to @Riley"));
expect(onTransfer).toHaveBeenCalledWith(item, "riley");
});
});
describe("AttentionForOtherGate", () => {
it("shows the card at once and asks only when the person acts (PAP-767)", async () => {
const onApprove = vi.fn();
await render(<AttentionForOtherGate item={card("a", { audience: forDotta })} currentUserId={SCOTT} userLabelMap={labels}>
<h2>Ship the launch plan</h2><button type="button" onClick={onApprove}>Approve</button>
</AttentionForOtherGate>);
// The title and the controls are readable before anything is asked.
expect(container.textContent).toContain("Ship the launch plan");
expect(buttonWithText("Approve", container)).toBeTruthy();
expect(container.textContent).not.toContain("This is for @Dotta");
await click(buttonWithText("Approve", container));
expect(onApprove).not.toHaveBeenCalled();
expect(container.querySelector("[data-attention-for-other-prompt]")?.textContent).toContain("This is for @Dotta. Continue?");
// Cancel keeps the card as it was; Continue runs the held-back Approve.
await click(buttonWithText("Cancel", container));
expect(container.querySelector("[data-attention-for-other-prompt]")).toBeNull();
expect(onApprove).not.toHaveBeenCalled();
await click(buttonWithText("Approve", container));
await click(buttonWithText("Continue", container));
expect(onApprove).toHaveBeenCalledTimes(1);
expect(container.querySelector("[data-attention-for-other-prompt]")).toBeNull();
});
it("shows the controls at once on your own card", async () => {
await render(<AttentionForOtherGate item={card("a")} currentUserId={SCOTT} userLabelMap={labels}><button type="button">Approve</button></AttentionForOtherGate>);
expect(container.querySelector("[data-attention-for-other-gate]")).toBeNull();
expect(buttonWithText("Approve", container)).toBeTruthy();
});
});
describe("AttentionStaleChip", () => {
it("shows the stale reason", async () => {
await render(<AttentionStaleChip item={card("a", { stale: { reason: "aged" } })} />);
expect(container.querySelector('[data-attention-stale="aged"]')?.textContent).toBe("Stale · Waiting 30+ days");
});
it("renders nothing for a current card", async () => {
await render(<AttentionStaleChip item={card("a")} />);
expect(container.textContent).toBe("");
});
});
describe("AttentionStaleGroup", () => {
const stale = [
card("old", { stale: { reason: "aged" } }),
card("closed-task", { stale: { reason: "issue_closed" }, audience: forDotta }),
card("run", { sourceKind: "failed_run", stale: { reason: "aged" }, closeModes: ["archive", "snooze"] }),
card("approval", { sourceKind: "approval", stale: { reason: "aged" }, required: true }),
];
it("starts collapsed with its count and opens with one click", async () => {
const onToggle = vi.fn();
await render(<AttentionStaleGroup items={stale} open={false} onToggle={onToggle} onWithdrawAll={vi.fn()} currentUserId={SCOTT} userLabelMap={labels}><p>cards</p></AttentionStaleGroup>);
expect(container.textContent).toContain("Stale (4)");
expect(container.textContent).not.toContain("cards");
const header = container.querySelector<HTMLElement>("[aria-expanded]")!;
expect(header.getAttribute("aria-expanded")).toBe("false");
await click(header);
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("lists every card before Withdraw all stale and withdraws only those with a request", async () => {
const onWithdrawAll = vi.fn();
await render(<AttentionStaleGroup items={stale} open={false} onToggle={vi.fn()} onWithdrawAll={onWithdrawAll} currentUserId={SCOTT} userLabelMap={labels}><p>cards</p></AttentionStaleGroup>);
await click(buttonWithText("Withdraw all stale", container));
expect(onWithdrawAll).not.toHaveBeenCalled();
const dialog = document.body.querySelector<HTMLElement>("[data-attention-stale-withdraw-dialog]")!;
expect(dialog.textContent).toContain("Withdraw 2 stale requests?");
expect(dialog.textContent).toContain("None of them counts as approved");
const withdrawn = [...dialog.querySelectorAll('[data-attention-stale-list="Will be withdrawn"] [data-attention-stale-item]')].map((row) => row.getAttribute("data-attention-stale-item"));
expect(withdrawn).toEqual(["old", "closed-task"]);
expect(dialog.querySelector('[data-attention-stale-item="closed-task"]')?.textContent).toContain("For @Dotta");
expect(dialog.querySelector('[data-attention-stale-item="run"]')?.closest("[data-attention-stale-list]")?.getAttribute("data-attention-stale-list"))
.toBe("Stay open: no request to withdraw");
// A stale required approval goes to a person; bulk withdraw leaves it open.
expect(dialog.querySelector('[data-attention-stale-item="approval"]')?.closest("[data-attention-stale-list]")?.getAttribute("data-attention-stale-list"))
.toBe("Stay open: required, withdraw one at a time");
expect(dialog.querySelector("[data-attention-stale-for-others]")?.textContent).toBe("This is for @Dotta. Continue?");
await click(buttonWithText("Withdraw 2 requests", dialog));
expect(onWithdrawAll).toHaveBeenCalledWith([stale[0], stale[1]], "");
});
it("names each card in the confirmation with the title the layout's card leads with (PAP-767)", async () => {
// The classic row leads with the card title.
await render(<AttentionStaleGroup items={stale} open={false} onToggle={vi.fn()} onWithdrawAll={vi.fn()} currentUserId={SCOTT} userLabelMap={labels}><p>cards</p></AttentionStaleGroup>);
await click(buttonWithText("Withdraw all stale", container));
let dialog = document.body.querySelector<HTMLElement>("[data-attention-stale-withdraw-dialog]")!;
expect(dialog.querySelector('[data-attention-stale-item="old"]')?.textContent).toContain("Card old");
await act(async () => root.unmount());
document.body.innerHTML = "";
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
// Needs You passes its own card title.
await render(<AttentionStaleGroup items={stale} open={false} onToggle={vi.fn()} onWithdrawAll={vi.fn()} currentUserId={SCOTT} userLabelMap={labels}
titleOf={(item) => `Task for ${item.id}`}><p>cards</p></AttentionStaleGroup>);
await click(buttonWithText("Withdraw all stale", container));
dialog = document.body.querySelector<HTMLElement>("[data-attention-stale-withdraw-dialog]")!;
expect(dialog.querySelector('[data-attention-stale-item="old"]')?.textContent).toContain("Task for old");
expect(dialog.textContent).not.toContain("Card old");
});
it("offers no bulk withdraw when nothing can be withdrawn", async () => {
await render(<AttentionStaleGroup items={[stale[2]!]} open={false} onToggle={vi.fn()} onWithdrawAll={vi.fn()} currentUserId={SCOTT} userLabelMap={labels}><p>cards</p></AttentionStaleGroup>);
expect(container.textContent).toContain("Stale (1)");
expect(buttonWithText("Withdraw all stale", container)).toBeUndefined();
});
it("renders nothing without stale cards", async () => {
await render(<AttentionStaleGroup items={[]} open onToggle={vi.fn()} currentUserId={SCOTT}><p>cards</p></AttentionStaleGroup>);
expect(container.querySelector("[data-attention-stale-group]")).toBeNull();
});
});
describe("AttentionHiddenChip and AttentionSnoozedGroup (PAP-749 task 2e)", () => {
it("shows when a card was archived and when a snoozed card comes back", async () => {
await render(<>
<AttentionHiddenChip item={card("a", { dismissal: { kind: "dismiss", dismissedAt: "2026-10-29T12:00:00.000Z", snoozedUntil: null, isActive: true } })} />
<AttentionHiddenChip item={card("s", { dismissal: { kind: "snooze", dismissedAt: "2026-10-29T12:00:00.000Z", snoozedUntil: "2026-10-31T09:00:00.000Z", isActive: true } })} />
<AttentionHiddenChip item={card("plain")} />
</>);
expect(container.querySelector('[data-attention-hidden="archived"]')?.textContent).toMatch(/^Archived /);
expect(container.querySelector('[data-attention-hidden="snoozed"] time')?.getAttribute("dateTime")).toBe("2026-10-31T09:00:00.000Z");
expect(container.querySelectorAll("[data-attention-hidden]")).toHaveLength(2);
});
it("starts open with its count, collapses with one click, and renders nothing when empty", async () => {
const onToggle = vi.fn();
await render(<AttentionSnoozedGroup count={2} open onToggle={onToggle}><p data-child>cards</p></AttentionSnoozedGroup>);
const group = container.querySelector<HTMLElement>("[data-attention-snoozed-group]")!;
expect(group.textContent).toContain("Snoozed (2)");
expect(group.querySelector("[data-child]")).not.toBeNull();
await click(group.querySelector<HTMLElement>("[aria-expanded]"));
expect(onToggle).toHaveBeenCalled();
await render(<AttentionSnoozedGroup count={0} open onToggle={onToggle}><p data-child>cards</p></AttentionSnoozedGroup>);
expect(container.querySelector("[data-attention-snoozed-group]")).toBeNull();
});
});
+630
View File
@@ -0,0 +1,630 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { AlarmClock, Archive, ArrowRightLeft, Ban, Clock, MoreHorizontal, ExternalLink, RotateCcw, UserRound } from "lucide-react";
import type { AttentionItem } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import {
attentionCanWithdraw,
attentionCloseModes,
attentionForOtherText,
attentionHiddenState,
attentionHiddenText,
attentionOtherAudienceUserId,
attentionStaleChipText,
attentionTransferTargets,
attentionUserLabel,
planStaleWithdraw,
} from "@/lib/attention-close";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { IssueGroupHeader } from "./IssueGroupHeader";
import { Textarea } from "./ui/textarea";
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
/** Tomorrow at 9am local time. */
function tomorrowMorningIso(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
d.setHours(9, 0, 0, 0);
return d.toISOString();
}
/** Snooze presets, resolved to a future ISO timestamp at click time. */
const SNOOZE_PRESETS: ReadonlyArray<{ label: string; resolve: () => string }> = [
{ label: "1 hour", resolve: () => new Date(Date.now() + HOUR_MS).toISOString() },
{ label: "4 hours", resolve: () => new Date(Date.now() + 4 * HOUR_MS).toISOString() },
{ label: "Tomorrow morning", resolve: tomorrowMorningIso },
{ label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() },
];
export interface AttentionCloseMenuProps {
item: AttentionItem;
currentUserId: string | null | undefined;
userLabelMap?: ReadonlyMap<string, string> | null;
onWithdraw?: (item: AttentionItem, reason: string) => void;
onArchive?: (item: AttentionItem) => void;
onSnooze?: (item: AttentionItem, until: string) => void;
onTransfer?: (item: AttentionItem, userId: string) => void;
/**
* Restore a card you archived or snoozed (the Archived view, PAP-749 plan
* §3). With it, a hidden card offers Restore instead of Archive, and a
* snoozed card offers "Change snooze time".
*/
onRestore?: (item: AttentionItem) => void;
busy?: boolean;
className?: string;
}
/**
* One Close… menu on every card (PAP-749 plan §2, Rule 6). Each verb has one
* effect: Withdraw request closes it for everyone, Archive and Snooze hide it
* for you only, Restore shows it again, Transfer sends it to a different
* person. The menu shows only the verbs the server listed in `closeModes`;
* the server checks each one again.
*/
export function AttentionCloseMenu({
item,
currentUserId,
userLabelMap,
onWithdraw,
onArchive,
onSnooze,
onTransfer,
onRestore,
busy = false,
className,
}: AttentionCloseMenuProps) {
const [withdrawOpen, setWithdrawOpen] = useState(false);
const [transferTo, setTransferTo] = useState<{ userId: string; label: string } | null>(null);
const otherUserId = attentionOtherAudienceUserId(item, currentUserId);
const otherName = otherUserId ? attentionUserLabel(otherUserId, userLabelMap) : null;
const hidden = onRestore ? attentionHiddenState(item) : null;
const canWithdraw = !!onWithdraw && attentionCanWithdraw(item);
const canRestore = hidden !== null;
const canArchive = !hidden && !!onArchive && attentionCloseModes(item).includes("archive");
const canSnooze = !!onSnooze && attentionCloseModes(item).includes("snooze");
const targets = onTransfer ? attentionTransferTargets(item, currentUserId, userLabelMap) : [];
const href = item.subject.href;
if (!canWithdraw && !canRestore && !canArchive && !canSnooze && targets.length === 0 && !href) return null;
const transfer = (target: { userId: string; label: string }) => {
// Transfer changes who the card is for, so a card for a different person asks first.
if (otherName) setTransferTo(target);
else onTransfer?.(item, target.userId);
};
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
disabled={busy}
className={className ?? "text-muted-foreground"}
aria-label="Close…"
title="Close…"
data-attention-close-menu
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canWithdraw && (
<DropdownMenuItem onSelect={() => setWithdrawOpen(true)} data-attention-close-mode="withdraw">
<Ban className="h-4 w-4" />
Withdraw request…
</DropdownMenuItem>
)}
{canRestore && (
<DropdownMenuItem onSelect={() => onRestore?.(item)} data-attention-close-mode="restore">
<RotateCcw className="h-4 w-4" />
{hidden?.kind === "snoozed" ? "Restore now" : "Restore"}
</DropdownMenuItem>
)}
{canArchive && (
<DropdownMenuItem onSelect={() => onArchive?.(item)} data-attention-close-mode="archive">
<Archive className="h-4 w-4" />
Archive for me
</DropdownMenuItem>
)}
{canSnooze && (
<SnoozeSubmenu
onSnooze={(until) => onSnooze?.(item, until)}
label={hidden?.kind === "snoozed" ? "Change snooze time" : undefined}
/>
)}
{targets.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger data-attention-close-mode="transfer">
<ArrowRightLeft className="h-4 w-4" />
Transfer to…
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{targets.map((target) => (
<DropdownMenuItem key={target.userId} onSelect={() => transfer(target)}>
<UserRound className="h-4 w-4" />
@{target.label}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{href && (
<>
{(canWithdraw || canRestore || canArchive || canSnooze || targets.length > 0) && <DropdownMenuSeparator />}
<DropdownMenuItem asChild>
<Link to={href}>
<ExternalLink className="h-4 w-4" />
Open source
</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{canWithdraw && (
<WithdrawRequestDialog
open={withdrawOpen}
onOpenChange={setWithdrawOpen}
title={item.subject.title}
otherName={otherName}
onConfirm={(reason) => {
setWithdrawOpen(false);
onWithdraw?.(item, reason);
}}
/>
)}
{transferTo && otherName && (
<Dialog open onOpenChange={(open) => { if (!open) setTransferTo(null); }}>
<DialogContent showCloseButton={false} onClick={(event) => event.stopPropagation()}>
<DialogHeader>
<DialogTitle>{attentionForOtherText(otherName, "continue")}</DialogTitle>
<DialogDescription>
The card leaves the list of @{otherName} and goes to @{transferTo.label}. The history records the transfer.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="outline" onClick={() => setTransferTo(null)}>Cancel</Button>
<Button
type="button"
onClick={() => {
onTransfer?.(item, transferTo.userId);
setTransferTo(null);
}}
>
Transfer to @{transferTo.label}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
);
}
/**
* Withdraw closes the request for everyone and is never an approval. It cannot
* be undone, so it always confirms; a card for a different person also says
* "This is for @x. Withdraw anyway?" (PAP-749 plan §2, special conditions).
*/
function WithdrawRequestDialog({
open,
onOpenChange,
title,
otherName,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string | null;
otherName: string | null;
onConfirm: (reason: string) => void;
}) {
const [reason, setReason] = useState("");
return (
<Dialog open={open} onOpenChange={(next) => { if (!next) setReason(""); onOpenChange(next); }}>
<DialogContent showCloseButton={false} onClick={(event) => event.stopPropagation()} data-attention-withdraw-dialog>
<DialogHeader>
<DialogTitle>{otherName ? attentionForOtherText(otherName, "withdraw") : "Withdraw this request?"}</DialogTitle>
<DialogDescription>
{title ? <><span className="font-medium text-foreground">{title}</span>{" — "}</> : null}
This closes the request for everyone and tells the agent it is out of date. It is not an approval. You cannot undo it.
</DialogDescription>
</DialogHeader>
<Textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="Why is it out of date? (optional, shown in history and to the agent)"
aria-label="Reason for withdrawing"
className="min-h-16 text-sm"
/>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="button" variant="destructive" onClick={() => { onConfirm(reason); setReason(""); }}>
Withdraw request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Snooze submenu: presets + a custom date-time. Personal; it does not move a shared deadline. */
export function SnoozeSubmenu({ onSnooze, label = "Snooze for me" }: { onSnooze: (snoozedUntil: string) => void; label?: string }) {
const [customValue, setCustomValue] = useState("");
const applyCustom = () => {
if (!customValue) return;
const ts = new Date(customValue);
if (Number.isNaN(ts.getTime())) return;
onSnooze(ts.toISOString());
};
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger data-attention-close-mode="snooze">
<AlarmClock className="h-4 w-4" />
{label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SNOOZE_PRESETS.map((preset) => (
<DropdownMenuItem key={preset.label} onSelect={() => onSnooze(preset.resolve())}>
{preset.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* Custom picker: a non-menu-item region so interacting with the input
doesn't close the menu (guard keydown/select against Radix typeahead). */}
<div
className="flex flex-col gap-1.5 px-2 py-1.5"
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<span className="text-(length:--text-nano) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Custom
</span>
<input
type="datetime-local"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
aria-label="Snooze until"
className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
/>
<Button type="button" size="xs" disabled={!customValue} onClick={applyCustom}>
Snooze until…
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
/** "Stale · Waiting 30+ days" (PAP-749 plan §2, Rule 10). Nothing for a current card. */
export function AttentionStaleChip({ item }: { item: AttentionItem }) {
const text = attentionStaleChipText(item);
if (!text || !item.stale) return null;
return (
<Badge variant="outline" data-attention-stale={item.stale.reason} className="text-muted-foreground">
<Clock aria-hidden="true" />
Stale · {text}
</Badge>
);
}
/**
* When a card in the Archived view went away and when it comes back (PAP-749
* plan §3): "Archived Oct 29" or "Back Fri, Oct 31, 9:00 AM". Nothing for a
* card you did not hide.
*/
export function AttentionHiddenChip({ item }: { item: AttentionItem }) {
const hidden = attentionHiddenState(item);
if (!hidden) return null;
const at = hidden.kind === "snoozed" ? hidden.until : hidden.since;
return (
<Badge variant="outline" data-attention-hidden={hidden.kind} className="text-muted-foreground" title={new Date(at).toLocaleString()}>
{hidden.kind === "snoozed" ? <AlarmClock aria-hidden="true" /> : <Archive aria-hidden="true" />}
<time dateTime={at}>{attentionHiddenText(hidden)}</time>
</Badge>
);
}
/**
* The Snoozed group of the Archived view (PAP-749 plan §3). Open by default:
* the person came to this view to see these cards. Each card shows when it
* comes back; its Close… menu restores it now or changes the time.
*/
export function AttentionSnoozedGroup({
count,
open,
onToggle,
children,
}: {
count: number;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
if (count === 0) return null;
return (
<section className="space-y-2" aria-label="Snoozed cards" data-attention-snoozed-group>
<IssueGroupHeader
label={`Snoozed (${count})`}
collapsible
collapsed={!open}
onToggle={onToggle}
className="text-muted-foreground"
/>
{open && <div className="space-y-4">{children}</div>}
</section>
);
}
/** The controls that act on a card; links only open something to read. */
const ACTING_CONTROL_SELECTOR = 'button, [role="button"], [role="radio"], [role="checkbox"], input[type="submit"]';
const AttentionActionConfirmationContext = createContext<() => Promise<void>>(() => Promise.resolve());
/** Resolver callbacks must pass the same gate as pointer controls. */
export function useAttentionActionConfirmation() {
return useContext(AttentionActionConfirmationContext);
}
/**
* Before you act on a card for a different person, the UI asks first (PAP-749
* plan §6). The card stays readable: its title and body show at once, and the
* question appears when you press one of its controls (PAP-767). Continue runs
* the control you pressed; the server still checks who may answer.
*/
export function AttentionForOtherGate({
item,
currentUserId,
userLabelMap,
children,
}: {
item: AttentionItem;
currentUserId: string | null | undefined;
userLabelMap?: ReadonlyMap<string, string> | null;
children: ReactNode;
}) {
const otherUserId = attentionOtherAudienceUserId(item, currentUserId);
const audienceKey = `${item.id}:${otherUserId ?? ""}`;
const [confirmedAudience, setConfirmedAudience] = useState<string | null>(null);
const confirmed = confirmedAudience === audienceKey;
const [pending, setPending] = useState<HTMLElement | null>(null);
const [submissionPending, setSubmissionPending] = useState(false);
const confirmation = useRef<{ promise: Promise<void>; resolve(): void; reject(error: Error): void } | null>(null);
const confirmAction = useCallback(() => {
if (!otherUserId || confirmed) return Promise.resolve();
if (confirmation.current) return confirmation.current.promise;
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((accept, cancel) => { resolve = accept; reject = cancel; });
confirmation.current = { promise, resolve, reject };
setSubmissionPending(true);
return promise;
}, [confirmed, otherUserId]);
useEffect(() => {
setPending(null);
setSubmissionPending(false);
return () => {
confirmation.current?.reject(new DOMException("Action cancelled.", "AbortError"));
confirmation.current = null;
};
}, [audienceKey]);
useEffect(() => {
// Run the control that was held back once the person said Continue.
if (!confirmed) return;
const waiting = confirmation.current;
confirmation.current = null;
setSubmissionPending(false);
waiting?.resolve();
if (pending) {
const control = pending;
setPending(null);
if (control.isConnected) control.click();
}
}, [confirmed, pending]);
if (!otherUserId) return <>{children}</>;
// The wrapper stays after Continue, so the card is not remounted and keeps
// what the person typed or picked.
return (
<AttentionActionConfirmationContext.Provider value={confirmAction}>
<div
className="space-y-4"
data-attention-for-other-gate={confirmed ? "confirmed" : pending || submissionPending ? "asking" : "waiting"}
onClickCapture={(event) => {
if (confirmed) return;
const target = event.target instanceof Element ? event.target.closest<HTMLElement>(ACTING_CONTROL_SELECTOR) : null;
if (!target || !event.currentTarget.contains(target) || target.closest("[data-attention-for-other-prompt]")) return;
event.preventDefault();
event.stopPropagation();
setPending(target);
}}
>
{children}
{(pending || submissionPending) && !confirmed ? (
<div
role="group"
aria-live="polite"
data-attention-for-other-prompt
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm"
>
<span>{attentionForOtherText(attentionUserLabel(otherUserId, userLabelMap), "continue")}</span>
<span className="flex items-center gap-2">
<Button type="button" variant="ghost" size="xs" onClick={(event) => {
event.stopPropagation();
const waiting = confirmation.current;
confirmation.current = null;
setPending(null);
setSubmissionPending(false);
waiting?.reject(new DOMException("Action cancelled.", "AbortError"));
}}>
Cancel
</Button>
<Button type="button" variant="outline" size="xs" onClick={(event) => { event.stopPropagation(); setConfirmedAudience(audienceKey); }}>
Continue
</Button>
</span>
</div>
) : null}
</div>
</AttentionActionConfirmationContext.Provider>
);
}
/**
* The collapsed Stale (N) group at the bottom of a view (PAP-749 plan §3).
* "Withdraw all stale" confirms first and lists each card: the ones it will
* withdraw (marked when they are for a different person), the required ones
* that stay with a person, and the ones with no request to withdraw.
*/
export function AttentionStaleGroup({
items,
open,
onToggle,
onWithdrawAll,
busy = false,
currentUserId,
userLabelMap,
titleOf = attentionCardHeadline,
children,
}: {
items: AttentionItem[];
open: boolean;
onToggle: () => void;
onWithdrawAll?: (items: AttentionItem[], reason: string) => void | Promise<unknown>;
busy?: boolean;
currentUserId: string | null | undefined;
userLabelMap?: ReadonlyMap<string, string> | null;
/** The title each card leads with in this layout, so the confirmation names cards as they read (PAP-767). */
titleOf?: (item: AttentionItem) => string;
children: ReactNode;
}) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [reason, setReason] = useState("");
const { withdrawable, required, kept } = planStaleWithdraw(items);
if (items.length === 0) return null;
const forOthers = withdrawable.filter((item) => attentionOtherAudienceUserId(item, currentUserId));
const noun = withdrawable.length === 1 ? "request" : "requests";
const closeDialog = () => { setConfirmOpen(false); setReason(""); };
return (
<section className="space-y-2" aria-label="Stale cards" data-attention-stale-group>
<IssueGroupHeader
label={`Stale (${items.length})`}
collapsible
collapsed={!open}
onToggle={onToggle}
className="text-muted-foreground"
trailing={onWithdrawAll && withdrawable.length > 0 ? (
<Button type="button" variant="outline" size="xs" disabled={busy} onClick={() => setConfirmOpen(true)}>
<Ban aria-hidden="true" />
{busy ? "Withdrawing…" : "Withdraw all stale"}
</Button>
) : null}
/>
{open && <div className="space-y-4">{children}</div>}
<Dialog open={confirmOpen} onOpenChange={(next) => { if (!next) closeDialog(); }}>
<DialogContent showCloseButton={false} data-attention-stale-withdraw-dialog>
<DialogHeader>
<DialogTitle>Withdraw {withdrawable.length} stale {noun}?</DialogTitle>
<DialogDescription>
Each request closes for everyone and its agent is told it is out of date. None of them counts as approved. You cannot undo this.
</DialogDescription>
</DialogHeader>
{forOthers.length > 0 && (
<p className="rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm" data-attention-stale-for-others>
{forOthers.length === 1
? attentionForOtherText(attentionUserLabel(attentionOtherAudienceUserId(forOthers[0]!, currentUserId)!, userLabelMap), "continue")
: `${forOthers.length} of these are for other people. Continue?`}
</p>
)}
<StaleItemList items={withdrawable} currentUserId={currentUserId} userLabelMap={userLabelMap} titleOf={titleOf} label="Will be withdrawn" />
{required.length > 0 && (
<StaleItemList items={required} currentUserId={currentUserId} userLabelMap={userLabelMap} titleOf={titleOf} label="Stay open: required, withdraw one at a time" />
)}
{kept.length > 0 && (
<StaleItemList items={kept} currentUserId={currentUserId} userLabelMap={userLabelMap} titleOf={titleOf} label="Stay open: no request to withdraw" />
)}
<Textarea
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="Why are they out of date? (optional, shown in history and to the agents)"
aria-label="Reason for withdrawing stale requests"
className="min-h-16 text-sm"
/>
<DialogFooter className="sm:justify-between">
<Button type="button" variant="outline" onClick={closeDialog}>Cancel</Button>
<Button
type="button"
variant="destructive"
disabled={busy}
onClick={() => {
const confirmedReason = reason;
closeDialog();
void onWithdrawAll?.(withdrawable, confirmedReason);
}}
>
Withdraw {withdrawable.length} {noun}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
/** The card title the classic row leads with (`AttentionQueueRow`). */
function attentionCardHeadline(item: AttentionItem) {
return item.subject.title ?? item.whyNow;
}
function StaleItemList({
items,
label,
currentUserId,
userLabelMap,
titleOf,
}: {
items: AttentionItem[];
label: string;
currentUserId: string | null | undefined;
userLabelMap?: ReadonlyMap<string, string> | null;
titleOf: (item: AttentionItem) => string;
}) {
return (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<ul className="max-h-48 space-y-1 overflow-auto text-sm" data-attention-stale-list={label}>
{items.map((item) => {
const otherUserId = attentionOtherAudienceUserId(item, currentUserId);
const task = item.relatedIssue?.identifier ?? (item.subject.kind === "issue" ? item.subject.identifier : null);
return (
<li key={item.id} className="flex flex-wrap items-baseline gap-x-2" data-attention-stale-item={item.id}>
<span className="min-w-0 truncate font-medium text-foreground">{titleOf(item)}</span>
{task && <span className="font-mono text-xs text-muted-foreground">{task}</span>}
<span className="text-xs text-muted-foreground">{attentionStaleChipText(item)}</span>
{otherUserId && <span className="text-xs text-muted-foreground">For @{attentionUserLabel(otherUserId, userLabelMap)}</span>}
</li>
);
})}
</ul>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { useRef, useState, type ReactNode } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ArrowUpRight, ExternalLink, Loader2, RotateCcw, TriangleAlert } from "lucide-react";
import type { AttentionItem } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { budgetsApi } from "../api/budgets";
import { issuesApi } from "../api/issues";
import { useOptionalToastActions } from "../context/ToastContext";
import { invalidateAttentionRecordViews } from "../lib/attention-record-views";
import { taskRetryNotStartedReason } from "../hooks/useTaskRetry";
import { queryKeys } from "../lib/queryKeys";
import { cn, formatCents } from "../lib/utils";
import { DecisionCardFooter } from "./DecisionCardFooter";
import { NeedsYouPanel, NeedsYouPanelNote, NeedsYouPanelRow } from "./coordinator/NeedsYouCardKit";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
/**
* Inline verbs for the cards whose server actions now work in place (PAP-749
* plan §2, Rule 7): Retry on a failed run, and Raise limit / Keep paused on a
* budget stop. Each verb calls the same endpoint as its native page, so the
* activity log and the authorization checks are the same.
*/
function ActionsFooter({ leading, children }: { leading?: ReactNode; children: ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-2" data-attention-actions="true">
{leading ?? <span />}
<div className="flex flex-wrap items-center gap-2">{children}</div>
</div>
);
}
function metadataString(item: AttentionItem, key: string): string | null {
const value = item.subject.metadata?.[key];
return typeof value === "string" && value ? value : null;
}
function metadataNumber(item: AttentionItem, key: string): number | null {
const value = item.subject.metadata?.[key];
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
/**
* "card" is the Needs You card (PAP-775): an inset panel with the context, then
* the footer with the primary action last. "panel" (default) is the row layout
* the decisions queue uses.
*/
export type InlineVerbsPresentation = "card" | "panel";
function failedRunError(item: AttentionItem): string {
const excerpt = item.detail?.kind === "failed_run" || item.detail?.kind === "agent_error" ? item.detail.failureReasonExcerpt : null;
return metadataString(item, "error") ?? metadataString(item, "errorReason") ?? excerpt ?? metadataString(item, "errorCode") ?? "The run failed";
}
/** Retry the failed run's task, and open the run log. */
export function FailedRunActions({
item,
companyId,
leading,
footerContext,
onResolved,
presentation = "panel",
}: {
item: AttentionItem;
companyId: string;
leading?: ReactNode;
footerContext?: ReactNode;
onResolved?: () => void;
presentation?: InlineVerbsPresentation;
}) {
const queryClient = useQueryClient();
const toasts = useOptionalToastActions();
const issueId = metadataString(item, "issueId") ?? item.relatedIssue?.id ?? null;
// One idempotency key per card, so a double click or a lost response asks once.
const idempotencyKey = useRef<string | null>(null);
const [retryNotice, setRetryNotice] = useState<string | null>(null);
const retry = useMutation({
mutationFn: () => {
idempotencyKey.current ??= crypto.randomUUID();
return issuesApi.retryTask(issueId!, { idempotencyKey: idempotencyKey.current, observedRunId: item.subject.id });
},
onMutate: () => setRetryNotice(null),
onSuccess: (result) => {
const notStarted = taskRetryNotStartedReason(result);
setRetryNotice(notStarted);
toasts?.pushToast({
title: notStarted ? "Retry did not start" : "Retry requested",
body: notStarted ?? item.relatedIssue?.identifier ?? undefined,
tone: notStarted ? "info" : "success",
});
void queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
if (issueId) void queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId) });
if (!notStarted) onResolved?.();
},
onError: (error) => {
idempotencyKey.current = null;
toasts?.pushToast({ title: "Could not retry this run", body: error instanceof Error ? error.message : undefined, tone: "error" });
},
});
if (presentation === "card") {
const error = failedRunError(item);
const reason = metadataString(item, "retryExhaustedReason");
const note = [reason && reason !== error ? reason : null, issueId ? "Retry starts a new run." : null].filter(Boolean).join(" ");
return <>
<NeedsYouPanel>
<NeedsYouPanelRow icon={<TriangleAlert className="text-destructive" />} title={error} />
{note ? <NeedsYouPanelNote>{note}</NeedsYouPanelNote> : null}
</NeedsYouPanel>
{footerContext}
{retryNotice ? <p role="status" className="text-sm text-muted-foreground">{retryNotice}</p> : null}
<DecisionCardFooter leading={leading}>
{item.subject.href && (
<Button asChild variant={issueId ? "outline" : "default"} size="sm">
<Link to={item.subject.href}>View run</Link>
</Button>
)}
{issueId && (
<Button type="button" size="sm" disabled={retry.isPending} onClick={() => retry.mutate()}>
{retry.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Retry run
</Button>
)}
</DecisionCardFooter>
</>;
}
return (
<>
{retryNotice ? <p role="status" className="text-sm text-muted-foreground">{retryNotice}</p> : null}
<ActionsFooter leading={leading}>
{item.subject.href && (
<Button asChild variant="outline" size="sm">
<Link to={item.subject.href}>
Open run
<ExternalLink className="h-3.5 w-3.5" />
</Link>
</Button>
)}
{issueId && (
<Button type="button" size="sm" disabled={retry.isPending} onClick={() => retry.mutate()}>
{retry.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
Retry
</Button>
)}
</ActionsFooter>
</>
);
}
function centsInputValue(value: number) {
return (value / 100).toFixed(2);
}
function parseDollarInput(value: string) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return Math.round(parsed * 100);
}
/**
* Raise limit / Keep paused on a budget card. "Keep paused" records that the
* pause continues; it never says that work started again (PAP-749 plan §2,
* Rule 6). A warning does not pause work, so its second verb dismisses it.
*/
export function BudgetAlertActions({
item,
companyId,
leading,
footerContext,
onResolved,
presentation = "panel",
}: {
item: AttentionItem;
companyId: string;
leading?: ReactNode;
footerContext?: ReactNode;
onResolved?: () => void;
presentation?: InlineVerbsPresentation;
}) {
const queryClient = useQueryClient();
const toasts = useOptionalToastActions();
// The card asks for the new limit only once you choose to increase it.
const [raising, setRaising] = useState(false);
const observed = metadataNumber(item, "amountObserved") ?? 0;
const limit = metadataNumber(item, "amountLimit") ?? 0;
const isWarning = item.subject.metadata?.thresholdType === "soft";
const [draft, setDraft] = useState(() => centsInputValue(Math.max(observed + 1000, limit + (isWarning ? 1000 : 0))));
const amount = parseDollarInput(draft);
const tooLow = amount !== null && amount <= observed;
const resolve = useMutation({
mutationFn: (input: { action: "keep_paused" | "raise_budget_and_resume"; amount?: number }) =>
budgetsApi.resolveIncident(companyId, item.subject.id, input),
onSuccess: (_result, input) => {
toasts?.pushToast({
title: input.action === "keep_paused" ? (isWarning ? "Warning dismissed" : "Kept paused") : "Limit raised",
body: item.subject.title ?? undefined,
tone: "success",
});
// The budget page and the folded override approval show the same stop.
invalidateAttentionRecordViews(queryClient, companyId, item);
onResolved?.();
},
onError: (error) => {
toasts?.pushToast({ title: "Could not update the budget", body: error instanceof Error ? error.message : undefined, tone: "error" });
},
});
const limitInput = (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`attention-budget-${item.id}`}>
New limit (USD)
</label>
<Input
id={`attention-budget-${item.id}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
inputMode="decimal"
className="sm:max-w-40"
autoFocus={presentation === "card"}
/>
</div>
);
const raiseDisabled = resolve.isPending || amount === null || tooLow;
const raise = () => { if (amount !== null) resolve.mutate({ action: "raise_budget_and_resume", amount }); };
if (presentation === "card") {
const percent = metadataNumber(item, "observedPercent") ?? (limit > 0 ? Math.round((observed / limit) * 100) : 0);
const scope = metadataString(item, "scopeType");
return <>
<NeedsYouPanel>
<div className="flex items-baseline justify-between gap-3">
<span className="truncate text-(length:--text-compact) font-medium text-foreground">{`${formatCents(observed)} spent of ${formatCents(limit)}`}</span>
<span className="shrink-0 text-xs text-muted-foreground">{`${percent}% used`}</span>
</div>
<div className="h-1 overflow-hidden rounded-full bg-muted" role="progressbar" aria-label="Budget used" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.min(percent, 100)}>
<div className={cn("h-full rounded-full", !isWarning || percent >= 100 ? "bg-destructive" : "bg-amber-500")} style={{ width: `${Math.min(percent, 100)}%` }} />
</div>
<NeedsYouPanelNote>
{[scope ? `${scope[0]!.toUpperCase()}${scope.slice(1)} limit` : "Limit", isWarning ? "Work continues until the limit is reached" : "New runs are paused"].join(" · ")}
</NeedsYouPanelNote>
{raising ? limitInput : null}
{raising && tooLow ? <p className="text-xs text-destructive">The new limit must be more than the current spend.</p> : null}
</NeedsYouPanel>
{footerContext}
<DecisionCardFooter leading={leading}>
<Button type="button" variant="ghost" size="sm" disabled={resolve.isPending} onClick={() => resolve.mutate({ action: "keep_paused" })}>
{isWarning ? "Dismiss warning" : "Keep paused"}
</Button>
{raising ? <>
<Button type="button" variant="outline" size="sm" disabled={resolve.isPending} onClick={() => setRaising(false)}>Cancel</Button>
<Button type="button" size="sm" disabled={raiseDisabled} onClick={raise}>
{resolve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{isWarning ? "Raise limit" : "Raise limit & resume"}
</Button>
</> : <Button type="button" size="sm" disabled={resolve.isPending} onClick={() => setRaising(true)}>Increase limit…</Button>}
</DecisionCardFooter>
</>;
}
return (
<div className="space-y-3" data-attention-budget-actions>
<p className="text-sm text-muted-foreground">
{`Spend is ${formatCents(observed)} of a ${formatCents(limit)} limit.`}
{isWarning ? " This warning does not pause work." : " Work in this scope is paused."}
</p>
{limitInput}
{tooLow && <p className="text-xs text-destructive">The new limit must be more than the current spend.</p>}
<ActionsFooter leading={leading}>
<Button
type="button"
variant="outline"
size="sm"
disabled={resolve.isPending}
onClick={() => resolve.mutate({ action: "keep_paused" })}
>
{isWarning ? "Dismiss warning" : "Keep paused"}
</Button>
<Button type="button" size="sm" disabled={raiseDisabled} onClick={raise}>
{resolve.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUpRight className="h-3.5 w-3.5" />}
{isWarning ? "Raise limit" : "Raise limit & resume"}
</Button>
</ActionsFooter>
</div>
);
}
@@ -5,9 +5,11 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, expect, it, vi } from "vitest";
import { issuesApi } from "../api/issues";
import { pendingRequestConfirmationInteraction, pendingRequestItemVerdictsInteraction } from "../fixtures/issueThreadInteractionFixtures";
import { pendingAskUserQuestionsInteraction, pendingRequestConfirmationInteraction, pendingRequestItemVerdictsInteraction } from "../fixtures/issueThreadInteractionFixtures";
import { ThemeProvider } from "../context/ThemeContext";
import { TooltipProvider } from "./ui/tooltip";
import type { AttentionItem, AskUserQuestionsInteraction } from "@paperclipai/shared";
import { AttentionForOtherGate } from "./AttentionCloseMenu";
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -17,7 +19,14 @@ vi.mock("@/lib/router", () => ({
}));
vi.mock("../api/issues", () => ({
issuesApi: { listInteractions: vi.fn(), acceptInteraction: vi.fn(), submitInteractionVerdicts: vi.fn() },
issuesApi: { listInteractions: vi.fn(), respondToInteraction: vi.fn(), acceptInteraction: vi.fn(), submitInteractionVerdicts: vi.fn() },
}));
vi.mock("./task-chat/TaskChatRichInput", () => ({
TaskChatRichInput: ({ value, onSubmit }: { value: string; onSubmit(): void }) =>
<textarea aria-label="Answer" readOnly value={value} onKeyDown={event => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) onSubmit();
}} />,
}));
afterEach(() => {
@@ -86,3 +95,40 @@ it("refreshes a preparing approval automatically, then stops polling without acc
client.clear();
}
});
it.each(["ctrlKey", "metaKey"] as const)("confirms another person's keyboard answer before sending (%s)", async (modifier) => {
const interaction: AskUserQuestionsInteraction = { ...pendingAskUserQuestionsInteraction,
id: `keyboard-${modifier}`, addresseeUserId: "dotta", payload: { version: 1, questions: [{ id: "answer", prompt: "What should we ship?", selectionMode: "single", options: [{ id: "text", label: "Write an answer", freeText: true }], allowOther: false }], questionSet: {
schema: "paperclip.question_set.v1", questions: [{ id: "answer", prompt: "What should we ship?", required: true,
answerMode: "text", initialText: "My draft answer" }],
} } };
vi.mocked(issuesApi.listInteractions).mockResolvedValue([interaction]);
vi.mocked(issuesApi.respondToInteraction).mockResolvedValue({ ...interaction, status: "answered" });
const item = { id: "keyboard-card", audience: { kind: "user", userId: "dotta", reason: "addressee" } } as AttentionItem;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const host = document.createElement("div"); document.body.append(host); const root = createRoot(host);
const click = async (label: string) => act(async () => [...host.querySelectorAll("button")].find(button => button.textContent?.trim() === label)!.click());
const sendShortcut = async () => act(async () => host.querySelector("textarea")!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", [modifier]: true, bubbles: true })));
try {
await act(async () => root.render(<QueryClientProvider client={client}><TooltipProvider><ThemeProvider>
<AttentionForOtherGate item={item} currentUserId="scott" userLabelMap={new Map([["dotta", "Dotta"]])}>
<AttentionInteractionResolver companyId={interaction.companyId} issueId={interaction.issueId} interactionId={interaction.id} currentUserId="scott" />
</AttentionForOtherGate>
</ThemeProvider></TooltipProvider></QueryClientProvider>));
await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
await sendShortcut();
expect(issuesApi.respondToInteraction).not.toHaveBeenCalled();
expect(host.textContent).toContain("This is for @Dotta. Continue?");
await click("Cancel");
expect(issuesApi.respondToInteraction).not.toHaveBeenCalled();
expect(host.querySelector("textarea")!.value).toBe("My draft answer");
expect(host.querySelector('[role="alert"]')).toBeNull();
expect(host.textContent).not.toContain("Action cancelled");
await sendShortcut();
expect(issuesApi.respondToInteraction).not.toHaveBeenCalled();
await click("Continue");
expect(issuesApi.respondToInteraction).toHaveBeenCalledExactlyOnceWith(interaction.issueId, interaction.id,
{ answers: [{ questionId: "answer", optionIds: [], otherText: "My draft answer" }] });
} finally { await act(async () => root.unmount()); host.remove(); client.clear(); }
});
@@ -4,6 +4,7 @@ import { Loader2 } from "lucide-react";
import type { Agent } from "@paperclipai/shared";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { invalidateInteractionRecordViews } from "../lib/attention-record-views";
import {
interactionReadinessRefetchInterval,
isIssueThreadInteraction,
@@ -16,6 +17,7 @@ import {
type RequestItemVerdictValue,
type SuggestTasksInteraction,
} from "../lib/issue-thread-interactions";
import { useAttentionActionConfirmation } from "./AttentionCloseMenu";
import { DecisionCardFooter } from "./DecisionCardFooter";
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
@@ -27,7 +29,15 @@ interface AttentionInteractionResolverProps {
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
footerSlot?: ReactNode;
/** A ready-made panel row for the decision's artifact, first in the compact
* card's context panel (PAP-775). */
artifactRow?: ReactNode;
/** The same decision is shown in another view (card + Review side panel):
* share one Revise… draft between them (PAP-773). */
shareReviseDraft?: boolean;
presentation?: "default" | "coordinator-decision";
/** The card's audience line already names who may answer (PAP-767). */
audienceStated?: boolean;
/** Successful updates include partial item reviews; inspect status before settling the whole card. */
onResolved?: (interaction?: IssueThreadInteraction) => void;
}
@@ -49,6 +59,10 @@ export function replaceResolvedInteraction(
* row and renders the existing {@link IssueThreadInteractionCard} inline, so
* confirmations and questions are answerable in-row without leaving the queue
* (converged PAP-12628). Reviews never reach here — they deep-link.
*
* There is no Cancel here: closing a request is the card's Close… menu
* ("Withdraw request"), which works for every interaction kind (PAP-749 plan
* §2, Rule 6). The task page keeps its own controls.
*/
export function AttentionInteractionResolver({
companyId,
@@ -58,10 +72,14 @@ export function AttentionInteractionResolver({
currentUserId,
userLabelMap,
presentation,
audienceStated,
footerSlot,
artifactRow,
shareReviseDraft,
onResolved,
}: AttentionInteractionResolverProps) {
const queryClient = useQueryClient();
const confirmAction = useAttentionActionConfirmation();
const { data: interactions, isLoading, error } = useQuery({
queryKey: queryKeys.issues.interactions(issueId),
@@ -86,10 +104,8 @@ export function AttentionInteractionResolver({
(current) => replaceResolvedInteraction(current, resolvedInteraction),
);
}
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
queryClient.invalidateQueries({ queryKey: ["issues", "pending-questions"] });
queryClient.invalidateQueries({ queryKey: ["issues", "decision-receipts"] });
// Task detail keys the same list by identifier, so refresh every view (UX2).
invalidateInteractionRecordViews(queryClient, companyId);
onResolved?.(resolvedInteraction);
};
@@ -120,12 +136,6 @@ export function AttentionInteractionResolver({
onSuccess: invalidate,
});
const cancelMutation = useMutation({
mutationFn: (input: { interactionId: string }) =>
issuesApi.cancelInteraction(issueId, input.interactionId),
onSuccess: invalidate,
});
const verdictsMutation = useMutation({
mutationFn: (input: {
interactionId: string;
@@ -160,26 +170,30 @@ export function AttentionInteractionResolver({
<IssueThreadInteractionCard
interaction={interaction}
presentationVariant={presentation}
audienceStated={audienceStated}
footerSlot={footerSlot}
artifactRow={artifactRow}
primaryActionOnRight={presentation === "coordinator-decision"}
shareReviseDraft={shareReviseDraft}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onAcceptInteraction={(target, selectedClientKeys, selectedOptionIds, rememberAction) =>
acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds, rememberAction }).then(() => undefined)
}
onRejectInteraction={(target, reason) =>
rejectMutation.mutateAsync({ interactionId: target.id, reason }).then(() => undefined)
}
onSubmitInteractionAnswers={(target: AskUserQuestionsInteraction, answers) =>
respondMutation.mutateAsync({ interactionId: target.id, answers }).then(() => undefined)
}
onCancelInteraction={(target: AskUserQuestionsInteraction) =>
cancelMutation.mutateAsync({ interactionId: target.id }).then(() => undefined)
}
onSubmitInteractionVerdicts={(target: RequestItemVerdictsInteraction, verdicts) =>
verdictsMutation.mutateAsync({ interactionId: target.id, verdicts }).then(() => undefined)
}
onAcceptInteraction={async (target, selectedClientKeys, selectedOptionIds, rememberAction) => {
await confirmAction();
await acceptMutation.mutateAsync({ interaction: target, selectedClientKeys, selectedOptionIds, rememberAction });
}}
onRejectInteraction={async (target, reason) => {
await confirmAction();
await rejectMutation.mutateAsync({ interactionId: target.id, reason });
}}
onSubmitInteractionAnswers={async (target: AskUserQuestionsInteraction, answers) => {
await confirmAction();
await respondMutation.mutateAsync({ interactionId: target.id, answers });
}}
onSubmitInteractionVerdicts={async (target: RequestItemVerdictsInteraction, verdicts) => {
await confirmAction();
await verdictsMutation.mutateAsync({ interactionId: target.id, verdicts });
}}
/>
);
}
@@ -0,0 +1,47 @@
import { ArrowUpRight } from "lucide-react";
import type { AttentionItem } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { attentionEvidenceLabel, attentionEvidenceText } from "@/lib/attention-obligations";
import { cn } from "@/lib/utils";
/**
* The rest of one obligation, folded into its lead card (PAP-749 plan §2,
* Rule 11): the task that waits on an approval, or the work a failed run
* blocks. One line each; the lead card keeps the one action.
*/
export function AttentionObligationEvidence({
item,
evidence,
className,
}: {
item: AttentionItem;
evidence: readonly AttentionItem[] | undefined;
className?: string;
}) {
if (!evidence?.length) return null;
return (
<ul
aria-label="Part of this request"
data-attention-obligation={item.obligationKey ?? undefined}
className={cn("space-y-1 border-l-2 border-border pl-3 text-xs leading-5 text-muted-foreground", className)}
>
{evidence.map((entry) => {
const { title, detail } = attentionEvidenceText(entry);
return (
<li key={entry.id} data-attention-evidence={entry.id} data-attention-evidence-source={entry.sourceKind}>
<span className="font-medium text-foreground">{attentionEvidenceLabel(item, entry)}: </span>
{entry.subject.href ? (
<Link to={entry.subject.href} className="inline-flex items-center gap-1 hover:text-foreground">
{title}
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</Link>
) : (
<span>{title}</span>
)}
{detail ? <span> — {detail}</span> : null}
</li>
);
})}
</ul>
);
}
+82 -8
View File
@@ -6,12 +6,13 @@ import { useState, type AnchorHTMLAttributes, type ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AttentionItem, AttentionSourceKind } from "@paperclipai/shared";
import { accessApi } from "../api/access";
import { approvalsApi } from "../api/approvals";
import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
import { ToastViewport } from "./ToastViewport";
import { ToastProvider } from "../context/ToastContext";
import { AttentionQueueRow } from "./AttentionQueueRow";
import { ApprovalResolver, JoinRequestResolver, AttentionQueueRow } from "./AttentionQueueRow";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => (
@@ -19,6 +20,7 @@ vi.mock("@/lib/router", () => ({
),
}));
vi.mock("../api/access", () => ({ accessApi: { approveJoinRequest: vi.fn(), rejectJoinRequest: vi.fn() } }));
vi.mock("../api/approvals", () => ({
approvalsApi: {
approve: vi.fn(),
@@ -55,6 +57,7 @@ function act<T>(cb: () => T): T {
let root: ReturnType<typeof createRoot> | null = null;
let container: HTMLDivElement | null = null;
let queryClient: QueryClient | null = null;
afterEach(() => {
if (root) act(() => root?.unmount());
@@ -69,6 +72,7 @@ function render(element: ReactElement) {
document.body.appendChild(container);
root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient = client;
act(() =>
root?.render(
<ToastProvider>
@@ -299,7 +303,26 @@ describe("AttentionQueueRow", () => {
expect(el.textContent).not.toContain("Send back to work");
});
it("fires onDismiss from the row menu action", () => {
it("folds the rest of its obligation into the row as evidence (PAP-749 task 2d)", () => {
const lead = buildItem({ obligationKey: "approval:approval-1", obligationLeadId: "a1" });
const review = buildItem({
id: "review:t1",
sourceKind: "review",
subject: { kind: "issue", id: "t1", companyId: "c1", title: "Launch page", identifier: "PAP-8", status: "in_review", href: "/PAP/issues/PAP-8" },
whyNow: "Issue is in review with a linked pending approval.",
obligationKey: "approval:approval-1",
obligationLeadId: "a1",
});
const el = render(
<AttentionQueueRow item={lead} companyId="c1" expanded={false} onToggleExpand={noop} onDismiss={noop} evidence={[review]} />,
);
const list = el.querySelector('[data-attention-obligation="approval:approval-1"]');
expect(list?.getAttribute("aria-label")).toBe("Part of this request");
expect(list?.querySelector('[data-attention-evidence="review:t1"]')?.textContent)
.toBe("Task waiting on this approval: PAP-8 Launch page — Issue is in review with a linked pending approval.");
});
it("puts Archive for me in the Close… menu", () => {
const onDismiss = vi.fn();
const item = buildItem();
render(
@@ -311,11 +334,11 @@ describe("AttentionQueueRow", () => {
onDismiss={onDismiss}
/>,
);
// The dropdown trigger + item live in a portal; invoke the handler contract
// directly via the rendered menu after opening is environment-flaky in
// jsdom, so assert the wiring by locating the trigger exists.
const trigger = container?.querySelector('[aria-label="Row actions"]');
expect(trigger).toBeTruthy();
// The Close… menu replaces the old row menu (PAP-749 Rule 6). Its items
// live in a portal and opening is environment-flaky in jsdom, so assert the
// wiring by locating the trigger; AttentionCloseMenu.test covers the verbs.
const trigger = container?.querySelector("[data-attention-close-menu]");
expect(trigger?.getAttribute("aria-label")).toBe("Close…");
});
it("toggles expand when the collapsed header of an inline row is clicked", () => {
@@ -533,7 +556,7 @@ describe("AttentionQueueRow", () => {
expect(actionArea?.getAttribute("class")).toContain("justify-between");
expect(decisionActions?.parentElement?.getAttribute("class")).toContain("@xl:justify-end");
const rowMenu = container?.querySelector('[aria-label="Row actions"]');
const rowMenu = container?.querySelector("[data-attention-close-menu]");
expect(rowMenu?.closest('[data-attention-menu="true"]')).toBeTruthy();
expect(rowMenu?.closest('[data-attention-actions="true"]')).toBeNull();
@@ -564,12 +587,16 @@ describe("AttentionQueueRow", () => {
const approve = Array.from(container?.querySelectorAll("button") ?? []).find(
(button) => button.textContent === "Approve",
);
const invalidate = vi.spyOn(queryClient!, "invalidateQueries");
act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(approvalsApi.approve).toHaveBeenCalledWith("approval-1");
expect(onToggleExpand).not.toHaveBeenCalled();
expect(container?.textContent).toContain("Approval approved");
// An open approval page refreshes too (PAP-771).
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["approvals", "detail", "approval-1"] });
expect(invalidate).toHaveBeenCalledWith({ queryKey: ["approvals", "c1"] });
});
it("renders configured confirmation labels and accepts from the compact action area", async () => {
@@ -610,11 +637,17 @@ describe("AttentionQueueRow", () => {
const approve = Array.from(decisionActions?.querySelectorAll("button") ?? []).find(
(button) => button.textContent === "Approve plan",
);
const invalidate = vi.spyOn(queryClient!, "invalidateQueries");
act(() => approve?.dispatchEvent(new MouseEvent("click", { bubbles: true })));
await new Promise((resolve) => setTimeout(resolve, 0));
expect(issuesApi.acceptInteraction).toHaveBeenCalledWith("issue-1", "interaction-1");
expect(onToggleExpand).not.toHaveBeenCalled();
// One record, many views (PAP-771): task detail and chat refresh too.
for (const queryKey of [["attention", "c1"], ["issues", "interactions", "issue-1"], ["issues", "pending-questions"], ["issues", "decision-receipts"]]) {
expect(invalidate).toHaveBeenCalledWith({ queryKey });
}
expect(invalidate).not.toHaveBeenCalledWith({ queryKey: ["access", "join-requests", "c1", "pending_approval"] });
});
it("opens the matching confirmation form when requesting changes from a compact action", () => {
@@ -943,6 +976,28 @@ describe("AttentionQueueRow", () => {
expect(summary?.parentElement?.className).toContain("break-words");
});
it("says who may answer once, in the audience line, when it knows the viewer (PAP-767)", () => {
render(
<AttentionQueueRow
item={{
...interactionItem({ ...openAudience, addresseeAgentId: "agent-nap", addresseeName: "Napping Agent" }),
audience: { kind: "pool", userId: null, reason: "none" },
}}
companyId="c1"
currentUserId="scott"
expanded={false}
onToggleExpand={noop}
onDismiss={noop}
/>,
);
const line = container?.querySelector('[data-attention-audience="pool"]');
expect(line?.textContent).toContain("No owner yet. Napping Agent or any board member can answer it.");
expect(container?.textContent).not.toContain("Any board member can answer it.");
// No second, contradicting clause beside the buttons.
expect(container?.querySelector('[data-testid="interaction-audience"]')).toBeNull();
});
it("renders no audience line when the feed carries no resolver metadata", () => {
render(
<AttentionQueueRow
@@ -1009,3 +1064,22 @@ describe("AttentionQueueRow", () => {
expect(container?.querySelector('[data-testid="attention-train-button"]')).toBeNull();
});
});
describe("decision write failures", () => {
it.each(["approve", "reject", "requestRevision"] as const)("shows a failed approval %s beside its controls", async (method) => {
vi.mocked(approvalsApi[method]).mockRejectedValueOnce(new Error("Decision could not be saved"));
const host = render(<ApprovalResolver item={buildItem()} companyId="c1" toggle={null} presentation={method === "requestRevision" ? "panel" : "card"} />);
const label = method === "approve" ? "Approve" : method === "reject" ? "Reject" : "Request revision";
act(() => [...host.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === label)!.click());
await vi.waitFor(() => expect(host.querySelector('[role="alert"]')?.textContent).toContain("Decision could not be saved"));
});
it.each(["approve", "reject"] as const)("shows a failed join-request %s beside its controls", async (action) => {
const method = action === "approve" ? "approveJoinRequest" : "rejectJoinRequest";
vi.mocked(accessApi[method]).mockRejectedValueOnce(new Error("Join request could not be saved"));
const host = render(<JoinRequestResolver item={buildItem({ sourceKind: "join_request" })} companyId="c1" toggle={null} presentation="card" />);
const label = action === "approve" ? "Approve access" : "Decline";
act(() => [...host.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === label)!.click());
await vi.waitFor(() => expect(host.querySelector('[role="alert"]')?.textContent).toContain("Join request could not be saved"));
});
});
+238 -188
View File
@@ -1,23 +1,27 @@
import { memo, useState, type KeyboardEvent, type ReactNode } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
AlarmClock,
Bot,
CalendarClock,
ChevronDown,
ChevronUp,
ExternalLink,
Loader2,
MoreHorizontal,
RotateCcw,
X,
UserRound,
} from "lucide-react";
import type { Agent, AttentionDetailImage, AttentionItem } from "@paperclipai/shared";
import { type Agent, type AttentionDetailImage, type AttentionItem } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { accessApi } from "../api/access";
import { approvalsApi } from "../api/approvals";
import { issuesApi } from "../api/issues";
import { useToastActions } from "../context/ToastContext";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { queryKeys } from "../lib/queryKeys";
import { ApprovalCardPanel } from "./ApprovalCardPanel";
import { DecisionCardFooter } from "./DecisionCardFooter";
import { NeedsYouPanel, NeedsYouPanelNote, NeedsYouPanelRow } from "./coordinator/NeedsYouCardKit";
import { useToastActions } from "../context/ToastContext";
import { invalidateAttentionRecordViews } from "../lib/attention-record-views";
import { claimReviseDraftWork, clearReviseDraft, releaseReviseDraftWork, reviseDraftKey, useReviseDraft, type ReviseDraftWork } from "../lib/revise-draft-store";
import { describeAttentionResolverAudience, type InteractionAudienceDescription } from "../lib/interaction-audience";
import { interactionResolutionErrorMessage } from "../lib/interaction-resolution-error";
import {
@@ -33,27 +37,20 @@ import {
import { cn, relativeTime } from "../lib/utils";
import { DecisionTriageStrip } from "./DecisionTriageStrip";
import { InteractionAudienceLine } from "./InteractionAudienceLine";
import { AttentionAudienceLine } from "./AttentionViews";
import { attentionAudienceLineNamesResponders } from "../lib/attention-views";
import { AttentionObligationEvidence } from "./AttentionObligationEvidence";
import { StatusGlyph } from "./StatusGlyph";
import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent } from "./ui/collapsible";
import { Textarea } from "./ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { AttentionCloseMenu, AttentionForOtherGate, AttentionHiddenChip, AttentionStaleChip } from "./AttentionCloseMenu";
import { BudgetAlertActions, FailedRunActions } from "./AttentionInlineVerbs";
import { AttentionInteractionResolver } from "./AttentionInteractionResolver";
import { DecisionResolver } from "./DecisionResolver";
import { StalledReviewActions } from "./StalledReviewActions";
import { readIssueReviewPolicyMetadata } from "../lib/review-policy";
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
import { attentionOtherAudienceUserId } from "../lib/attention-close";
// Decision-action buttons: a comfortable tap target when the row is narrow
// (h-9 / text-sm), shrinking back to the dense pill (h-6 / text-xs) once the
@@ -61,34 +58,29 @@ const DAY_MS = 24 * HOUR_MS;
// the row also reflows correctly inside narrow side panels, not just on phones.
const ACTION_BTN = "h-9 gap-1.5 px-3 text-sm @xl:h-6 @xl:gap-1 @xl:px-2 @xl:text-xs";
/** Tomorrow at 9am local time. */
function tomorrowMorningIso(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
d.setHours(9, 0, 0, 0);
return d.toISOString();
}
/** Snooze presets, resolved to a future ISO timestamp at click time. */
const SNOOZE_PRESETS: ReadonlyArray<{ label: string; resolve: () => string }> = [
{ label: "1 hour", resolve: () => new Date(Date.now() + HOUR_MS).toISOString() },
{ label: "4 hours", resolve: () => new Date(Date.now() + 4 * HOUR_MS).toISOString() },
{ label: "Tomorrow morning", resolve: tomorrowMorningIso },
{ label: "Next week", resolve: () => new Date(Date.now() + 7 * DAY_MS).toISOString() },
];
interface AttentionQueueRowProps {
item: AttentionItem;
companyId: string;
expanded: boolean;
/** Receives the row's item so the parent can pass one stable callback for every row. */
onToggleExpand: (item: AttentionItem) => void;
/** "Archive for me" in the Close… menu: hides the card for you only (PAP-749 Rule 6). */
onDismiss: (item: AttentionItem) => void;
/** "Snooze for me" in the Close… menu. */
onSnooze?: (item: AttentionItem, snoozedUntil: string) => void;
/** Restore a snoozed/dismissed row (curtain variant only). */
/** "Withdraw request" closes it for everyone; only cards whose `closeModes` allow it. */
onWithdraw?: (item: AttentionItem, reason: string) => void;
/** "Transfer to @x" (interactions only). */
onTransfer?: (item: AttentionItem, userId: string) => void;
/** A close or transfer of this card is in flight. */
closing?: boolean;
/**
* Restore a card you archived or snoozed (the Archived view, PAP-749 plan
* §3). The row then says when it was archived or when it comes back, and
* its Close… menu offers Restore instead of Archive. The card stays
* answerable like any other row.
*/
onRestore?: (item: AttentionItem) => void;
/** "active" renders the live queue row; "hidden" renders a curtain row. */
variant?: "active" | "hidden";
agentMap?: Map<string, Agent>;
/** Company agents, for the triage strip's route-to-agent picker. */
agents?: Agent[];
@@ -96,7 +88,15 @@ interface AttentionQueueRowProps {
showTriage?: boolean;
currentUserId?: string | null;
userLabelMap?: ReadonlyMap<string, string> | null;
/** Claim an ownerless card (PAP-749). Pass one stable callback for every row. */
onClaim?: (item: AttentionItem) => void;
claiming?: boolean;
selected?: boolean;
/**
* The rest of this card's obligation (PAP-749 plan §2, Rule 11), folded in
* as one line each. Pass a stable array; the row is memoized.
*/
evidence?: readonly AttentionItem[];
}
/**
@@ -113,14 +113,19 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
onToggleExpand,
onDismiss,
onSnooze,
onWithdraw,
onTransfer,
closing = false,
onRestore,
variant = "active",
agentMap,
agents,
showTriage = false,
currentUserId,
userLabelMap,
onClaim,
claiming = false,
selected = false,
evidence,
}: AttentionQueueRowProps) {
const meta = sourceMeta(item.sourceKind);
// Colour + glyph are borrowed wholesale from the task status system, so a
@@ -128,10 +133,8 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
const status = attentionStatus(item);
// The task this row belongs to, whichever field the feed put it in.
const taskRef = attentionTaskRef(item);
const isHidden = variant === "hidden";
const inline = !isHidden && isInlineResolvable(item);
const inline = isInlineResolvable(item);
const href = item.subject.href;
const snoozedUntil = item.dismissal?.kind === "snooze" ? item.dismissal.snoozedUntil : null;
const detailLine = attentionDetailLine(item) ?? item.whyNow;
const images = attentionDetailImages(item);
const hasImages = images.length > 0;
@@ -143,8 +146,8 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
// expand to reveal the per-card triage strip (PAP-16032 §4.5). Any of these
// gives a header/thumbnail click somewhere to go. Non-inline, image-less rows
// with no triage keep the explicit Open button and never toggle on a stray click.
const triageEnabled = showTriage && !isHidden;
const expandable = inline || (!isHidden && hasImages) || triageEnabled;
const triageEnabled = showTriage;
const expandable = inline || hasImages || triageEnabled;
const activate = () => {
if (expandable) onToggleExpand(item);
};
@@ -157,15 +160,18 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
};
// Which rows contribute an action bar. Inline rows carry compact decision
// verbs; deep-link rows carry an Open button; curtain rows carry Restore.
const compactActions = !isHidden ? collectCompactActions(item) : [];
// verbs; deep-link rows carry an Open button.
// A card for a different person asks "This is for @x. Continue?" before any
// answer (PAP-749 plan §6), so it offers no one-click verbs while collapsed.
const forOther = attentionOtherAudienceUserId(item, currentUserId) !== null;
const compactActions = !forOther ? collectCompactActions(item) : [];
// Who the server will let resolve this interaction. A collapsed row offers
// Accept/Reject before anything fetches the interaction, so the audience
// travels with the feed item; null for every non-interaction source and for a
// feed built before the metadata existed (PAP-17287).
const audience = describeAttentionResolverAudience(item);
const audienceLineNamesResponders = attentionAudienceLineNamesResponders(item, currentUserId);
const showOpen = !inline && !!href;
const showRestore = isHidden && !!onRestore;
// An expanded inline row hands its footer to the resolver, which owns the
// decision verbs — so the toggle rides alongside them on one row rather than
// stranding a lone "See less" under the buttons. That makes the collapsed
@@ -197,7 +203,7 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
*/
const renderFooter = ({ compact }: { compact: boolean }) => {
const showCompact = compactActions.length > 0 && (compact || !expanded);
if (!toggle && !showCompact && !showOpen && !showRestore) return null;
if (!toggle && !showCompact && !showOpen) return null;
return (
<div className="flex flex-wrap items-center justify-between gap-2" data-attention-actions="true">
{toggle ?? <span />}
@@ -221,12 +227,6 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
</Button>
)}
{showRestore && (
<Button type="button" variant="outline" size="xs" className={ACTION_BTN} onClick={() => onRestore(item)}>
<RotateCcw className="h-3 w-3" />
Restore
</Button>
)}
</div>
</div>
);
@@ -241,7 +241,6 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
// row's first paint; `auto` keeps the real measured height afterwards.
"[content-visibility:auto] [contain-intrinsic-size:auto_104px]",
"motion-safe:transition-[opacity,transform,border-color,background-color] motion-safe:duration-200 motion-safe:ease-out hover:border-border/80",
isHidden && "bg-muted/30 opacity-80 hover:opacity-100",
selected && "border-ring ring-1 ring-ring",
)}
id={`attention-row-${item.id}`}
@@ -287,48 +286,33 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
</span>
</>
)}
{onRestore && item.dismissal?.isActive && (
<>
<EyebrowSeparator />
<AttentionHiddenChip item={item} />
</>
)}
{item.stale && (
<>
<EyebrowSeparator />
<AttentionStaleChip item={item} />
</>
)}
</div>
<div className="flex shrink-0 items-center gap-1" data-attention-menu="true">
{isHidden && snoozedUntil ? (
<span
className="text-(length:--text-nano) text-muted-foreground"
title={`Reappears ${new Date(snoozedUntil).toLocaleString()}`}
>
Reappears {reappearLabel(snoozedUntil)}
</span>
) : (
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
)}
{!isHidden && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label="Row actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onSnooze && <SnoozeSubmenu onSnooze={(iso) => onSnooze(item, iso)} />}
<DropdownMenuItem onClick={() => onDismiss(item)}>
<X className="h-4 w-4" />
Dismiss
</DropdownMenuItem>
{href && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={href}>Open source</Link>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
<span className="text-(length:--text-nano) text-muted-foreground">{relativeTime(item.activityAt)}</span>
<AttentionCloseMenu
item={item}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onWithdraw={onWithdraw}
onArchive={onDismiss}
onSnooze={onSnooze}
onTransfer={onTransfer}
onRestore={onRestore}
busy={closing}
/>
</div>
</div>
@@ -356,6 +340,21 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{detailLine}</p>
</div>
{/* Why this card is here (PAP-749 plan §1, Rule 4): "Why me?", "For
@name", or why an ownerless card is yours to answer, with Claim. */}
<AttentionAudienceLine
item={item}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onClaim={onClaim}
claiming={claiming}
className="-mt-2"
/>
{/* One card for each obligation (PAP-749 Rule 11): the task waiting on
this approval, or the work this failed run blocks. */}
<AttentionObligationEvidence item={item} evidence={evidence} />
{/* Collapsed-only content. It has no counterpart to morph into — the
thumbnail strip becomes a full gallery, and an inline row's footer is
replaced by the resolver's own — so it rides an inverse disclosure and
@@ -369,7 +368,10 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
{/* The audience reads *before* the verbs it qualifies: a compact
Accept sitting alone asks for a decision without saying whose
it is (PAP-17287). */}
{audience && <InteractionAudienceLine audience={audience} variant="compact" />}
{/* The audience line above already names who may answer when
it knows the viewer; a second clause would contradict it
(PAP-767). */}
{audience && !audienceLineNamesResponders && <InteractionAudienceLine audience={audience} variant="compact" />}
{inline && renderFooter({ compact: true })}
</div>
</CollapsibleContent>
@@ -388,22 +390,24 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({
{hasImages && <ExpandedImages images={images} issueHref={issueHref} />}
{triageEnabled && <DecisionTriageStrip item={item} companyId={companyId} agents={agents} />}
{inline && (
<InlineResolver
item={item}
companyId={companyId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
toggle={toggle}
/>
<AttentionForOtherGate item={item} currentUserId={currentUserId} userLabelMap={userLabelMap}>
<InlineResolver
item={item}
companyId={companyId}
agentMap={agentMap}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
toggle={toggle}
/>
</AttentionForOtherGate>
)}
</div>
</CollapsibleContent>
</Collapsible>
{/* A non-inline row keeps one footer across both states — its Open or
Restore button and its toggle are the same control either way, so it
stays put rather than crossfading with itself. */}
{/* A non-inline row keeps one footer across both states — its Open
button and its toggle are the same control either way, so it stays
put rather than crossfading with itself. */}
{!inline && renderFooter({ compact: false })}
</div>
);
@@ -509,12 +513,8 @@ function CompactDecisionActions({
throw new Error("This decision must be completed from its detail view.");
},
onSuccess: (_result, action) => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
if (item.sourceKind === "approval") {
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
} else {
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
}
// Task detail, chat and the approval page show the same record.
invalidateAttentionRecordViews(queryClient, companyId, item);
pushToast({
title: compactDecisionSuccessLabel(item.sourceKind, action),
tone: "success",
@@ -671,53 +671,6 @@ function ExpandedImages({ images, issueHref }: { images: AttentionDetailImage[];
);
}
/** Snooze submenu: presets + a custom date-time (plan §6). */
function SnoozeSubmenu({ onSnooze }: { onSnooze: (snoozedUntil: string) => void }) {
const [customValue, setCustomValue] = useState("");
const applyCustom = () => {
if (!customValue) return;
const ts = new Date(customValue);
if (Number.isNaN(ts.getTime())) return;
onSnooze(ts.toISOString());
};
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<AlarmClock className="h-4 w-4" />
Snooze
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{SNOOZE_PRESETS.map((preset) => (
<DropdownMenuItem key={preset.label} onClick={() => onSnooze(preset.resolve())}>
{preset.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{/* Custom picker: a non-menu-item region so interacting with the input
doesn't close the menu (guard keydown/select against Radix typeahead). */}
<div
className="flex flex-col gap-1.5 px-2 py-1.5"
onKeyDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<span className="text-(length:--text-nano) font-medium uppercase tracking-(--tracking-eyebrow) text-muted-foreground">
Custom
</span>
<input
type="datetime-local"
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
className="w-full rounded-sm border border-border bg-background px-2 py-1 text-xs"
/>
<Button type="button" size="xs" disabled={!customValue} onClick={applyCustom}>
Snooze until…
</Button>
</div>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
/**
* Who set this row's decide-by deadline, for the card's provenance line
* ("· set by Prioritizer"). Returns null when unattributed so the chip shows
@@ -730,18 +683,6 @@ function decideByProvenance(item: AttentionItem): string | null {
return "you";
}
/** Compact "when does this snooze end" label, e.g. `in 2h`, `in 3d`. */
function reappearLabel(snoozedUntil: string): string {
const diffMs = new Date(snoozedUntil).getTime() - Date.now();
if (!Number.isFinite(diffMs) || diffMs <= 0) return "soon";
const diffMin = Math.round(diffMs / 60000);
if (diffMin < 60) return `in ${diffMin}m`;
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `in ${diffHr}h`;
const diffDay = Math.round(diffHr / 24);
return `in ${diffDay}d`;
}
/**
* Expanded-row content. Resolvers that own their decision verbs also render the
* row's footer, so the disclosure toggle (`toggle`) sits on the same line as the
@@ -802,6 +743,14 @@ function InlineResolver({
return <JoinRequestResolver item={item} companyId={companyId} toggle={toggle} />;
}
if (item.sourceKind === "failed_run") {
return <FailedRunActions item={item} companyId={companyId} leading={toggle} />;
}
if (item.sourceKind === "budget_alert") {
return <BudgetAlertActions item={item} companyId={companyId} leading={toggle} />;
}
if (item.sourceKind === "review") {
// Inline only for stalled reviews (server sets inlineResolvable then); the
// subject IS the issue, so its id is the decision target.
@@ -828,48 +777,112 @@ function ResolverFooter({ toggle, children }: { toggle: ReactNode; children: Rea
);
}
function ApprovalResolver({ item, companyId, toggle }: { item: AttentionItem; companyId: string; toggle: ReactNode }) {
/** Approve, reject or send back an approval in place (PAP-775, UX1: also on coordinator Needs You). */
export function ApprovalResolver({ item, companyId, toggle, onResolved, shareDraft = false, presentation = "panel", artifactRow, onViewDetails, footerContext }: {
item: AttentionItem;
companyId: string;
toggle: ReactNode;
onResolved?: () => void;
/** Another view shows this same approval (PAP-775): share the decision note
* and the in-flight action with it, so typing mirrors and nothing sends twice. */
shareDraft?: boolean;
/**
* "card": the Needs You card — what is being approved in an inset panel, then
* View details · Reject · Approve, no note. "panel" (default): the full
* resolver with the decision note and Request revision, as in the side panel.
*/
presentation?: "card" | "panel";
/** Card only: a row for the artifact the card opens, shown as the panel instead of the payload summary. */
artifactRow?: ReactNode;
/** Supporting request context stays above the card's action footer. */
footerContext?: ReactNode;
/** Card only: opens the full request (the side panel). No button without it. */
onViewDetails?: () => void;
}) {
const queryClient = useQueryClient();
const [note, setNote] = useState("");
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(companyId) });
const [actionError, setActionError] = useState<string | null>(null);
const onError = (error: unknown) => setActionError(error instanceof Error ? error.message : "The decision could not be saved.");
// Keyed by the request's last (re)submission: a revised request starts clean.
const draftKey = shareDraft ? reviseDraftKey(`approval:${item.subject.id}`, item.updatedAt) : null;
const [sharedDraft, updateSharedDraft] = useReviseDraft(draftKey);
const [localNote, setLocalNote] = useState("");
const note = draftKey ? sharedDraft.reason : localNote;
const setNote = (next: string) => draftKey ? updateSharedDraft({ reason: next }) : setLocalNote(next);
const invalidate = () => { if (draftKey) clearReviseDraft(draftKey); invalidateAttentionRecordViews(queryClient, companyId, item); onResolved?.(); };
const send = (work: ReviseDraftWork, request: (note?: string) => Promise<unknown>) => async () => {
// The other view is already sending this approval: never send it twice.
if (draftKey && !claimReviseDraftWork(draftKey, work)) throw new Error("This approval is already being sent.");
try {
return await request(note.trim() || undefined);
} finally {
if (draftKey) releaseReviseDraftWork(draftKey);
}
};
const approve = useMutation({
mutationFn: () => approvalsApi.approve(item.subject.id, note.trim() || undefined),
mutationFn: send("accept", (decisionNote) => approvalsApi.approve(item.subject.id, decisionNote)),
onSuccess: invalidate,
onMutate: () => setActionError(null),
onError,
});
const reject = useMutation({
mutationFn: () => approvalsApi.reject(item.subject.id, note.trim() || undefined),
mutationFn: send("reject", (decisionNote) => approvalsApi.reject(item.subject.id, decisionNote)),
onSuccess: invalidate,
onMutate: () => setActionError(null),
onError,
});
const revise = useMutation({
mutationFn: () => approvalsApi.requestRevision(item.subject.id, note.trim() || undefined),
mutationFn: send("revise", (decisionNote) => approvalsApi.requestRevision(item.subject.id, decisionNote)),
onSuccess: invalidate,
onMutate: () => setActionError(null),
onError,
});
const pending = approve.isPending || reject.isPending || revise.isPending;
// Either view sending disables both.
const working = approve.isPending ? "accept" : reject.isPending ? "reject" : revise.isPending ? "revise" : sharedDraft.working;
const pending = working !== null;
if (presentation === "card") {
const hire = item.subject.metadata?.type === "hire_agent";
return <>
<ApprovalCardPanel item={item} companyId={companyId} artifactRow={artifactRow} onViewDetails={onViewDetails} />
{footerContext}
{actionError ? <p role="alert" className="text-sm text-destructive">{actionError}</p> : null}
<DecisionCardFooter leading={toggle}>
{onViewDetails ? <Button size="sm" variant="ghost" onClick={onViewDetails}>{hire ? "View configuration" : "View details"}</Button> : null}
<Button size="sm" variant="outline" onClick={() => reject.mutate()} disabled={pending}>
{working === "reject" && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Reject
</Button>
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{working === "accept" && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{hire ? "Approve hire" : "Approve"}
</Button>
</DecisionCardFooter>
</>;
}
// Verb order matches the collapsed row exactly (revise → reject → approve),
// so expanding never moves the button the operator was already aiming at.
return (
<>
{actionError ? <p role="alert" className="text-sm text-destructive">{actionError}</p> : null}
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Optional decision note…"
className="min-h-16 text-sm"
disabled={pending}
/>
<ResolverFooter toggle={toggle}>
<Button size="sm" variant="outline" onClick={() => revise.mutate()} disabled={pending}>
{revise.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{working === "revise" && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Request revision
</Button>
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{working === "reject" && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Reject
</Button>
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{working === "accept" && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Approve
</Button>
</ResolverFooter>
@@ -877,23 +890,59 @@ function ApprovalResolver({ item, companyId, toggle }: { item: AttentionItem; co
);
}
function JoinRequestResolver({ item, companyId, toggle }: { item: AttentionItem; companyId: string; toggle: ReactNode }) {
/** Approve or reject a join request in place (PAP-775, UX1). */
export function JoinRequestResolver({ item, companyId, toggle, onResolved, presentation = "panel" }: {
item: AttentionItem;
companyId: string;
toggle: ReactNode;
onResolved?: () => void;
/** "card": the Needs You card (who is asking, then Decline · Approve access); "panel" (default): Reject · Approve. */
presentation?: "card" | "panel";
}) {
const queryClient = useQueryClient();
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.access.joinRequests(companyId) });
};
const [actionError, setActionError] = useState<string | null>(null);
const onError = (error: unknown) => setActionError(error instanceof Error ? error.message : "The join request could not be saved.");
const invalidate = () => { invalidateAttentionRecordViews(queryClient, companyId, item); onResolved?.(); };
const approve = useMutation({
mutationFn: () => accessApi.approveJoinRequest(companyId, item.subject.id),
onSuccess: invalidate,
onMutate: () => setActionError(null),
onError,
});
const reject = useMutation({
mutationFn: () => accessApi.rejectJoinRequest(companyId, item.subject.id),
onSuccess: invalidate,
onMutate: () => setActionError(null),
onError,
});
const pending = approve.isPending || reject.isPending;
if (presentation === "card") {
const agent = item.subject.metadata?.requestType === "agent";
const adapter = typeof item.subject.metadata?.adapterType === "string" ? item.subject.metadata.adapterType : null;
return <>
<NeedsYouPanel>
<NeedsYouPanelRow icon={agent ? <Bot /> : <UserRound />} title={item.subject.title ?? "Join request"}
meta={agent ? ["Agent access", adapter ? getAdapterLabel(adapter) : null].filter(Boolean).join(" · ") : "Member access"} />
<NeedsYouPanelNote>{agent ? "Approval adds this agent to the company." : "Approval adds them to the company as a member."}</NeedsYouPanelNote>
</NeedsYouPanel>
{actionError ? <p role="alert" className="text-sm text-destructive">{actionError}</p> : null}
<DecisionCardFooter leading={toggle}>
<Button size="sm" variant="ghost" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Decline
</Button>
<Button size="sm" onClick={() => approve.mutate()} disabled={pending}>
{approve.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Approve access
</Button>
</DecisionCardFooter>
</>;
}
return (
<>
{actionError ? <p role="alert" className="text-sm text-destructive">{actionError}</p> : null}
<ResolverFooter toggle={toggle}>
<Button size="sm" variant="destructive" onClick={() => reject.mutate()} disabled={pending}>
{reject.isPending && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
@@ -904,5 +953,6 @@ function JoinRequestResolver({ item, companyId, toggle }: { item: AttentionItem;
Approve
</Button>
</ResolverFooter>
</>
);
}
+180
View File
@@ -0,0 +1,180 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionAudience, AttentionItem, AttentionReceipt } from "@paperclipai/shared";
import type { AnchorHTMLAttributes } from "react";
import { AttentionAudienceLine, AttentionReceiptList, AttentionUnassignedBanner, AttentionViewSwitch } from "./AttentionViews";
vi.mock("@/lib/router", () => ({
Link: ({ children, to, disableIssueQuicklook: _quicklook, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; disableIssueQuicklook?: boolean }) => (
<a href={to} {...props}>{children}</a>
),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const card = (audience: AttentionAudience) =>
({ id: "card", sourceKind: "issue_thread_interaction", audience, subject: { id: "interaction-1", title: "Approve the plan" } }) as unknown as AttentionItem;
const labels = new Map([["dotta", "Dotta"]]);
describe("AttentionViewSwitch", () => {
it("shows the For you badge and the Unassigned and Company counts on their own tabs", async () => {
const onChange = vi.fn();
await act(async () => root.render(<AttentionViewSwitch value="mine" counts={{ mine: 4, unassigned: 3, company: 2, archived: 5, all: 9 }} onChange={onChange} />));
const tabs = [...container.querySelectorAll('[role="tab"]')];
expect(tabs.map((tab) => tab.textContent)).toEqual(["For you4", "Unassigned3", "Company2", "Archived5"]);
expect(tabs[0]?.getAttribute("aria-selected")).toBe("true");
expect(container.querySelector('[data-attention-view-count="mine"]')?.getAttribute("data-slot")).toBe("badge");
const unassigned = container.querySelector<HTMLElement>('[data-attention-view="unassigned"]')!;
await act(async () => {
unassigned.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
unassigned.focus();
});
expect(onChange).toHaveBeenCalledWith("unassigned");
});
it("shows Everyone with its own count only to a board member, never as the badge (PAP-768)", async () => {
const onChange = vi.fn();
await act(async () => root.render(<AttentionViewSwitch value="all" counts={{ mine: 4, unassigned: 3, company: 2, archived: 5, all: 9 }} onChange={onChange} everyone />));
const tabs = [...container.querySelectorAll('[role="tab"]')];
expect(tabs.map((tab) => tab.textContent)).toEqual(["For you4", "Unassigned3", "Company2", "Archived5", "Everyone9"]);
expect(tabs[4]?.getAttribute("aria-selected")).toBe("true");
expect(container.querySelector('[data-attention-view-count="all"]')?.getAttribute("data-slot")).not.toBe("badge");
const mine = container.querySelector<HTMLElement>('[data-attention-view="mine"]')!;
await act(async () => {
mine.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
mine.focus();
});
expect(onChange).toHaveBeenCalledWith("mine");
});
it("leaves Everyone out for a person who is not a board member (PAP-768)", async () => {
await act(async () => root.render(<AttentionViewSwitch value="mine" counts={{ mine: 4, unassigned: 3, company: 2, archived: 5, all: 9 }} onChange={() => {}} />));
expect(container.querySelector('[data-attention-view="all"]')).toBeNull();
});
it("hides an empty For you badge", async () => {
await act(async () => root.render(<AttentionViewSwitch value="mine" counts={{ mine: 0, unassigned: 1, company: 0, archived: 0, all: 1 }} onChange={() => {}} />));
expect(container.querySelector('[data-attention-view-count="mine"]')).toBeNull();
});
});
describe("AttentionUnassignedBanner", () => {
it("says how many cards need an owner and opens Unassigned", async () => {
const onOpen = vi.fn();
await act(async () => root.render(<AttentionUnassignedBanner count={3} onOpen={onOpen} />));
expect(container.textContent).toContain("3 cards need an owner");
await act(async () => container.querySelector("button")!.click());
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("renders nothing when the pool is empty", async () => {
await act(async () => root.render(<AttentionUnassignedBanner count={0} onOpen={() => {}} />));
expect(container.querySelector("[data-attention-unassigned-banner]")).toBeNull();
});
});
describe("AttentionAudienceLine", () => {
it("points an ownerless card Claim cannot take to its task, or says it cannot be claimed (PAP-767)", async () => {
const pool: AttentionAudience = { kind: "pool", userId: null, reason: "none" };
// NEE-16: a blocked task with no human; its owner is whoever the task is assigned to.
const blocker = { ...card(pool), sourceKind: "blocker_attention", subject: { id: "issue-16", kind: "issue", title: "Run recovery", href: "/NEE/issues/NEE-16" } } as unknown as AttentionItem;
await act(async () => root.render(<AttentionAudienceLine item={blocker} currentUserId="scott" onClaim={() => {}} />));
const assign = container.querySelector<HTMLAnchorElement>("[data-attention-open-to-assign]");
expect(assign?.textContent).toBe("Open to assign");
expect(assign?.getAttribute("href")).toBe("/NEE/issues/NEE-16");
expect(container.textContent).not.toContain("Claim");
// The budget override approval has no task to assign.
const approval = { ...card(pool), sourceKind: "approval", subject: { id: "approval-1", kind: "approval", title: "budget override required", href: "/NEE/approvals/approval-1" }, relatedIssue: null } as unknown as AttentionItem;
await act(async () => root.render(<AttentionAudienceLine item={approval} currentUserId="scott" onClaim={() => {}} />));
expect(container.querySelector("[data-attention-no-claim]")?.textContent).toBe("It has no task to assign, so it can't be claimed.");
expect(container.querySelector("[data-attention-open-to-assign]")).toBeNull();
});
it("answers Why me? on your own card", async () => {
await act(async () => root.render(<AttentionAudienceLine item={card({ kind: "user", userId: "scott", reason: "reviewer" })} currentUserId="scott" />));
expect(container.querySelector('[data-attention-audience="mine"]')?.textContent).toBe("Why me?You are the reviewer");
});
it("shows For @name on a different person's card, with no Claim", async () => {
await act(async () => root.render(<AttentionAudienceLine item={card({ kind: "user", userId: "dotta", reason: "addressee" })} currentUserId="scott" userLabelMap={labels} onClaim={() => {}} />));
expect(container.querySelector("[data-attention-for-user]")?.textContent).toBe("For @Dotta");
expect(container.querySelector("button")).toBeNull();
});
it("offers Claim on an ownerless card and reports the claimed item", async () => {
const onClaim = vi.fn();
const pool = card({ kind: "pool", userId: null, reason: "none" });
await act(async () => root.render(<AttentionAudienceLine item={pool} currentUserId="scott" onClaim={onClaim} />));
expect(container.textContent).toContain("No owner yet");
await act(async () => container.querySelector("button")!.click());
expect(onClaim).toHaveBeenCalledWith(pool);
});
it("says who transferred a card to you", async () => {
const labelsWithScott = new Map([["scott", "Scott"]]);
await act(async () => root.render(<AttentionAudienceLine item={card({ kind: "user", userId: "riley", reason: "addressee", transferredByUserId: "scott" })} currentUserId="riley" userLabelMap={labelsWithScott} />));
expect(container.querySelector('[data-attention-audience="mine"]')?.textContent).toBe("Why me?Transferred by @Scott");
});
it("stays silent until the viewer is known", async () => {
await act(async () => root.render(<AttentionAudienceLine item={card({ kind: "user", userId: "scott", reason: "addressee" })} currentUserId={null} />));
expect(container.innerHTML).toBe("");
});
});
describe("AttentionReceiptList (PAP-749 plan §6)", () => {
const receipt = (overrides: Partial<AttentionReceipt> = {}): AttentionReceipt => ({
id: "activity-1",
dismissalKey: "attention:receipt:activity-1",
outcome: "withdrawn",
sourceKind: "issue_thread_interaction",
sourceId: "interaction-1",
title: "Approve the launch plan?",
reason: "Plan changed",
actorUserId: "scott",
transferredToUserId: null,
issue: { id: "issue-1", identifier: "PAP-1", title: "Launch", href: "/PAP/issues/PAP-1" },
href: "/PAP/issues/PAP-1#interaction-interaction-1",
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
...overrides,
});
const people = new Map([["scott", "Scott"], ["riley", "Riley"]]);
it("says who did what, why, and opens or dismisses the receipt", async () => {
const onDismiss = vi.fn();
const onOpen = vi.fn();
const withdrawn = receipt();
await act(async () => root.render(<AttentionReceiptList receipts={[withdrawn, receipt({ id: "activity-2", outcome: "transferred", reason: null, transferredToUserId: "riley" })]} userLabelMap={people} onDismiss={onDismiss} onOpen={onOpen} />));
const rows = [...container.querySelectorAll("[data-attention-receipt]")];
expect(rows).toHaveLength(2);
expect(rows[0]?.textContent).toContain("Withdrawn by @Scott");
expect(rows[0]?.textContent).toContain("Reason: Plan changed");
expect(rows[0]?.textContent).toContain("PAP-1");
expect(rows[1]?.textContent).toContain("Transferred to @Riley by @Scott");
await act(async () => rows[0]!.querySelector<HTMLButtonElement>("button")!.click());
expect(onOpen).toHaveBeenCalledWith("/PAP/issues/PAP-1#interaction-interaction-1");
await act(async () => rows[0]!.querySelector<HTMLButtonElement>('[aria-label="Dismiss receipt"]')!.click());
expect(onDismiss).toHaveBeenCalledWith(withdrawn);
});
it("renders nothing without receipts", async () => {
await act(async () => root.render(<AttentionReceiptList receipts={[]} onDismiss={() => {}} />));
expect(container.querySelector("[data-attention-receipts]")).toBeNull();
});
});
+233
View File
@@ -0,0 +1,233 @@
import { ArrowRight, ArrowUpRight, Hand, Mail, UserRound, X } from "lucide-react";
import {
type AttentionItem,
type AttentionPersonalView,
type AttentionReceipt,
type AttentionViewCounts,
} from "@paperclipai/shared";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ATTENTION_VIEW_LABELS,
ATTENTION_NO_CLAIM_TEXT,
attentionClaimAlternative,
attentionReceiptLine,
attentionResponderText,
attentionUnownedAudienceText,
attentionViewsFor,
describeAttentionAudience,
unassignedBannerText,
} from "@/lib/attention-views";
import { Link } from "@/lib/router";
import { timeAgo } from "@/lib/timeAgo";
import { cn } from "@/lib/utils";
/**
* For you / Unassigned / Company / Archived (PAP-749 plan §1, Rule 3), plus
* Everyone for a board member (PAP-768). The "For you" count is the shared
* badge; the other counts stay on their own tabs.
*/
export function AttentionViewSwitch({
value,
counts,
onChange,
everyone = false,
className,
}: {
value: AttentionPersonalView;
counts: AttentionViewCounts;
onChange: (view: AttentionPersonalView) => void;
/** Show Everyone: the viewer is a board member. */
everyone?: boolean;
className?: string;
}) {
return (
<Tabs value={value} onValueChange={(next) => onChange(next as AttentionPersonalView)} className={className}>
<TabsList variant="line" aria-label="Decision views" className="p-0">
{attentionViewsFor(everyone).map((view) => (
<TabsTrigger key={view} value={view} data-attention-view={view}>
{ATTENTION_VIEW_LABELS[view]}
{view === "mine" ? (
counts.mine > 0 ? <Badge data-attention-view-count={view}>{counts.mine > 99 ? "99+" : counts.mine}</Badge> : null
) : (
<span data-attention-view-count={view} className="text-xs tabular-nums text-muted-foreground">{counts[view]}</span>
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
);
}
/** Shown in "For you" while ownerless cards wait (recommendation e). Not in the badge. */
export function AttentionUnassignedBanner({ count, onOpen }: { count: number; onOpen: () => void }) {
if (count <= 0) return null;
return (
<div
role="status"
data-attention-unassigned-banner
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm"
>
<span className="inline-flex items-center gap-2 text-foreground">
<Hand className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
{unassignedBannerText(count)}
</span>
<Button type="button" variant="ghost" size="xs" onClick={onOpen}>
Open Unassigned
<ArrowRight className="h-3 w-3" />
</Button>
</div>
);
}
/**
* The card's audience (PAP-749 plan §1, Rule 4): "Why me?" on your own cards,
* "For @name" on a different person's card, and why you can answer plus Claim
* on an ownerless card. Presentation only; the server re-checks every action.
*/
export function AttentionAudienceLine({
item,
currentUserId,
userLabelMap,
onClaim,
claiming = false,
className,
}: {
item: AttentionItem;
currentUserId: string | null | undefined;
userLabelMap?: ReadonlyMap<string, string> | null;
onClaim?: (item: AttentionItem) => void;
claiming?: boolean;
className?: string;
}) {
// Without the viewer there is no honest answer to "Why me?".
const description = currentUserId ? describeAttentionAudience(item, currentUserId) : null;
if (!description) return null;
const base = cn("flex flex-wrap items-center gap-2 text-xs leading-5 text-muted-foreground", className);
// Who may answer, in the same statement as why the card is here (PAP-767).
const responders = attentionResponderText(item, description, userLabelMap);
if (description.kind === "mine") {
return (
<p className={base} data-attention-audience="mine">
<span className="font-medium text-foreground">Why me?</span>
<span data-attention-transferred-by={description.transferredByUserId}>
{description.transferredByUserId
? `${description.text} @${userLabelMap?.get(description.transferredByUserId) ?? "someone"}`
: description.text}
{responders ? <span data-attention-responders>. {responders}</span> : null}
</span>
</p>
);
}
if (description.kind === "other") {
const name = userLabelMap?.get(description.userId) ?? "another person";
return (
<div className={base} data-attention-audience="other">
<Badge variant="outline" data-attention-for-user={description.userId}>
<UserRound aria-hidden="true" />
For @{name}
</Badge>
{responders ? <span data-attention-responders>{responders}</span> : null}
</div>
);
}
// Claim stores an owner only on a request; other ownerless cards point to
// their task, where a person can be assigned (PAP-767).
const alternative = onClaim ? attentionClaimAlternative(item) : null;
return (
<div className={base} data-attention-audience={description.kind}>
<span>{attentionUnownedAudienceText(item, description, userLabelMap)}</span>
{alternative?.kind === "none" ? <span data-attention-no-claim>{ATTENTION_NO_CLAIM_TEXT}</span> : null}
{alternative?.kind === "assign" ? (
<Button asChild variant="outline" size="xs">
<Link to={alternative.href} disableIssueQuicklook data-attention-open-to-assign onClick={(event) => event.stopPropagation()}>
<UserRound className="h-3 w-3" />
Open to assign
</Link>
</Button>
) : null}
{description.claimable && onClaim ? (
<Button
type="button"
variant="outline"
size="xs"
disabled={claiming}
onClick={(event) => {
event.stopPropagation();
onClaim(item);
}}
>
<Hand className="h-3 w-3" />
{claiming ? "Claiming…" : "Claim"}
</Button>
) : null}
</div>
);
}
/**
* Receipts at the top of For you (PAP-749 plan §6, Rule 12): what a different
* person did with a card that was for you. A receipt is not a card: it is not
* in the badge, has no Close… menu, and leaves after 7 days or when you
* dismiss it.
*/
export function AttentionReceiptList({
receipts,
userLabelMap,
onDismiss,
onOpen,
dismissingId,
className,
}: {
receipts: readonly AttentionReceipt[];
userLabelMap?: ReadonlyMap<string, string> | null;
onDismiss: (receipt: AttentionReceipt) => void;
onOpen?: (href: string) => void;
dismissingId?: string | null;
className?: string;
}) {
if (receipts.length === 0) return null;
return (
<section aria-label="Receipts" data-attention-receipts className={cn("space-y-2", className)}>
{receipts.map((receipt) => {
const href = receipt.href ?? receipt.issue?.href ?? null;
return (
<div
key={receipt.id}
role="status"
data-attention-receipt={receipt.outcome}
className="flex items-start gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2 text-sm"
>
<Mail className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1 space-y-0.5">
<p className="font-medium text-foreground">{attentionReceiptLine(receipt, userLabelMap)}</p>
{receipt.title ? <p className="truncate text-muted-foreground">{receipt.title}</p> : null}
{receipt.reason ? <p className="text-muted-foreground">Reason: {receipt.reason}</p> : null}
<p className="text-xs text-muted-foreground">
{receipt.issue?.identifier ? `${receipt.issue.identifier} · ` : null}
<time dateTime={receipt.createdAt}>{timeAgo(receipt.createdAt)}</time>
</p>
</div>
{href && onOpen ? (
<Button type="button" variant="ghost" size="xs" onClick={() => onOpen(href)}>
Open
<ArrowUpRight className="h-3 w-3" />
</Button>
) : null}
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Dismiss receipt"
disabled={dismissingId === receipt.id}
onClick={() => onDismiss(receipt)}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</section>
);
}
+5 -7
View File
@@ -128,13 +128,12 @@ afterEach(() => {
});
describe("DecisionCard", () => {
it("renders a pending decision with provenance, effect summary and dismiss", () => {
it("renders a pending decision with provenance and effect summary", () => {
const el = render({});
expect(el.textContent).toContain("Pending");
expect(el.textContent).toContain("Gardener");
expect(el.textContent).toContain("PAP-123");
expect(el.textContent).toContain("Comment on PAP-456");
expect([...el.querySelectorAll("button")].some((b) => b.textContent?.includes("Dismiss"))).toBe(true);
});
it("links the target issue the decision applies to, not just the origin", () => {
@@ -192,11 +191,10 @@ describe("DecisionCard", () => {
expect(onDecide).toHaveBeenCalledWith("comment", expect.any(Object));
});
it("fires onDismiss from the always-present zero-effect dismiss", () => {
const onDismiss = vi.fn();
const el = render({ onDismiss });
clickButtonWithText(el, "Dismiss");
expect(onDismiss).toHaveBeenCalled();
it("offers no \"Dismiss — no effects\" verb; closing lives in the card's Close… menu (PAP-749 Rule 8)", () => {
const el = render({});
expect(el.textContent).not.toContain("Dismiss — no effects");
expect(el.textContent).not.toContain("Not now?");
});
it("warns and disables strict options when a target is stale", () => {
+5 -13
View File
@@ -27,8 +27,11 @@ import { MarkdownBody } from "./MarkdownBody";
* Presentational card for a single Decisions-v1 decision (PAP-14966 / PAP-14939
* §4), modeled on {@link IssueThreadInteractionCard}. It renders every state —
* pending / stale-target / destructive cancel-tree / decided / partial / failed
* / expired / cancelled / dismissed — and delegates the actual decide / dismiss
* mutations to the parent (the feed's `DecisionResolver`, or the history view).
* / expired / cancelled / dismissed — and delegates the decide mutation to the
* parent (the feed's `DecisionResolver`, or the history view). There is no
* "Dismiss — no effects" verb: it recorded a final `decided` value and could
* wake the agent, so the card's Close… menu offers Withdraw request or Archive
* for me instead (PAP-749 plan §2, Rule 8). Legacy dismissed records still render.
* It never fetches: issue labels + the cancel-tree preview arrive via resolvers
* so the same component drives the live feed and the screenshot harness.
*/
@@ -57,7 +60,6 @@ export interface DecisionCardProps {
busy?: boolean;
errorMessage?: string | null;
onDecide?: (optionId: string, inputValues: Record<string, string>) => void;
onDismiss?: (reason?: string) => void;
className?: string;
}
@@ -236,7 +238,6 @@ export function DecisionCard({
busy = false,
errorMessage,
onDecide,
onDismiss,
className,
}: DecisionCardProps) {
const [inputValues, setInputValues] = useState<Record<string, string>>({});
@@ -558,15 +559,6 @@ export function DecisionCard({
);
})}
{/* Always-present zero-effect Dismiss (telemetered "no", distinct from expiry) */}
{!decision.options.some((option) => option.effects.length === 0) && (
<div className="flex items-center justify-between gap-2 pt-1">
<span className="text-xs text-muted-foreground">Not now?</span>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => onDismiss?.()}>
Dismiss — no effects
</Button>
</div>
)}
{errorMessage && <p className="text-xs text-rose-600 dark:text-rose-400">{errorMessage}</p>}
</div>
)}
+2 -3
View File
@@ -7,11 +7,10 @@ export function DecisionCardFooter({ leading, children, className }: {
children?: ReactNode;
className?: string;
}) {
if (!leading) return <>{children}</>;
return (
<div data-decision-footer className={cn("flex flex-wrap items-center justify-between gap-x-4 gap-y-3", className)}>
<div className="min-w-0 flex-1 basis-48">{leading}</div>
{children ? <div className="ml-auto max-w-full">{children}</div> : null}
{leading ? <div className="min-w-0 flex-1 basis-48">{leading}</div> : null}
{children ? <div data-decision-actions className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-1.5">{children}</div> : null}
</div>
);
}
@@ -0,0 +1,90 @@
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { useState, type ReactNode } from "react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import type { Agent, DecisionHistoryScope } from "@paperclipai/shared";
import { attentionApi } from "@/api/attention";
import { projectsApi } from "@/api/projects";
import { useNavigate } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { DECISION_HISTORY_OUTCOME_OPTIONS, DECISION_HISTORY_SCOPE_OPTIONS, DEFAULT_DECISION_HISTORY_SCOPE } from "@/lib/coordinator-attention";
import { CoordinatorDecisionReceipt as Receipt } from "@/components/coordinator/CoordinatorDecisionReceipt";
import { ToolbarSearch } from "@/components/ToolbarSearch";
import { Button } from "@/components/ui/button";
import { NativeSelect } from "@/components/ui/select";
type Group = "none" | "task" | "agent" | "project";
/** "Handled for me" is a filter on who the card was for, not an outcome (PAP-749 plan §6). */
const HANDLED_FOR_ME = "handled_for_me";
/**
* The Decision history panel of both Decisions pages (PAP-749 plan §3): every
* closed card — answered, approved, withdrawn, expired or closed with its
* task, approvals included. It opens on "Handled by me or for me"; Everyone
* lists every person's (PAP-768). Search, filter by outcome, agent and
* project within either, and open the task or approval.
*/
export function DecisionHistoryPanel({
companyId,
agents,
userLabelMap,
header,
}: {
companyId: string;
agents: Agent[];
userLabelMap?: ReadonlyMap<string, string>;
header?: ReactNode;
}) {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [outcome, setOutcome] = useState("");
const [agentId, setAgentId] = useState("");
const [projectId, setProjectId] = useState("");
const [groupBy, setGroupBy] = useState<Group>("none");
const [sort, setSort] = useState<"newest" | "oldest">("newest");
const [scope, setScope] = useState<DecisionHistoryScope>(DEFAULT_DECISION_HISTORY_SCOPE);
const projects = useQuery({ queryKey: queryKeys.projects.list(companyId), queryFn: () => projectsApi.list(companyId) });
const history = useInfiniteQuery({
queryKey: [...queryKeys.attention(companyId), "history", { search, outcome, agentId, projectId, sort, scope }],
queryFn: ({ pageParam }) => attentionApi.history(companyId, {
search, agentId, projectId, sort, scope, cursor: pageParam, limit: 40,
...(outcome === HANDLED_FOR_ME ? { handledForMe: true } : { outcome }),
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.nextCursor ?? undefined,
});
const historyView = useQueryView(history);
const waiting = historyView.kind === "loading" || historyView.kind === "reconnecting";
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
const groups = new Map<string, typeof items>();
for (const item of items) {
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle ?? "No task" : groupBy === "agent" ? item.agentName ?? "Other agents" : groupBy === "project" ? item.projectName ?? "No project" : "";
groups.set(label, [...(groups.get(label) ?? []), item]);
}
return <div className="space-y-5" data-decision-history>
{header}
<ToolbarSearch query={search} onQueryChange={setSearch} label="Search decision history" placeholder="Search decisions or task IDs…" />
<div className="grid grid-cols-2 gap-2">
<NativeSelect aria-label="Whose decision history" value={scope} onChange={(event) => setScope(event.target.value as DecisionHistoryScope)} className="col-span-2">
{DECISION_HISTORY_SCOPE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</NativeSelect>
<NativeSelect aria-label="Filter decision outcomes" value={outcome} onChange={(event) => setOutcome(event.target.value)}>
<option value="">All outcomes</option>
{DECISION_HISTORY_OUTCOME_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
<option value={HANDLED_FOR_ME}>Handled for me</option>
</NativeSelect>
<NativeSelect aria-label="Sort decision history" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="newest">Newest first</option><option value="oldest">Oldest first</option></NativeSelect>
<NativeSelect aria-label="Filter decision history by agent" value={agentId} onChange={(event) => setAgentId(event.target.value)} className="col-span-2"><option value="">All agents</option>{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</NativeSelect>
<NativeSelect aria-label="Filter decision history by project" value={projectId} onChange={(event) => setProjectId(event.target.value)} className="col-span-2"><option value="">All projects</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</NativeSelect>
<NativeSelect aria-label="Group decision history" value={groupBy} onChange={(event) => setGroupBy(event.target.value as Group)} className="col-span-2"><option value="none">No grouping</option><option value="task">By task</option><option value="agent">By agent</option><option value="project">By project</option></NativeSelect>
</div>
{waiting ? <p role="status" className="text-sm text-muted-foreground">Loading decisions…</p> : null}
{historyView.kind === "error" ? <QueryErrorState error={historyView.error} onRetry={historyView.retry} /> : null}
{!waiting && historyView.kind !== "error" && !items.length ? <div className="space-y-2 text-sm text-muted-foreground">
<p>{scope === "mine" && !search && !outcome && !agentId && !projectId ? "No decisions handled by you or for you yet." : "No decisions match these filters."}</p>
{scope === "mine" ? <Button variant="outline" size="sm" onClick={() => setScope("all")}>Show everyone's decisions</Button> : null}
</div> : null}
<div className="space-y-4">{[...groups].map(([label, entries]) => <section key={label} className="space-y-3">{label ? <h3 className="text-sm font-medium text-muted-foreground">{label}</h3> : null}{entries.map((item) => <div key={item.id} className="space-y-1"><Receipt item={item} onOpen={navigate} userLabelMap={userLabelMap} /><p className="px-1 text-xs text-muted-foreground"><time dateTime={item.resolvedAt}>{new Date(item.resolvedAt).toLocaleString()}</time></p></div>)}</section>)}</div>
{history.hasNextPage ? <Button variant="outline" disabled={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>{history.isFetchingNextPage ? "Loading…" : "Load more decisions"}</Button> : null}
</div>;
}
+2 -1
View File
@@ -7,7 +7,8 @@ import type { DecisionOutcome } from "../api/decisions";
const state = vi.hoisted(() => ({ issueStatus: "todo", queriedIssueIds: [] as string[] }));
vi.mock("@tanstack/react-query", () => ({
vi.mock("@tanstack/react-query", async (importOriginal) => ({
...await importOriginal<typeof import("@tanstack/react-query")>(),
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
useMutation: () => ({ isPending: false, error: null, mutate: vi.fn() }),
useQuery: ({ queryKey }: { queryKey: readonly string[] }) => queryKey[1] === "detail"
+121 -23
View File
@@ -1,12 +1,18 @@
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { decisionEffectTargetIssueIds, type Agent, type AttentionSubject } from "@paperclipai/shared";
import { decisionEffectTargetIssueIds, type Agent, type AttentionSubject, type DecisionEffect } from "@paperclipai/shared";
import { decisionsApi, type DecisionOutcome } from "../api/decisions";
import { issuesApi } from "../api/issues";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { useCompany } from "../context/CompanyContext";
import { DecisionCard, type DecisionIssueRef } from "./DecisionCard";
import { DecisionCardFooter } from "./DecisionCardFooter";
import { MarkdownBody } from "./MarkdownBody";
import { NeedsYouPanel, NeedsYouPanelNote } from "./coordinator/NeedsYouCardKit";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
interface DecisionResolverProps {
companyId: string;
@@ -18,8 +24,11 @@ interface DecisionResolverProps {
initialDecision?: DecisionOutcome;
/** Fresh, authorized terminal record owned by an enclosing detail query. */
recordedDecision?: DecisionOutcome;
/** Called after a decide/dismiss so the parent can refresh the feed row. */
/** Called after a decide so the parent can refresh the feed row. */
onResolved?: () => void;
/** A Needs You card's task link (PAP-775). Passing it renders the compact
* card: the options in an inset panel, the task link and Submit below. */
leading?: ReactNode;
}
type IssueDetail = Awaited<ReturnType<typeof issuesApi.get>>;
@@ -38,11 +47,102 @@ export function signedCancelTreePreviewIds(targetIssueId: string, snapshot: { de
/**
* Container for a Decisions-v1 decision surfaced in the attention feed. Fetches
* the full decision (`get`) plus the shared open-list (for `targetChanged`),
* resolves referenced issue ids + the cancel-tree preview, owns the decide /
* dismiss mutations, and invalidates the attention feed + target-issue keys on
* resolves referenced issue ids + the cancel-tree preview, owns the decide
* mutation, and invalidates the attention feed + target-issue keys on
* success — same conventions as {@link AttentionInteractionResolver}.
*/
export function DecisionResolver({ companyId, decisionId, originIssue, agentMap, initialDecision, recordedDecision, onResolved }: DecisionResolverProps) {
function issueLabel(ref: DecisionIssueRef | null, id: string) {
return ref?.identifier ?? ref?.title ?? `issue ${id.slice(0, 8)}`;
}
/** What choosing an option does, in one line (mirrors the full card's preview). */
function effectLine(effect: DecisionEffect, resolve: (id: string) => DecisionIssueRef | null) {
const target = issueLabel(resolve(effect.targetIssueId), effect.targetIssueId);
switch (effect.type) {
case "comment_on_issue": return `Comment on ${target}`;
case "create_issue": return `Create “${effect.draft.title}”`;
case "update_issue_status": return `Set ${target} to ${effect.status.replaceAll("_", " ")}`;
case "assign_issue": return `Reassign ${target}`;
case "resolve_blocker": return `Unblock ${target}`;
case "cancel_issue_tree": return `Cancel ${target} and its sub-tree`;
default: return "Apply effect";
}
}
/**
* Paper "Make a choice" (PAP-775): the ask, its options as a radio list in the
* inset panel, and one Submit on the right of the footer. A decision that can
* cancel an issue tree keeps the full card and its typed confirmation.
*/
function CompactDecision({ decision, resolveIssue, targetChanged, busy, errorMessage, leading, onDecide }: {
decision: DecisionOutcome;
resolveIssue: (id: string) => DecisionIssueRef | null;
targetChanged: Record<string, boolean> | null;
busy: boolean;
errorMessage: string | null;
leading: ReactNode;
onDecide: (optionId: string, inputValues: Record<string, string>) => void;
}) {
const [optionId, setOptionId] = useState<string | null>(null);
const [inputValues, setInputValues] = useState<Record<string, string>>({});
const stale = new Set(Object.entries(targetChanged ?? {}).filter(([, changed]) => changed).map(([id]) => id));
const requiredUnmet = (decision.inputs ?? []).some((field) => field.required && !(inputValues[field.id] ?? "").trim());
const blocked = (id: string | null) => decision.options.find((option) => option.id === id)?.effects
.some((effect) => effect.staleness === "strict" && decisionEffectTargetIssueIds(effect).some((target) => stale.has(target))) ?? false;
const body = decision.body?.trim();
return (
<div className="space-y-4" data-decision-state="pending">
<div className="space-y-3">
<h2 data-decision-prompt className="text-base font-medium">{decision.title}</h2>
{body ? <div className="text-sm text-muted-foreground"><MarkdownBody className="paperclip-markdown-on-accent">{body}</MarkdownBody></div> : null}
</div>
<NeedsYouPanel>
{(decision.inputs ?? []).map((field) => (
<label key={field.id} className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">{field.label}{field.required ? " · Required" : ""}</span>
<Textarea
value={inputValues[field.id] ?? ""}
onChange={(event) => setInputValues((current) => ({ ...current, [field.id]: event.target.value }))}
placeholder={field.placeholder ?? undefined}
maxLength={field.maxLength ?? undefined}
className="min-h-16 bg-card text-sm"
/>
</label>
))}
<div role="radiogroup" aria-label="Options" className="flex flex-col">
{decision.options.map((option) => {
const selected = optionId === option.id;
const disabled = busy || blocked(option.id);
const effects = option.effects.map((effect) => effectLine(effect, resolveIssue));
return (
<button key={option.id} type="button" role="radio" aria-checked={selected} disabled={disabled} onClick={() => setOptionId(option.id)}
className={cn("flex w-full items-start gap-2.5 rounded-sm px-1 py-1 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60",
selected ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
<span aria-hidden className={cn("mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border", selected ? "border-foreground" : "border-muted-foreground/50")}>
{selected ? <span className="size-2 rounded-full bg-foreground" /> : null}
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="text-(length:--text-compact) leading-5">{option.label}{blocked(option.id) ? " · Target changed" : ""}</span>
{option.description ? <span className="text-xs leading-4.5 text-muted-foreground">{option.description}</span> : null}
{effects.length > 0 ? <span className="text-xs leading-4.5 text-muted-foreground">{effects.join(" · ")}</span> : null}
</span>
</button>
);
})}
</div>
{stale.size > 0 ? <NeedsYouPanelNote>A target changed since this was proposed, so options that need it unchanged are off.</NeedsYouPanelNote> : null}
</NeedsYouPanel>
{errorMessage ? <p className="text-xs text-destructive">{errorMessage}</p> : null}
<DecisionCardFooter leading={leading}>
<Button size="sm" disabled={!optionId || busy || requiredUnmet || blocked(optionId)} onClick={() => { if (optionId) onDecide(optionId, inputValues); }}>
{busy ? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Submitting…</> : "Submit choice"}
</Button>
</DecisionCardFooter>
</div>
);
}
export function DecisionResolver({ companyId, decisionId, originIssue, agentMap, initialDecision, recordedDecision, onResolved, leading }: DecisionResolverProps) {
const queryClient = useQueryClient();
const { selectedCompany } = useCompany();
const prefix = selectedCompany?.issuePrefix ?? "";
@@ -157,37 +257,35 @@ export function DecisionResolver({ companyId, decisionId, originIssue, agentMap,
},
});
const dismissMutation = useMutation({
mutationFn: (reason: string | undefined) => decisionsApi.dismiss(decisionId, reason),
onSuccess: (data) => {
queryClient.setQueryData(queryKeys.decisions.detail(decisionId), data);
invalidate();
},
});
const compact = leading !== undefined;
if (!recordedDecision && detail.isLoading) {
return (
const loading = (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading decision…
</div>
);
return compact ? <div className="space-y-3">{loading}<DecisionCardFooter leading={leading} /></div> : loading;
}
if ((!recordedDecision && detail.error) || !decision) {
return (
const gone = (
<p className="py-3 text-xs text-muted-foreground">
This decision is no longer available — it may have been resolved elsewhere.
</p>
);
return compact ? <div className="space-y-3">{gone}<DecisionCardFooter leading={leading} /></div> : gone;
}
const busy = decideMutation.isPending || dismissMutation.isPending;
const errorMessage =
(decideMutation.error instanceof Error && decideMutation.error.message) ||
(dismissMutation.error instanceof Error && dismissMutation.error.message) ||
null;
const busy = decideMutation.isPending;
const errorMessage = (decideMutation.error instanceof Error && decideMutation.error.message) || null;
const decide = (optionId: string, inputValues: Record<string, string>) => decideMutation.mutate({ optionId, inputValues, idempotencyKey: crypto.randomUUID() });
return (
if (compact && decision.status === "open" && !decision.options.some((option) => option.effects.some((effect) => effect.type === "cancel_issue_tree"))) {
return <CompactDecision decision={decision} resolveIssue={resolveIssue} targetChanged={targetChanged} busy={busy} errorMessage={errorMessage} leading={leading} onDecide={decide} />;
}
const card = (
<DecisionCard
decision={decision}
executions={decision.executions}
@@ -208,8 +306,8 @@ export function DecisionResolver({ companyId, decisionId, originIssue, agentMap,
}
busy={busy}
errorMessage={errorMessage}
onDecide={(optionId, inputValues) => decideMutation.mutate({ optionId, inputValues, idempotencyKey: crypto.randomUUID() })}
onDismiss={(reason) => dismissMutation.mutate(reason)}
onDecide={decide}
/>
);
return compact ? <div className="space-y-4">{card}<DecisionCardFooter leading={leading} /></div> : card;
}
+4
View File
@@ -58,6 +58,7 @@ export function AgingItemRow({
onToggleExpand,
onDismiss,
onSnooze,
evidence,
}: {
item: AttentionItem;
companyId: string;
@@ -69,6 +70,8 @@ export function AgingItemRow({
onToggleExpand: (item: AttentionItem) => void;
onDismiss: (item: AttentionItem) => void;
onSnooze: (item: AttentionItem, snoozedUntil: string) => void;
/** The rest of this card's obligation (PAP-749 Rule 11). */
evidence?: readonly AttentionItem[];
}) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
@@ -117,6 +120,7 @@ export function AgingItemRow({
agents={agents}
showTriage
currentUserId={currentUserId}
evidence={evidence}
/>
</div>
);
@@ -264,7 +264,7 @@ describe("IssueThreadInteractionCard", () => {
);
const radios = [...host.querySelectorAll('[role="radio"]')];
expect(radios).toHaveLength(2);
expect(radios).toHaveLength(3);
expect(radios[0]?.getAttribute("aria-checked")).toBe("false");
act(() => {
@@ -278,13 +278,55 @@ describe("IssueThreadInteractionCard", () => {
expect(multiGroup?.getAttribute("aria-labelledby")).toBe(
"interaction-questions-default-post-submit-summary-prompt",
);
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(3);
expect(host.querySelectorAll('[role="checkbox"]')).toHaveLength(4);
const otherLink = Array.from(host.querySelectorAll("button")).find((button) =>
button.textContent === "Other",
);
expect(otherLink?.getAttribute("role")).toBeNull();
expect(otherLink?.className).toContain("underline");
const otherOption = radios.find((button) => button.textContent === "Other");
expect(otherOption?.closest('[role="radiogroup"]')).toBe(singleGroup);
});
it("renders block Markdown in compact question labels without paragraph nesting", () => {
const question = { ...pendingAskUserQuestionsInteraction.payload.questions[0]!, prompt: "**Which option?**\n\n- First consideration\n- Second consideration" };
const interaction = { ...pendingAskUserQuestionsInteraction, payload: { ...pendingAskUserQuestionsInteraction.payload, questions: [question] } };
const host = renderCard({ presentationVariant: "coordinator-decision", interaction });
const group = host.querySelector('[role="radiogroup"]')!;
const label = host.querySelector(`#${group.getAttribute("aria-labelledby")}`)!;
expect(label.querySelector("strong")?.textContent).toBe("Which option?");
expect(label.querySelectorAll("li")).toHaveLength(2);
expect(label.querySelector("div")?.closest("p")).toBeNull();
});
it.each(["default", "coordinator-decision"] as const)("treats Other as a radio choice that reveals its text field (%s)", async (presentationVariant) => {
const question = { ...pendingAskUserQuestionsInteraction.payload.questions[0]!, required: true };
const interaction = { ...pendingAskUserQuestionsInteraction, payload: { ...pendingAskUserQuestionsInteraction.payload, questions: [question] } };
const onSubmitInteractionAnswers = vi.fn(async () => undefined);
const host = renderCard({ presentationVariant, interaction, onSubmitInteractionAnswers });
const group = host.querySelector('[role="radiogroup"]')!;
const radios = [...group.querySelectorAll<HTMLButtonElement>('[role="radio"]')];
const other = radios.find(button => button.textContent === "Other")!;
expect(other).toBeTruthy();
expect(host.querySelector("textarea")).toBeNull();
await act(async () => radios[0]!.click());
await act(async () => other.click());
expect(other.getAttribute("aria-checked")).toBe("true");
expect(radios[0]!.getAttribute("aria-checked")).toBe("false");
const input = host.querySelector("textarea")!;
const submit = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Send answers"))!;
expect(input).not.toBeNull();
expect(submit.disabled).toBe(true);
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(input, "My alternative");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(submit.disabled).toBe(false);
await act(async () => other.click());
expect(other.getAttribute("aria-checked")).toBe("true");
expect(host.querySelector("textarea")?.value).toBe("My alternative");
await act(async () => radios[0]!.click());
expect(other.getAttribute("aria-checked")).toBe("false");
expect(host.querySelector("textarea")).toBeNull();
await act(async () => submit.click());
expect(onSubmitInteractionAnswers).toHaveBeenCalledWith(interaction, [{ questionId: question.id, optionIds: [question.options[0]!.id] }]);
});
it("keeps Coordinator decision questions focused while preserving answers and accessible requirements", async () => {
@@ -314,31 +356,45 @@ describe("IssueThreadInteractionCard", () => {
]);
});
it("keeps Coordinator confirmation details collapsed while preserving context, exact target, and approval", async () => {
it("shows a Coordinator card's responder clause when it is addressed elsewhere", () => {
const interaction = { ...pendingRequestConfirmationInteraction, addresseeAgentId: "agent-nap" };
const host = renderCard({ presentationVariant: "coordinator-decision", interaction });
expect(host.querySelector('[data-testid="interaction-audience"]')?.parentElement?.className ?? "").not.toContain("sr-only");
});
it("keeps that clause for screen readers when the card's audience line states it (PAP-767)", () => {
const interaction = { ...pendingRequestConfirmationInteraction, addresseeAgentId: "agent-nap" };
const host = renderCard({ presentationVariant: "coordinator-decision", interaction, audienceStated: true });
expect(host.querySelector('[data-testid="interaction-audience"]')?.parentElement?.className).toContain("sr-only");
});
it("puts a Coordinator confirmation's target and details in its context panel, approve on the right", async () => {
const onAcceptInteraction = vi.fn(async () => undefined);
const interaction = { ...pendingRequestConfirmationInteraction, title: "Repeated approval context", summary: "Repeated approval context", payload: { ...pendingRequestConfirmationInteraction.payload, prompt: "Repeated approval context", detailsMarkdown: "Revision proof: sha256-test-proof" } };
const host = renderCard({ presentationVariant: "coordinator-decision", interaction, onAcceptInteraction });
expect(host.textContent?.split("Repeated approval context")).toHaveLength(2);
expect(host.querySelector('[data-testid="interaction-status-badge"]')).toBeNull();
const documentLink = host.querySelector('a[href*="#document-"]');
const panel = host.querySelector("[data-needs-you-panel]")!;
const documentLink = panel.querySelector('a[href*="#document-"]');
expect(documentLink?.getAttribute("href")).toContain("revision=11111111-1111-4111-8111-111111111111");
expect(documentLink?.getAttribute("href")).toContain(`interaction=${interaction.id}`);
const details = host.querySelector("details")!;
expect(details.open).toBe(false);
expect(details.querySelector("summary")?.textContent?.trim()).toBe("Details");
expect(details.textContent).toContain("Revision proof: sha256-test-proof");
expect(documentLink?.closest("details")).toBeNull();
expect(host.querySelector("[data-decision-prompt]")?.closest("details")).toBeNull();
await act(() => details.querySelector("summary")!.click());
expect(details.open).toBe(true);
await act(() => details.querySelector("summary")!.click());
expect(details.open).toBe(false);
const approve = [...host.querySelectorAll("button")].find(button => button.textContent?.includes(interaction.payload.acceptLabel ?? "Approve"))!;
expect(approve.closest("details")).toBeNull();
await act(() => approve.click());
expect(panel.textContent).toContain("Revision proof: sha256-test-proof");
expect(host.querySelector("details")).toBeNull();
expect(host.querySelector("[data-decision-prompt]")?.closest("[data-needs-you-panel]")).toBeNull();
const actions = [...host.querySelectorAll('[data-testid="confirmation-actions"] button')];
const approve = actions.at(-1)!;
expect(approve.textContent).toContain(interaction.payload.acceptLabel ?? "Approve plan");
await act(() => (approve as HTMLButtonElement).click());
expect(onAcceptInteraction).toHaveBeenCalledWith(interaction);
});
it("renders the Needs You artifact row first in a compact confirmation's panel", () => {
const host = renderCard({ presentationVariant: "coordinator-decision", interaction: pendingRequestConfirmationInteraction, artifactRow: <div data-testid="artifact-row">Plan</div> });
const panel = host.querySelector("[data-needs-you-panel]")!;
expect(panel.firstElementChild?.getAttribute("data-testid")).toBe("artifact-row");
expect(panel.querySelector('a[href*="#document-"]')).toBeNull();
});
it("retains inline confirmation details in the Classic presentation", () => {
const host = renderCard({ interaction: {
...pendingRequestConfirmationInteraction,
@@ -694,6 +750,28 @@ describe("IssueThreadInteractionCard", () => {
expect(prompt?.textContent).not.toContain("[two screenshots]");
});
it.each([
["checkbox", false, "coordinator-decision"], ["checkbox", true, "coordinator-decision"],
["items", false, "coordinator-decision"], ["items", true, "coordinator-decision"],
["items", false, "default"], ["items", true, "default"],
] as const)("renders %s Markdown prompts with a separate title %s in %s", (kind, separateTitle, presentationVariant) => {
const interaction = structuredClone(kind === "checkbox" ? pendingRequestCheckboxConfirmationInteraction : pendingRequestItemVerdictsInteraction);
interaction.title = separateTitle ? "Review this request" : null;
interaction.payload.prompt = "Review **carefully** and [read the plan](https://example.com/plan).\n\n- Verify the work\n- Check the result\n\n![Reference](https://example.com/reference.png)";
const host = renderCard({ interaction, presentationVariant });
const prompt = host.querySelector('a[href="https://example.com/plan"]')?.closest('.paperclip-markdown');
expect(prompt?.querySelector('strong')?.textContent).toBe('carefully');
expect(prompt?.querySelectorAll('li')).toHaveLength(2);
expect(prompt?.querySelector('[data-markdown-image-reference]')).not.toBeNull();
expect(prompt?.querySelector('img')).toBeNull();
expect(host.querySelector('h2 .paperclip-markdown')).toBeNull();
if (presentationVariant === "coordinator-decision") {
const heading = host.querySelector('[data-decision-prompt]');
expect(heading?.getAttribute('role')).toBe('heading');
expect(heading?.getAttribute('aria-level')).toBe('2');
}
});
it("requires a revision note when the request confirmation payload asks for one", async () => {
const onRejectInteraction = vi.fn(async () => undefined);
const host = renderCard({
@@ -1326,11 +1404,11 @@ describe("IssueThreadInteractionCard tool-action card", () => {
.toContain("Reconnect");
});
it("renders only the server-redacted proposal as plain text when inspected", async () => {
it.each(["default", "coordinator-decision"] as const)("renders the full redacted proposal as plain text before approval (%s)", async (presentationVariant) => {
const interaction = structuredClone(pendingToolActionWriteInteraction);
interaction.payload.toolAction!.argumentsSummaryJson = JSON.stringify({ text: '<img src="x" onerror="alert(1)">', apiKey: "[redacted]" });
const accept = vi.fn();
const host = renderCard({ interaction, onAcceptInteraction: accept });
const host = renderCard({ interaction, onAcceptInteraction: accept, presentationVariant });
const details = host.querySelector("details")!;
expect(details.open).toBe(false);
await act(async () => details.querySelector("summary")!.click());
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -765,7 +765,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
)}
</main>
</div>
<PropertiesPanel taskDetailLayout={!isMobile && useStreamlinedTaskDetailShell} />
<PropertiesPanel taskDetailLayout={!isMobile && (useStreamlinedTaskDetailShell || (coordinatorWorkspaceEnabled && companyPathSegments[0]?.toLowerCase() === "decisions"))} />
</div>
</div>
</div>
@@ -0,0 +1,139 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectProperties } from "./ProjectProperties";
import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "../lib/queryKeys";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function act(callback: () => void) {
flushSync(() => {
callback();
});
}
const noop = vi.hoisted(() => () => undefined);
vi.mock("../api/projects", () => ({ projectsApi: { createWorkspace: vi.fn(), removeWorkspace: vi.fn(), updateWorkspace: vi.fn() } }));
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/secrets", () => ({ secretsApi: { list: vi.fn().mockResolvedValue([]), listUserSecretDefinitions: vi.fn().mockResolvedValue([]), create: vi.fn() } }));
vi.mock("../api/environments", () => ({ environmentsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: { getExperimental: vi.fn().mockResolvedValue({ enableIsolatedWorkspaces: true }) } }));
vi.mock("@/api/access", () => ({
accessApi: {
getCurrentBoardAccess: vi.fn().mockResolvedValue(null),
listMembers: vi.fn().mockResolvedValue({
members: [
{ user: { id: "scott", name: "Scott", email: "scott@example.test" } },
{ user: { id: "riley", name: "Riley", email: "riley@example.test" } },
],
}),
},
}));
vi.mock("@/api/auth", () => ({ authApi: { getSession: vi.fn().mockResolvedValue(null) } }));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ companies: [{ id: "company-1", issuePrefix: "PAP" }], selectedCompanyId: "company-1", setSelectedCompanyId: vi.fn() }),
}));
// Heavy children that are unrelated to the decision owner picker.
vi.mock("./environment-variables-editor", () => ({ EnvironmentVariablesEditor: () => null }));
vi.mock("./InlineEditor", () => ({ InlineEditor: ({ value }: { value?: ReactNode }) => <div>{value}</div> }));
vi.mock("./PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
function makeProject(overrides: Partial<Project> = {}): Project {
return {
id: "project-1",
urlKey: "project-1",
name: "Test project",
description: "A long description that fills the value column",
status: "in_progress",
goalIds: [],
goals: [],
env: null,
codebase: { workspaceId: null, repoUrl: null, repoRef: null, defaultRef: null, repoName: null },
primaryWorkspace: null,
workspaces: [],
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", allowIssueOverride: true },
...overrides,
} as unknown as Project;
}
function primedClient() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(queryKeys.health, { hiddenSettings: [] });
client.setQueryData(queryKeys.instance.experimentalSettings, { enableIsolatedWorkspaces: true });
return client;
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
async function render(project: Project, onFieldUpdate: (field: string, data: Record<string, unknown>) => void) {
act(() => {
root.render(
<QueryClientProvider client={primedClient()}>
<TooltipProvider>
<ProjectProperties project={project} onFieldUpdate={onFieldUpdate} onArchive={noop} />
</TooltipProvider>
</QueryClientProvider>,
);
});
// Let the members query resolve and re-render the options.
await vi.waitFor(() => {
expect(container.querySelectorAll("[data-testid='project-decision-owner'] option")).toHaveLength(3);
});
}
function picker(): HTMLSelectElement {
const select = container.querySelector<HTMLSelectElement>("[data-testid='project-decision-owner']");
if (!select) throw new Error("Decision owner picker not rendered");
return select;
}
// PAP-749 task 1d: the project's decision owner gets the project's ownerless
// Needs You cards before the company's default decision owner.
describe("ProjectProperties decision owner", () => {
it("lists company members after the Company default option", async () => {
await render(makeProject(), vi.fn());
expect([...picker().options].map((option) => option.textContent)).toEqual(["Company default", "Scott", "Riley"]);
expect(picker().value).toBe("");
});
it("shows the saved owner and saves a new one through the field update", async () => {
const onFieldUpdate = vi.fn();
await render(makeProject({ decisionOwnerUserId: "scott" }), onFieldUpdate);
expect(picker().value).toBe("scott");
act(() => {
picker().value = "riley";
picker().dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onFieldUpdate).toHaveBeenCalledWith("decision_owner", { decisionOwnerUserId: "riley" });
act(() => {
picker().value = "";
picker().dispatchEvent(new Event("change", { bubbles: true }));
});
expect(onFieldUpdate).toHaveBeenLastCalledWith("decision_owner", { decisionOwnerUserId: null });
});
});
+33
View File
@@ -15,6 +15,7 @@ import { secretsApi } from "../api/secrets";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { Separator } from "@/components/ui/separator";
import { NativeSelect } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Trash2 } from "lucide-react";
@@ -45,6 +46,7 @@ export type ProjectConfigFieldKey =
| "description"
| "status"
| "visibility"
| "decision_owner"
| "goals"
| "env"
| "execution_workspace_enabled"
@@ -259,6 +261,12 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
// PAP-749 task 1d: members populate the decision owner picker.
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyMembers(selectedCompanyId ?? ""),
queryFn: () => accessApi.listMembers(selectedCompanyId!),
enabled: Boolean(selectedCompanyId) && Boolean(onUpdate || onFieldUpdate),
});
const providerConfiguration = project.workspaceProvider;
const projectStoragePluginId = project.executionWorkspacePolicy?.storageProviderPluginId ?? null;
const showStorageAvailability = Boolean(projectStoragePluginId || providerConfiguration?.providers.length);
@@ -501,6 +509,31 @@ export function ProjectProperties({ project, repositories, onUpdate, onFieldUpda
{project.visibility === "private" ? <ProjectAccessMembers project={project} canManage={canManagePrivacy} /> : null}
</div>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Decision owner" state={fieldState("decision_owner")} />} alignStart>
<div className="space-y-1">
<NativeSelect
aria-label="Decision owner"
data-testid="project-decision-owner"
value={project.decisionOwnerUserId ?? ""}
disabled={!onUpdate && !onFieldUpdate}
onChange={(event) =>
commitField("decision_owner", { decisionOwnerUserId: event.target.value === "" ? null : event.target.value })
}
>
<option value="">Company default</option>
{(companyMembers?.members ?? [])
.filter((member) => member.user?.id)
.map((member) => (
<option key={member.user!.id} value={member.user!.id}>
{member.user!.name || member.user!.email || member.user!.id}
</option>
))}
</NativeSelect>
<p className="text-(length:--text-micro) text-muted-foreground">
A Needs You card on this project's work that names no person goes to this user's For you list.
</p>
</div>
</PropertyRow>
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
<ProjectGitAuthorization key={`git-${project.id}`} project={project} />
<PropertyRow
+6
View File
@@ -0,0 +1,6 @@
import { MarkdownBody } from "./MarkdownBody";
/** Decision prompts use the same safe Markdown and reference media as task chat. */
export function PromptText({ children }: { children: string }) {
return <MarkdownBody className="paperclip-markdown-on-accent" mediaMode="reference">{children}</MarkdownBody>;
}
+5 -7
View File
@@ -31,11 +31,10 @@ import { SidebarStarredProjects } from "./SidebarStarredProjects.production";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { attentionApi } from "../api/attention";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { attentionBadgeCount } from "../lib/attention";
import { useAttentionBadge } from "../hooks/useAttentionViews";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -87,13 +86,12 @@ export function Sidebar() {
// item is hidden entirely until the flag is enabled (same no-flash pattern as
// showWorkspacesLink — it defaults hidden, so no placeholder is needed).
const showDecisions = experimentalSettings?.enableDecisions === true;
const { data: attentionFeed } = useQuery({
queryKey: queryKeys.attention(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && showDecisions,
// The shared "For you" badge (PAP-749 Rule 5): same query as the Decisions
// page and the coordinator navigation, with no midnight reset.
const { count: attentionCount } = useAttentionBadge(selectedCompanyId, {
enabled: showDecisions,
refetchInterval: 60_000,
});
const attentionCount = attentionBadgeCount(attentionFeed);
const showCases = experimentalSettings?.enableCases === true;
// Streamlined left navigation (top-level Projects link + starred children) is
// now the standard product sidebar (PAP-12472). The former experimental
+5 -7
View File
@@ -38,11 +38,10 @@ import { SidebarRecentTasks } from "./SidebarRecentTasks";
import { useDialogActions } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { useSidebar } from "../context/SidebarContext";
import { attentionApi } from "../api/attention";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { attentionBadgeCount } from "../lib/attention";
import { useAttentionBadge } from "../hooks/useAttentionViews";
import { useInboxBadge } from "../hooks/useInboxBadge";
import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled";
import { usePublishSharedQueryData, useSharedPollingQuery } from "../hooks/useSharedPolling";
@@ -111,13 +110,12 @@ function ClassicSidebar({ children }: { children?: ReactNode }) {
// item is hidden entirely until the flag is enabled (same no-flash pattern as
// showWorkspacesLink — it defaults hidden, so no placeholder is needed).
const showDecisions = experimentalSettings?.enableDecisions === true;
const { data: attentionFeed } = useQuery({
queryKey: queryKeys.attention(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && showDecisions,
// The shared "For you" badge (PAP-749 Rule 5): same query as the Decisions
// page and the coordinator navigation, with no midnight reset.
const { count: attentionCount } = useAttentionBadge(selectedCompanyId, {
enabled: showDecisions,
refetchInterval: 60_000,
});
const attentionCount = attentionBadgeCount(attentionFeed);
const showCases = experimentalSettings?.enableCases === true;
// Conference Room Chat flag (PAP-136/PAP-137): the Conference Room nav item
// is a new surface, hidden entirely while the flag is off (same no-flash
+74 -53
View File
@@ -7,6 +7,7 @@ import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { issueReviewPolicyBadge } from "../lib/review-policy";
import { cn } from "../lib/utils";
import { DecisionCardFooter } from "./DecisionCardFooter";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -26,6 +27,11 @@ interface StalledReviewActionsProps {
reviewPolicy?: IssueReviewPolicy | null;
/** Explicit task-level wording where a separate asset approval may already exist. */
approveLabel?: string;
/**
* "card": the Needs You card footer (PAP-775) — Send back (ghost), Request
* changes (outline), approve (primary, last). "panel" (default): today's row.
*/
presentation?: "card" | "panel";
className?: string;
}
@@ -52,6 +58,7 @@ export function StalledReviewActions({
onResolved,
reviewPolicy,
approveLabel = "Approve",
presentation = "panel",
className,
}: StalledReviewActionsProps) {
const queryClient = useQueryClient();
@@ -86,9 +93,64 @@ export function StalledReviewActions({
const runningFor = (action: StalledReviewDecisionAction) =>
pending && decide.variables === action;
const policyBadge = issueReviewPolicyBadge(reviewPolicy);
const card = presentation === "card";
const sendBack = (
<Button
type="button"
variant={card ? "ghost" : "outline"}
size="sm"
className={card ? undefined : "w-full sm:w-auto sm:flex-1 @xl:flex-none"}
disabled={pending}
onClick={() => decide.mutate("send_back")}
data-testid="stalled-review-send-back"
>
{runningFor("send_back") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : card ? null : (
<Undo2 className="h-3.5 w-3.5" aria-hidden />
)}
Send back to work
</Button>
);
const requestChanges = (
<Button
type="button"
variant="outline"
size="sm"
className={card ? undefined : "w-full border-amber-400/70 text-amber-900 hover:bg-amber-100 dark:border-amber-500/50 dark:text-amber-100 dark:hover:bg-amber-500/15 sm:w-auto sm:flex-1 @xl:flex-none"}
disabled={pending || noteEmpty}
title={noteEmpty ? "Add a note to request changes" : undefined}
onClick={() => decide.mutate("request_changes")}
data-testid="stalled-review-request-changes"
>
{runningFor("request_changes") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : card ? null : (
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
)}
Request changes
</Button>
);
const approve = (
<Button
type="button"
size="sm"
className={card ? undefined : "w-full sm:w-auto sm:flex-1 @xl:flex-none"}
disabled={pending}
onClick={() => decide.mutate("approve")}
data-testid="stalled-review-approve"
>
{runningFor("approve") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : card ? null : (
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden />
)}
{approveLabel}
</Button>
);
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className={cn("flex flex-col gap-3", card && "gap-4", className)}>
{/* Only a constrained policy gets a badge: the server refuses a verdict
from the wrong actor, so the card has to warn before offering Approve.
The default needs no line — anyone with write access can approve. */}
@@ -112,59 +174,18 @@ export function StalledReviewActions({
data-testid="stalled-review-note"
disabled={pending}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
{footerSlot ?? <span className="hidden sm:block" />}
<div className="flex flex-col gap-2 sm:flex-1 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end @xl:flex-none">
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto sm:flex-1 @xl:flex-none"
disabled={pending}
onClick={() => decide.mutate("send_back")}
data-testid="stalled-review-send-back"
>
{runningFor("send_back") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<Undo2 className="h-3.5 w-3.5" aria-hidden />
)}
Send back to work
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="w-full border-amber-400/70 text-amber-900 hover:bg-amber-100 dark:border-amber-500/50 dark:text-amber-100 dark:hover:bg-amber-500/15 sm:w-auto sm:flex-1 @xl:flex-none"
disabled={pending || noteEmpty}
title={noteEmpty ? "Add a note to request changes" : undefined}
onClick={() => decide.mutate("request_changes")}
data-testid="stalled-review-request-changes"
>
{runningFor("request_changes") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="h-3.5 w-3.5" aria-hidden />
)}
Request changes
</Button>
<Button
type="button"
size="sm"
className="w-full sm:w-auto sm:flex-1 @xl:flex-none"
disabled={pending}
onClick={() => decide.mutate("approve")}
data-testid="stalled-review-approve"
>
{runningFor("approve") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden />
)}
{approveLabel}
</Button>
{card ? (
<DecisionCardFooter leading={footerSlot}>{sendBack}{requestChanges}{approve}</DecisionCardFooter>
) : (
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
{footerSlot ?? <span className="hidden sm:block" />}
<div className="flex flex-col gap-2 sm:flex-1 sm:flex-row sm:flex-wrap sm:items-center sm:justify-end @xl:flex-none">
{sendBack}
{requestChanges}
{approve}
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,10 +1,9 @@
import { useQueryView } from "@/components/QueryView";
import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpRight } from "lucide-react";
import { attentionApi } from "@/api/attention";
import { useCompany } from "@/context/CompanyContext";
import { coordinatorAttentionQueryKey, pendingDecisionTaskIds } from "@/lib/coordinator-attention";
import { pendingDecisionTaskIds } from "@/lib/coordinator-attention";
import { useAttentionViewFeed } from "@/hooks/useAttentionViews";
import { useNavigate } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { StatusIcon } from "@/components/StatusIcon";
@@ -15,8 +14,7 @@ export function CoordinatorAttentionTasks({ toolbar }: { toolbar: ReactNode }) {
const { selectedCompanyId } = useCompany();
const navigate = useNavigate();
const [search, setSearch] = useState("");
const feed = useQuery({ queryKey: coordinatorAttentionQueryKey(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
const feed = useAttentionViewFeed(selectedCompanyId, "mine");
const feedView = useQueryView(feed);
const waiting = ["loading", "reconnecting"].includes(feedView.kind);
const items = feed.data?.items ?? [];
@@ -1,13 +1,22 @@
import { ArrowUpRight, Check } from "lucide-react";
import type { DecisionHistoryItem } from "@paperclipai/shared";
import { decisionHistoryContext, decisionHistoryHeadline } from "@/lib/coordinator-attention";
/** A recorded decision links to its context; it is not a second approval form. */
export function CoordinatorDecisionReceipt({ item, onOpen }: { item: DecisionHistoryItem; onOpen: (href: string) => void }) {
return <button type="button" onClick={() => onOpen(item.href)} className="coordinator-decision-receipt flex w-full items-center gap-3 rounded-xl border border-border bg-card p-4 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
export function CoordinatorDecisionReceipt({ item, onOpen, userLabelMap }: {
item: DecisionHistoryItem;
onOpen: (href: string) => void;
/** Names the person a different person answered for ("for @dotta"; PAP-749 plan §6). */
userLabelMap?: ReadonlyMap<string, string> | null;
}) {
const handledFor = item.handledForUserId ? userLabelMap?.get(item.handledForUserId) ?? "someone else" : null;
return <button type="button" onClick={() => onOpen(item.href)} data-decision-history-item={item.id} className="coordinator-decision-receipt flex w-full items-center gap-3 rounded-xl border border-border bg-card p-4 text-left hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Check className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1 space-y-1">
<span className="block text-sm font-medium">{item.label}</span>
<span className="block truncate text-sm text-muted-foreground">{item.issueIdentifier} · {item.issueTitle}</span>
<span className="block text-sm font-medium">{decisionHistoryHeadline(item, userLabelMap)}{handledFor ? <span className="font-normal text-muted-foreground"> · for @{handledFor}</span> : null}</span>
<span className="block truncate text-sm text-muted-foreground">{decisionHistoryContext(item)}</span>
{/* Who withdrew it is in the headline; the reason they gave is here (PAP-749 plan §3). */}
{item.reason ? <span className="block text-sm text-muted-foreground" data-decision-history-reason>Reason: {item.reason}</span> : null}
</span>
<ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</button>;
@@ -4,22 +4,43 @@ import { MemoryRouter, useLocation } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
import type { Agent, AttentionItem, AttentionReceipt, DecisionHistoryItem } from "@paperclipai/shared";
import { CoordinatorNeedsYou } from "./CoordinatorNeedsYou";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({ pending: [] as AttentionItem[], history: [] as DecisionHistoryItem[], url: "/decisions", mobile: false, partial: false, historyFail: false, feedFail: false, openPanel: vi.fn(), closePanel: vi.fn(), setPanelVisible: vi.fn(), navigate: vi.fn(), breadcrumbs: vi.fn() }));
const fixture = vi.hoisted(() => ({ pending: [] as AttentionItem[], byView: {} as Record<string, AttentionItem[]>, viewCounts: undefined as undefined | Record<string, number>, receipts: [] as AttentionReceipt[], listCalls: [] as unknown[], claim: vi.fn(async () => ({})), close: vi.fn(async (..._args: unknown[]) => ({ until: null })), retryTask: vi.fn(async (..._args: unknown[]) => ({ accepted: true, requestId: "r", stale: false, presentation: null })), history: [] as DecisionHistoryItem[], url: "/decisions", mobile: false, partial: false, historyFail: false, feedFail: false, openPanel: vi.fn(), closePanel: vi.fn(), setPanelVisible: vi.fn(), navigate: vi.fn(), breadcrumbs: vi.fn(), boardAccess: null as null | Record<string, unknown>, historyByScope: {} as Record<string, DecisionHistoryItem[]>, historyCalls: [] as Array<Record<string, unknown>>, resolveIncident: vi.fn(async (..._args: unknown[]) => ({})) }));
const dismissals = vi.hoisted(() => ({ restore: vi.fn(async (..._args: unknown[]) => ({})), dismiss: vi.fn(async (..._args: unknown[]) => ({})) }));
vi.mock("@/api/inboxDismissals", () => ({ inboxDismissalsApi: { restore: dismissals.restore, dismiss: dismissals.dismiss } }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) }));
vi.mock("@/context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: fixture.breadcrumbs }) }));
vi.mock("@/context/PanelContext", () => ({ usePanel: () => ({ openPanel: fixture.openPanel, closePanel: fixture.closePanel, setPanelVisible: fixture.setPanelVisible }) }));
vi.mock("@/context/PanelContext", () => ({ usePanel: () => ({ openPanel: fixture.openPanel, closePanel: fixture.closePanel, setPanelVisible: fixture.setPanelVisible, panelVisible: true }) }));
vi.mock("@/context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: fixture.mobile }) }));
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ userId: "reviewer" }) }));
vi.mock("@/lib/router", async importOriginal => ({ ...await importOriginal<typeof import("@/lib/router")>(), useNavigate: () => fixture.navigate }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => [] } }));
const agentFixture = vi.hoisted(() => ({ rows: [] as Agent[] }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: async () => agentFixture.rows } }));
vi.mock("@/api/projects", () => ({ projectsApi: { list: async () => [] } }));
vi.mock("@/api/access", () => ({ accessApi: {
listUserDirectory: async () => ({ users: [{ principalId: "dotta", status: "active", user: { name: "Dotta" } }] }),
getCurrentBoardAccess: async () => { if (!fixture.boardAccess) throw new Error("No board access"); return fixture.boardAccess; },
} }));
vi.mock("@/api/issues", () => ({ issuesApi: { retryTask: fixture.retryTask } }));
vi.mock("@/api/budgets", () => ({ budgetsApi: { resolveIncident: fixture.resolveIncident } }));
const approvals = vi.hoisted(() => ({ get: vi.fn(async (..._args: unknown[]) => ({ payload: {} })), approve: vi.fn(async (..._args: unknown[]) => ({})), reject: vi.fn(async () => ({})), requestRevision: vi.fn(async () => ({})) }));
vi.mock("@/api/approvals", () => ({ approvalsApi: approvals }));
vi.mock("@/api/attention", () => ({ attentionApi: {
list: async () => { if (fixture.feedFail) throw new Error("Feed unavailable"); return { items: [...fixture.pending] }; },
history: async () => { if (fixture.historyFail) throw new Error("History unavailable"); return { items: [...fixture.history], nextCursor: null }; },
claim: fixture.claim,
close: fixture.close,
list: async (_companyId: string, options: { view?: string }) => {
fixture.listCalls.push(options);
if (fixture.feedFail) throw new Error("Feed unavailable");
return { items: [...(fixture.byView[options.view ?? ""] ?? fixture.pending)], viewCounts: fixture.viewCounts, receipts: options.view === "mine" ? [...fixture.receipts] : [] };
},
history: async (_companyId: string, options: Record<string, unknown> = {}) => {
fixture.historyCalls.push(options);
if (fixture.historyFail) throw new Error("History unavailable");
return { items: [...(fixture.historyByScope[String(options.scope)] ?? fixture.history)], nextCursor: null };
},
} }));
vi.mock("motion/react", () => ({
LayoutGroup: ({ children }: { children: ReactNode }) => children,
@@ -32,7 +53,7 @@ vi.mock("@/components/StalledReviewActions", () => ({ StalledReviewActions: ({ i
fixture.pending = fixture.pending.filter((item) => item.subject.id !== issueId);
onResolved();
}}>{approveLabel}</button> }));
vi.mock("@/components/AttentionInteractionResolver", () => ({ AttentionInteractionResolver: ({ issueId, interactionId, footerSlot, onResolved }: { issueId: string; interactionId: string; footerSlot?: ReactNode; onResolved: (interaction: { status: string }) => void }) => <>{footerSlot}<button data-issue-id={issueId} onClick={() => {
vi.mock("@/components/AttentionInteractionResolver", () => ({ AttentionInteractionResolver: ({ issueId, interactionId, footerSlot, artifactRow, onResolved }: { issueId: string; interactionId: string; footerSlot?: ReactNode; artifactRow?: ReactNode; onResolved: (interaction: { status: string }) => void }) => <>{artifactRow}{footerSlot}<button data-issue-id={issueId} onClick={() => {
if (fixture.partial) { onResolved({ status: "pending" }); return; }
fixture.pending = fixture.pending.filter((item) => item.subject.id !== interactionId);
fixture.history.push({ id: `issue_thread_interaction:${interactionId}`, sourceKind: "issue_thread_interaction", sourceId: interactionId,
@@ -46,7 +67,9 @@ let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
fixture.pending = ["1", "2", "3"].map((id) => ({ id, sourceKind: "issue_thread_interaction", inlineResolvable: true, subject: { id, kind: "interaction", title: `Review ${id}`, metadata: { issueId: `task-${id}` } }, relatedIssue: { id: `task-${id}`, identifier: `PAP-${id}`, title: `Task ${id}`, href: `/PAP/issues/PAP-${id}` }, activityAt: new Date().toISOString() } as unknown as AttentionItem));
agentFixture.rows = [{ id: "agent-cos", name: "Chief of Staff" }] as Agent[];
fixture.history = []; fixture.url = "/decisions"; vi.clearAllMocks();
fixture.byView = {}; fixture.viewCounts = undefined; fixture.receipts = []; fixture.listCalls = []; fixture.boardAccess = null; fixture.historyByScope = {}; fixture.historyCalls = [];
fixture.historyFail = false; fixture.feedFail = false; fixture.partial = false; fixture.mobile = false;
container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container);
});
@@ -57,6 +80,157 @@ async function click(text: string) {
expect(button, text).toBeTruthy();
await act(async () => button!.click()); await flush();
}
/** History has one home, the View menu (PAP-775, IA2–IA4). */
async function openHistory() {
const trigger = container.querySelector<HTMLElement>("[data-attention-view-menu]");
expect(trigger, "View menu").toBeTruthy();
await act(async () => {
trigger!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const item = [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((entry) => entry.textContent === "Decision history");
expect(item, "Decision history menu item").toBeTruthy();
await act(async () => item!.click()); await flush();
}
async function renderPage(url = fixture.url) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
await flush(); await flush();
return client;
}
describe("Coordinator Needs You — Archived view and history filters (PAP-749 task 2e)", () => {
const hidden = (id: string, kind: "dismiss" | "snooze", snoozedUntil: string | null = null) => ({
id, sourceKind: "issue_thread_interaction", inlineResolvable: false, dismissalKey: `attention:${id}`, changeFingerprint: `${id}.me`,
closeModes: ["withdraw", "archive", "snooze"], obligationKey: null, obligationLeadId: null, audience: { kind: "user", userId: "reviewer", reason: "addressee" },
dismissal: { kind, dismissedAt: "2026-10-29T12:00:00.000Z", snoozedUntil, isActive: true },
subject: { id: `${id}-interaction`, kind: "interaction", title: `Hidden ${id}`, href: `/PAP/issues/PAP-9#${id}`, metadata: {} },
relatedIssue: null, activityAt: new Date().toISOString(),
}) as unknown as AttentionItem;
it("lists your archived cards with their date and a Snoozed group, and restores one", async () => {
fixture.byView = { archived: [hidden("arch", "dismiss"), hidden("snz", "snooze", "2026-10-31T09:00:00.000Z")] };
fixture.viewCounts = { mine: 2, unassigned: 0, company: 0, archived: 2 };
const client = await renderPage("/decisions?view=archived");
expect(fixture.listCalls).toContainEqual(expect.objectContaining({ view: "archived", all: true }));
// Focus (PAP-770): Archived opens from the View menu, not a persistent tab;
// its heading names the view, with a back control to Needs You (PAP-775).
expect(container.querySelector("[data-attention-view-menu]")).not.toBeNull();
expect(container.querySelector("h1")?.textContent).toBe("Archived");
expect(container.querySelector('[aria-label="Back to Needs You"]')).not.toBeNull();
expect(container.querySelector('select[aria-label="Filter decisions by assignee"]')).toBeNull();
const archivedCard = container.querySelector<HTMLElement>('[data-attention-card="arch"]')!;
expect(archivedCard.querySelector('[data-attention-hidden="archived"]')?.textContent).toMatch(/^Archived /);
const group = container.querySelector<HTMLElement>("[data-attention-snoozed-group]")!;
expect(group.textContent).toContain("Snoozed (1)");
expect(group.querySelector('[data-attention-card="snz"] [data-attention-hidden="snoozed"]')).not.toBeNull();
expect(group.querySelector('[data-attention-card="arch"]')).toBeNull();
dismissals.restore.mockImplementationOnce(async () => { fixture.byView.archived = fixture.byView.archived!.filter(item => item.id !== "arch"); return {}; });
const trigger = archivedCard.querySelector<HTMLElement>("[data-attention-close-menu]")!;
await act(async () => {
trigger.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(document.body.querySelector('[data-attention-close-mode="archive"]')).toBeNull();
await act(async () => document.body.querySelector<HTMLElement>('[data-attention-close-mode="restore"]')!.click());
await flush();
expect(dismissals.restore).toHaveBeenCalledWith("company", "attention:arch");
expect(container.querySelector('[data-attention-card="arch"]')).toBeNull();
await act(async () => container.querySelector<HTMLElement>('[aria-label="Back to Needs You"]')!.click()); await flush();
expect(container.querySelector("[data-location]")?.textContent).toBe("");
expect(container.querySelector("h1")).toBeNull();
client.clear();
// The file's first test also pays for the cold module graph.
}, 15_000);
it("shows the Archived empty state", async () => {
fixture.byView = { archived: [] };
const client = await renderPage("/decisions?view=archived");
expect(container.textContent).toContain("Nothing archived. Cards you archive or snooze for yourself show here.");
client.clear();
});
it("filters history by Withdrawn, Expired and Closed with task, and names who withdrew and why", async () => {
fixture.mobile = true;
fixture.pending = [];
fixture.history = [{ id: "approval:appr", sourceKind: "approval", sourceId: "appr", issueId: null, issueIdentifier: null, issueTitle: null,
title: "Hire a designer", label: "Withdrawn by user", outcome: "cancelled", resultOutcome: "withdrawn", reason: "Budget moved to Q1",
resolvedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), resolvedByUserId: "dotta", resolvedByAgentId: null,
agentId: null, agentName: null, projectId: null, projectName: null, href: "/PAP/approvals/appr", target: null }];
const historySpy = vi.spyOn(await import("@/api/attention").then((module) => module.attentionApi), "history");
const client = await renderPage();
await openHistory();
const dialog = container.querySelector<HTMLElement>("[data-coordinator-decision-history]")!;
const outcome = dialog.querySelector<HTMLSelectElement>('[aria-label="Filter decision outcomes"]')!;
const labels = [...outcome.options].map((option) => option.textContent);
expect(labels).toEqual(expect.arrayContaining(["Withdrawn", "Expired", "Closed with task", "Handled for me"]));
expect(dialog.textContent).toContain("Withdrawn by @Dotta");
expect(dialog.textContent).toContain("Reason: Budget moved to Q1");
expect(dialog.textContent).toContain("Approval · Hire a designer");
await act(async () => {
outcome.value = "issue_closed";
outcome.dispatchEvent(new Event("change", { bubbles: true }));
});
await flush();
expect(historySpy).toHaveBeenCalledWith("company", expect.objectContaining({ outcome: "issue_closed" }));
client.clear();
});
const historyEntry = (id: string, issueTitle: string, resolvedByUserId: string) => ({ id: `issue_thread_interaction:${id}`, sourceKind: "issue_thread_interaction",
sourceId: id, issueId: `task-${id}`, issueIdentifier: `NEE-${id}`, issueTitle, title: "Confirm", label: "Approved", outcome: "accepted",
action: { outcome: "approved", detail: null }, resolvedAt: new Date(Date.now() - 60_000).toISOString(), resolvedByUserId, resolvedByAgentId: null,
agentId: null, agentName: null, projectId: null, projectName: null, href: `/NEE/issues/NEE-${id}`, target: null }) as DecisionHistoryItem;
const choose = async (root: ParentNode, label: string, value: string) => {
const select = root.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`);
expect(select, label).not.toBeNull();
await act(async () => { select!.value = value; select!.dispatchEvent(new Event("change", { bubbles: true })); });
await flush();
return select!;
};
it("opens Decision history on Handled by me or for me, with Everyone one choice away, and keeps history off the page (PAP-768, PAP-775)", async () => {
fixture.mobile = true;
fixture.pending = [];
const mineEntry = historyEntry("1", "Dotta's launch checklist", "reviewer");
const othersEntry = historyEntry("2", "Scott: task you created", "scott");
fixture.historyByScope = { mine: [mineEntry], all: [mineEntry, othersEntry] };
const client = await renderPage();
// IA3/IA4: history has one home, the main-pane view; no Recent Decisions list or header button.
expect(container.querySelector('[aria-label="Recent Decisions"]')).toBeNull();
expect(container.textContent).not.toContain("Dotta's launch checklist");
expect([...container.querySelectorAll("button")].some((button) => button.textContent?.includes("Decision history"))).toBe(false);
await openHistory();
const dialog = container.querySelector<HTMLElement>("[data-coordinator-decision-history]")!;
const scope = dialog.querySelector<HTMLSelectElement>('select[aria-label="Whose decision history"]')!;
expect(scope.value).toBe("mine");
expect([...scope.options].map((option) => option.textContent)).toEqual(["Handled by me or for me", "Everyone"]);
expect(dialog.textContent).not.toContain("Scott: task you created");
await choose(dialog, "Whose decision history", "all");
expect(dialog.textContent).toContain("Scott: task you created");
// An outcome filter applies within the chosen scope.
await choose(dialog, "Filter decision outcomes", "accepted");
expect(fixture.historyCalls).toContainEqual(expect.objectContaining({ scope: "all", outcome: "accepted", limit: 40 }));
client.clear();
});
it("offers everyone's decisions when nothing was handled by or for you (PAP-768)", async () => {
fixture.mobile = true;
fixture.pending = [];
fixture.historyByScope = { mine: [], all: [historyEntry("2", "Scott: task you created", "scott")] };
const client = await renderPage();
// Nothing of yours in the last day: Recent Decisions stays out of the way.
expect(container.querySelector('[aria-label="Recent Decisions"]')).toBeNull();
await openHistory();
const dialog = container.querySelector<HTMLElement>("[data-coordinator-decision-history]")!;
expect(dialog.textContent).toContain("No decisions handled by you or for you yet.");
await act(async () => [...dialog.querySelectorAll("button")].find((button) => button.textContent === "Show everyone's decisions")!.click());
await flush();
expect(dialog.querySelector<HTMLSelectElement>('select[aria-label="Whose decision history"]')!.value).toBe("all");
expect(dialog.textContent).toContain("Scott: task you created");
client.clear();
});
});
describe("Coordinator Needs You", () => {
it("reveals and focuses a linked decision beyond the first rendering window, then consumes only its URL parameter", async () => {
fixture.url = "/decisions?decisionId=target&q=kept";
@@ -80,25 +254,38 @@ describe("Coordinator Needs You", () => {
expect(scroll).not.toHaveBeenCalled();
client.clear();
});
it("opens searchable history from both phone buttons without the hidden desktop panel", async () => {
it("opens a decision link outside the personal feed before focusing it", async () => {
fixture.url = "/decisions?decisionId=target";
fixture.boardAccess = { userId: "reviewer", source: "session", memberships: [{ companyId: "company", status: "active", membershipRole: "admin" }] };
fixture.byView = { mine: [], all: [{ ...fixture.pending[0], id: "linked-decision", sourceKind: "decision", subject: { id: "target", kind: "decision", title: "Linked decision" } } as AttentionItem] };
const scroll = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, writable: true, value: scroll });
const client = await renderPage(fixture.url);
for (let tick = 0; tick < 5; tick++) await flush();
expect(fixture.listCalls).toContainEqual(expect.objectContaining({ view: "all" }));
expect(container.querySelector("#coordinator-decision-target")).not.toBeNull();
expect(container.querySelector("[data-location]")?.textContent).not.toContain("decisionId");
expect(scroll).toHaveBeenCalled();
client.clear();
});
it("opens searchable history in the main pane from the View menu, never in the side panel (PAP-770, PAP-775)", async () => {
fixture.mobile = true;
fixture.pending = [];
fixture.history = [{ id: "old-decision", sourceKind: "issue_thread_interaction", sourceId: "old", issueId: "task-1", issueIdentifier: "PAP-1", issueTitle: "Earlier launch", title: "Review", label: "Approved", outcome: "accepted", resolvedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), resolvedByUserId: "reviewer", resolvedByAgentId: null, agentId: null, agentName: null, projectId: null, projectName: null, href: "/PAP/issues/PAP-1#interaction-old", target: null }];
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
await flush();
for (const label of ["Decision history", "Open decision history"]) {
const trigger = [...container.querySelectorAll("button")].find(button => button.textContent?.trim() === label);
expect(trigger).toBeTruthy();
await act(async () => trigger!.click()); await flush();
const dialog = document.querySelector('[role="dialog"]');
expect(dialog).not.toBeNull();
expect(dialog!.querySelector('[aria-label="Search decision history"]')).not.toBeNull();
expect(dialog!.textContent).toContain("Earlier launch");
for (let round = 0; round < 2; round += 1) {
await openHistory();
const history = container.querySelector("[data-coordinator-decision-history]");
expect(history).not.toBeNull();
expect(history!.querySelector('[aria-label="Search decision history"]')).not.toBeNull();
expect(history!.textContent).toContain("Earlier launch");
expect(fixture.openPanel).not.toHaveBeenCalled();
const close = [...dialog!.querySelectorAll("button")].find(button => button.textContent === "Close");
await act(async () => close!.click()); await flush();
expect(document.querySelector('[role="dialog"]')).toBeNull();
const back = [...history!.querySelectorAll("button")].find(button => button.textContent === "Needs You");
await act(async () => back!.click()); await flush();
expect(container.querySelector("[data-coordinator-decision-history]")).toBeNull();
}
client.clear();
});
@@ -122,41 +309,40 @@ describe("Coordinator Needs You", () => {
fixture.historyFail = true; fixture.feedFail = staleFeed;
await click("Resolve 1"); await flush();
expect([...container.querySelectorAll("button")].some(button => button.textContent === "Resolve 1")).toBe(false);
expect(container.textContent).toContain("Recent decisions could not be loaded");
expect(container.textContent).toContain("Resolve 2");
client.clear();
});
it("sorts, groups, and filters the recent decisions shown beneath the toolbar", async () => {
fixture.pending = [];
const now = Date.now();
fixture.history = [
{ id: "recent-old", sourceId: "old", issueIdentifier: "PAP-1", issueTitle: "First task", projectId: "launch", projectName: "Launch", resolvedAt: new Date(now - 180_000).toISOString() },
{ id: "recent-middle", sourceId: "middle", issueIdentifier: "PAP-2", issueTitle: "Second task", projectId: "other", projectName: "Other", resolvedAt: new Date(now - 120_000).toISOString() },
{ id: "recent-new", sourceId: "new", issueIdentifier: "PAP-3", issueTitle: "Third task", projectId: "launch", projectName: "Launch", resolvedAt: new Date(now - 60_000).toISOString() },
].map((item) => ({ sourceKind: "issue_thread_interaction", issueId: item.sourceId, agentId: null, agentName: null,
title: "Review", outcome: "accepted", label: "Approved", resolvedByUserId: "reviewer", resolvedByAgentId: null,
href: `/PAP/issues/${item.issueIdentifier}`, target: null, ...item })) as DecisionHistoryItem[];
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
await flush();
const visibleIds = () => [...container.querySelectorAll('[aria-label="Recent Decisions"] .coordinator-decision-receipt')]
.map((item) => item.textContent?.match(/PAP-\d+/)?.[0]);
const changeSelect = async (label: string, value: string) => {
const select = container.querySelector<HTMLSelectElement>(`select[aria-label="${label}"]`);
expect(select).not.toBeNull();
await act(async () => { select!.value = value; select!.dispatchEvent(new Event("change", { bubbles: true })); });
};
expect(visibleIds()).toEqual(["PAP-3", "PAP-2", "PAP-1"]);
await changeSelect("Sort decisions", "oldest");
expect(visibleIds()).toEqual(["PAP-1", "PAP-2", "PAP-3"]);
await changeSelect("Group decisions", "task");
expect([...container.querySelectorAll('[aria-label="Recent Decisions"] h3')].map((item) => item.textContent)).toEqual(["PAP-1", "PAP-2", "PAP-3"]);
await changeSelect("Filter decisions by project", "launch");
expect(visibleIds()).toEqual(["PAP-1", "PAP-3"]);
it("keeps search, filters and grouping in one visible toolbar and applies them to cards (UX6, PAP-775)", async () => {
const client = await renderPage();
const toolbar = container.querySelector<HTMLElement>('[role="toolbar"][aria-label="Decisions controls"]')!;
expect(toolbar).not.toBeNull();
// No filter icon toggle and no sort select: the controls are always there.
expect(container.querySelector('[aria-label="Filter and search"]')).toBeNull();
expect(container.querySelector('select[aria-label="Sort decisions"]')).toBeNull();
const summary = container.querySelector("[data-needs-you-summary]")!;
expect(summary.textContent).toBe("3 decisions");
expect(toolbar.compareDocumentPosition(summary) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(container.querySelector("h1")).toBeNull();
expect(container.textContent).not.toContain("Someone needs you to choose");
// Grouped by type by default.
const group = toolbar.querySelector<HTMLSelectElement>('select[aria-label="Group decisions"]')!;
expect(group.value).toBe("type");
expect([...group.options].map((option) => option.textContent)).toEqual(["By type", "By task", "By agent", "By project", "No grouping"]);
expect(container.querySelector('[data-needs-you-section="decisions"]')).toBeNull();
await act(async () => { group.value = "none"; group.dispatchEvent(new Event("change", { bubbles: true })); });
expect(container.querySelector("[data-needs-you-section]")).toBeNull();
expect(container.querySelectorAll("article")).toHaveLength(3);
// Search narrows the cards.
const search = toolbar.querySelector<HTMLInputElement>('input[placeholder="Search…"]')!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(search, "Task 2");
search.dispatchEvent(new Event("input", { bubbles: true }));
});
expect([...container.querySelectorAll("[data-attention-card]")].map((card) => card.getAttribute("data-attention-card"))).toEqual(["2"]);
client.clear();
});
it("resizes and gathers light in place before moving the saved receipt into Recent Decisions", async () => {
it("resizes and gathers light in place, then the receipt leaves for Decision history", async () => {
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
const animate = vi.fn().mockReturnValue({ cancel: vi.fn() });
HTMLElement.prototype.animate = animate;
@@ -179,8 +365,9 @@ describe("Coordinator Needs You", () => {
expect(animate.mock.calls[0]?.[1]).toMatchObject({ duration: 480 });
await act(async () => { await vi.advanceTimersByTimeAsync(1400); });
expect(container.querySelector("[data-motion]")).toBeNull();
expect(container.querySelector('[aria-label="Recent Decisions"]')?.textContent).toContain("PAP-1");
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(1);
// IA3: the record now lives in Decision history only.
expect(container.querySelector('[aria-label="Recent Decisions"]')).toBeNull();
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(0);
client.clear();
});
it("renders a pending canonical question from persistent chat with its original resolver and context", async () => {
@@ -210,14 +397,18 @@ describe("Coordinator Needs You", () => {
expect(container.textContent).toContain("Choose how this task continues");
expect(container.textContent).not.toContain("Open to review");
expect(container.querySelectorAll("article")).toHaveLength(1);
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(1);
// IA7: a stuck task is a problem card, listed under Problems (PAP-775).
expect(container.querySelector("article")?.getAttribute("data-card-kind")).toBe("problem");
expect(container.querySelector("article")?.getAttribute("data-card-type")).toBe("blocked_task");
expect(container.querySelector("[data-attention-type-label]")?.textContent).toBe("Unblock a task");
expect(container.querySelector("[data-needs-you-summary]")?.textContent).toBe("1 problem");
expect(container.querySelector('[data-needs-you-section="decisions"]')).toBeNull();
expect(container.querySelector("[data-review-policy]")?.getAttribute("data-review-policy")).toBe("human_only");
await click("Mark task done");
expect(container.querySelectorAll("article")).toHaveLength(0);
expect(container.textContent).toContain("You're caught up.");
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(1);
});
it("retains all three decisions as receipts, including an answered question, and opens history only on demand", async () => {
it("turns all three answers into receipts, then keeps them in Decision history, opened only on demand", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
await flush();
@@ -226,19 +417,532 @@ describe("Coordinator Needs You", () => {
await click("Resolve 1"); await click("Resolve 2"); await click("Resolve 3");
await flush();
expect(container.querySelectorAll("article")).toHaveLength(0);
expect(container.textContent).toContain("Recent Decisions");
expect(container.querySelectorAll(".coordinator-decision-receipt")).toHaveLength(3);
expect(container.textContent).toContain("Answers recorded");
await click("PAP-3");
expect(fixture.navigate).toHaveBeenCalledWith("/PAP/issues/PAP-3#interaction-3");
await click("Decision history");
expect(fixture.openPanel).toHaveBeenCalled();
expect(fixture.setPanelVisible).toHaveBeenCalledWith(true);
// The shared desktop hide control changes visibility without closing
// this tab. Clicking history again must reveal the already-open tab.
fixture.setPanelVisible(false);
fixture.setPanelVisible.mockClear();
await click("Decision history");
expect(fixture.setPanelVisible).toHaveBeenCalledWith(true);
expect(container.textContent).not.toContain("Recent Decisions");
// Decision history opens in the main pane, not the side panel (PAP-770).
await openHistory();
const history = container.querySelector<HTMLElement>("[data-coordinator-decision-history]");
expect(history).not.toBeNull();
expect(history!.textContent).toContain("Answers recorded");
expect(fixture.openPanel).not.toHaveBeenCalled();
});
});
describe("Coordinator Needs You views (PAP-749 task 1b)", () => {
const mine = (id: string, reason: string) => ({ ...fixture.pending[0], id, subject: { id, kind: "interaction", title: `Mine ${id}`, metadata: { issueId: `task-${id}` } },
audience: { kind: "user", userId: "reviewer", reason } }) as unknown as AttentionItem;
const pool = { id: "pool", sourceKind: "issue_thread_interaction", inlineResolvable: true, subject: { id: "pool-interaction", kind: "interaction", title: "Pool", metadata: { issueId: "task-pool" } },
relatedIssue: { id: "task-pool", identifier: "PAP-9", title: "Pool task", href: "/PAP/issues/PAP-9" }, activityAt: new Date().toISOString(),
audience: { kind: "pool", userId: null, reason: "none" } } as unknown as AttentionItem;
const company = { id: "alert", sourceKind: "failed_run", inlineResolvable: false, subject: { id: "run-1", kind: "run", title: "Nightly sync failed", href: "/PAP/runs/1" }, relatedIssue: null,
activityAt: new Date().toISOString(), audience: { kind: "user", userId: "dotta", reason: "escalation_owner" } } as unknown as AttentionItem;
async function render(client: QueryClient) {
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[fixture.url]}><CoordinatorNeedsYou /><Location /></MemoryRouter></QueryClientProvider>));
await flush(); await flush();
}
const tab = (view: string) => container.querySelector<HTMLElement>(`[data-attention-view="${view}"]`)!;
it("defaults to Assigned to you with Why me?, no banner and per-person keys, then claims from No owner yet", async () => {
fixture.byView = { mine: [mine("m1", "addressee"), mine("m2", "reviewer")], unassigned: [pool], company: [company] };
fixture.viewCounts = { mine: 2, unassigned: 1, company: 1 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
expect(fixture.listCalls).toContainEqual({ all: true, view: "mine" });
expect(fixture.listCalls.some((call) => (call as { view?: string }).view !== "mine")).toBe(false);
// PAP-775: For you is the page (no tab, no chips); the toolbar's assignee
// filter says "Assigned to you" and widens to the other views.
expect(tab("mine")).toBeNull();
expect(tab("unassigned")).toBeNull();
const assignee = () => container.querySelector<HTMLSelectElement>('select[aria-label="Filter decisions by assignee"]')!;
expect(assignee().value).toBe("me");
expect(assignee().selectedOptions[0]?.textContent).toBe("Assigned to you");
// Why me? is the type label's tooltip, not text on the card; your own card doesn't name you.
expect([...container.querySelectorAll<HTMLElement>("[data-attention-type-label]")].map((label) => [label.textContent, label.title]))
.toEqual([["Confirm or decline", "Why me? Sent to you"], ["Confirm or decline", "Why me? You are the reviewer"]]);
expect(container.textContent).not.toContain("Why me?");
expect(container.querySelector("[data-attention-for-user]")).toBeNull();
expect(container.querySelector("[data-attention-unassigned-banner]")).toBeNull();
expect(client.getQueryCache().find({ queryKey: ["attention", "company", "reviewer", "mine"], exact: false })).toBeTruthy();
await act(async () => { assignee().value = "unassigned"; assignee().dispatchEvent(new Event("change", { bubbles: true })); }); await flush();
expect(container.querySelector("[data-location]")?.textContent).toBe("?view=unassigned");
expect(assignee().value).toBe("unassigned");
expect(fixture.listCalls).toContainEqual({ all: true, view: "unassigned" });
expect(client.getQueryCache().find({ queryKey: ["attention", "company", "reviewer", "unassigned"], exact: false })).toBeTruthy();
expect(container.querySelector("[data-attention-unassigned-banner]")).toBeNull();
await vi.waitFor(() => expect(container.querySelector('[data-attention-audience="pool"]')).not.toBeNull());
await click("Claim");
expect(fixture.claim).toHaveBeenCalledWith("company", "issue_thread_interaction", "pool-interaction");
client.clear();
});
it("opens Company alerts from the URL and names another person's card with @name before its type", async () => {
fixture.url = "/decisions?view=company";
fixture.byView = { company: [company] };
fixture.viewCounts = { mine: 0, unassigned: 0, company: 1 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const assignee = container.querySelector<HTMLSelectElement>('select[aria-label="Filter decisions by assignee"]')!;
expect(assignee.value).toBe("company");
expect(assignee.selectedOptions[0]?.textContent).toBe("Company alerts");
expect(fixture.listCalls).toContainEqual({ all: true, view: "company" });
await flush();
// A1 (PAP-775): the type label starts with the person the card is for.
const label = container.querySelector<HTMLElement>("[data-attention-type-label]")!;
expect(label.textContent).toBe("@Dotta Fix a failed run");
expect(label.querySelector("[data-attention-for-user]")?.getAttribute("data-attention-for-user")).toBe("dotta");
expect(container.querySelector("article")?.textContent).not.toContain("Company alert");
expect(container.querySelector('[data-attention-view-count="mine"]')).toBeNull();
client.clear();
});
it("puts stale cards in a collapsed Stale (N) group at the bottom and withdraws them after a confirmation (PAP-749 task 2c)", async () => {
const stale = (id: string, reason: string, extra: Record<string, unknown> = {}) => ({ ...mine(id, "addressee"), stale: { reason }, closeModes: ["withdraw", "archive", "snooze"], ...extra }) as unknown as AttentionItem;
fixture.byView = { mine: [mine("fresh", "addressee"), stale("old", "aged"), stale("done", "issue_closed"),
stale("run", "aged", { sourceKind: "failed_run", inlineResolvable: false, closeModes: ["archive", "snooze"], subject: { id: "run-9", kind: "run", title: "Old run", href: "/PAP/runs/9" } })] };
fixture.viewCounts = { mine: 4, unassigned: 0, company: 0 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const group = container.querySelector<HTMLElement>("[data-attention-stale-group]")!;
expect(group.textContent).toContain("Stale (3)");
// Collapsed: only the current card is mounted, and the group sits after it.
expect(container.querySelectorAll("article")).toHaveLength(1);
expect(container.querySelector("article")!.compareDocumentPosition(group) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
await act(async () => group.querySelector<HTMLElement>("[aria-expanded]")!.click()); await flush();
expect(container.querySelectorAll("article")).toHaveLength(4);
expect(group.querySelector('[data-attention-stale="aged"]')?.textContent).toBe("Stale · Waiting 30+ days");
expect(group.querySelector('[data-attention-stale="issue_closed"]')?.textContent).toBe("Stale · Task closed");
await click("Withdraw all stale");
const dialog = document.querySelector<HTMLElement>("[data-attention-stale-withdraw-dialog]")!;
expect(dialog.textContent).toContain("Withdraw 2 stale requests?");
expect(fixture.close).not.toHaveBeenCalled();
await act(async () => [...dialog.querySelectorAll("button")].find((button) => button.textContent === "Withdraw 2 requests")!.click());
await flush(); await flush();
expect(fixture.close.mock.calls.map((call) => [call[2], (call[3] as { mode: string }).mode])).toEqual([["old", "withdraw"], ["done", "withdraw"]]);
// Withdrawn cards leave at once; the failed run has nothing to withdraw and stays.
expect(group.textContent).toContain("Stale (1)");
client.clear();
});
it("indexes company agents once when many task cards need assignee names", async () => {
let reads = 0;
agentFixture.rows = Array.from({ length: 1000 }, (_, index) => ({
get id() { if (++reads > 15_000) throw new Error("Repeated company roster scan"); return `agent-${index}`; },
name: `Agent ${index}`,
})) as Agent[];
fixture.byView = { mine: Array.from({ length: 80 }, (_, index) => ({
...mine(`review-${index}`, "assignee"), sourceKind: "review", inlineResolvable: false,
subject: { id: `task-${index}`, companyId: "company", identifier: `PAP-${index}`, kind: "issue", title: `Review ${index}`, status: "in_review", href: `/PAP/issues/PAP-${index}`,
metadata: { assigneeAgentId: "agent-999" } }, relatedIssue: null,
}) as AttentionItem) };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
expect(container.querySelectorAll('[data-attention-card]')).toHaveLength(40);
expect(container.textContent?.match(/Assigned to Agent 999/g)).toHaveLength(40);
expect(reads).toBeLessThan(15_000);
client.clear();
});
it("archives a card for me from its Close… menu with the version I saw", async () => {
fixture.byView = { mine: [{ ...mine("m1", "addressee"), closeModes: ["withdraw", "archive", "snooze"], changeFingerprint: "fp-1.me" } as unknown as AttentionItem, mine("m2", "addressee")] };
fixture.close.mockImplementationOnce(async () => { fixture.byView.mine = [mine("m2", "addressee")]; return { until: null }; });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const menu = container.querySelector<HTMLElement>("[data-attention-close-menu]")!;
await act(async () => {
menu.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
menu.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => document.querySelector<HTMLElement>('[data-attention-close-mode="archive"]')!.click()); await flush(); await flush();
expect(fixture.close).toHaveBeenCalledWith("company", "issue_thread_interaction", "m1", { mode: "archive", reason: undefined, until: undefined, fingerprint: "fp-1.me" });
expect(container.querySelector('[data-attention-card="m1"]')).toBeNull();
expect(container.querySelector('[data-attention-card="m2"]')).not.toBeNull();
// The server can return the same request after a snooze or content change.
fixture.byView.mine = [{ ...mine("m1", "addressee"), changeFingerprint: "fp-2.me" } as AttentionItem, mine("m2", "addressee")];
await act(async () => { await client.invalidateQueries({ queryKey: ["attention", "company"] }); });
await flush();
expect(container.querySelector('[data-attention-card="m1"]')).not.toBeNull();
client.clear();
});
it("retries a failed run inline instead of only linking to it (Rule 7)", async () => {
fixture.url = "/decisions?view=company";
fixture.byView = { company: [{ id: "failed", sourceKind: "failed_run", inlineResolvable: true, decisionVerbs: [{ id: "retry", label: "Retry" }, { id: "inspect", label: "Open run" }],
subject: { id: "run-7", kind: "run", title: "Nightly sync failed", href: "/PAP/agents/a/runs/run-7", metadata: { issueId: "task-7" } },
relatedIssue: { id: "task-7", identifier: "PAP-7", title: "Nightly sync", href: "/PAP/issues/PAP-7" }, closeModes: ["archive", "snooze"],
activityAt: new Date().toISOString(), audience: { kind: "company", userId: null, reason: "none" } } as unknown as AttentionItem] };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
expect(container.textContent).not.toContain("Open to review");
// PAP-775: View run, then the primary Retry run last.
const card = container.querySelector<HTMLElement>('[data-attention-card="failed"]')!;
expect([...card.querySelectorAll("a, button")].map((entry) => entry.textContent).slice(-2)).toEqual(["View run", "Retry run"]);
await click("Retry run");
expect(fixture.retryTask).toHaveBeenCalledWith("task-7", { idempotencyKey: expect.any(String), observedRunId: "run-7" });
client.clear();
});
it("explains when retry does not start new work", async () => {
fixture.url = "/decisions?view=company";
fixture.retryTask.mockResolvedValueOnce({ accepted: false, requestId: "stale", stale: true, presentation: null });
fixture.byView = { company: [{ id: "stale-run", sourceKind: "failed_run", inlineResolvable: true,
subject: { id: "run-old", kind: "run", title: "Previous attempt", metadata: { issueId: "task-old" } },
relatedIssue: { id: "task-old", identifier: "PAP-7", href: "/PAP/issues/PAP-7" }, activityAt: new Date().toISOString(),
audience: { kind: "company", userId: null, reason: "none" } } as unknown as AttentionItem] };
const client = await renderPage(fixture.url);
await click("Retry run");
expect(container.textContent).toContain("Retry did not start new work");
expect(container.textContent).not.toContain("A retry is already running");
client.clear();
});
it("shows what others did with your requests as Updates cards you can dismiss (PAP-749 plan §6, PAP-775)", async () => {
fixture.byView = { mine: [mine("m1", "addressee")] };
fixture.receipts = [{ id: "receipt-1", dismissalKey: "receipt:1", outcome: "approved", sourceKind: "approval", sourceId: "appr-9", title: "Hire a designer",
reason: null, actorUserId: "dotta", transferredToUserId: null, issue: null, href: "/PAP/approvals/appr-9",
createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() }];
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
await vi.waitFor(() => expect(container.querySelector('[data-attention-receipt="approved"]')).not.toBeNull());
expect(container.querySelector("[data-needs-you-summary]")?.textContent).toBe("1 decision · 1 update");
expect(container.querySelector('[data-needs-you-section="updates"]')?.textContent).toBe("Updates");
// No collapsed "N updates on your requests" line any more.
expect(container.textContent).not.toContain("updates on your requests");
const update = container.querySelector<HTMLElement>('[data-attention-receipt="approved"]')!;
expect(update.textContent).toContain("View a request update");
expect(update.querySelector("h2")?.textContent).toBe("Hire a designer");
expect(update.querySelector("[data-needs-you-panel]")?.textContent).toBe("Approved by Dotta");
// Updates are not in the badge and have no ⋯ menu.
expect(update.querySelector("[data-attention-close-menu]")).toBeNull();
expect([...update.querySelectorAll("button")].map((button) => button.textContent)).toEqual(["Dismiss", "View request"]);
await act(async () => [...update.querySelectorAll("button")].find((button) => button.textContent === "View request")!.click());
expect(fixture.navigate).toHaveBeenCalledWith("/PAP/approvals/appr-9");
await act(async () => [...update.querySelectorAll("button")].find((button) => button.textContent === "Dismiss")!.click()); await flush();
expect(dismissals.dismiss).toHaveBeenCalledWith("company", "receipt:1");
client.clear();
});
it("shows one card for each obligation, with the rest folded in as evidence (PAP-749 task 2d)", async () => {
const now = new Date().toISOString();
const forMe = { kind: "user", userId: "reviewer", reason: "instigator" };
const run = { id: "failed", sourceKind: "failed_run", inlineResolvable: true, decisionVerbs: [{ id: "retry", label: "Retry" }, { id: "inspect", label: "Open run" }],
subject: { id: "run-7", kind: "run", title: "Flaky Runner run failed", href: "/PAP/agents/a/runs/run-7", metadata: { issueId: "task-7" } },
relatedIssue: { id: "task-7", identifier: "PAP-7", title: "Nightly sync", href: "/PAP/issues/PAP-7" }, closeModes: ["archive", "snooze"],
obligationKey: "blocked:task-7", obligationLeadId: "failed", activityAt: now, audience: forMe } as unknown as AttentionItem;
const blocked = { id: "blocked", sourceKind: "blocker_attention", inlineResolvable: false, whyNow: "Automatic retries exhausted. Retry or reassign the task.",
subject: { id: "task-7", kind: "issue", identifier: "PAP-7", title: "Nightly sync", href: "/PAP/issues/PAP-7" }, relatedIssue: null, closeModes: ["archive", "snooze"],
obligationKey: "blocked:task-7", obligationLeadId: "failed", activityAt: now, audience: forMe } as unknown as AttentionItem;
const approval = { id: "approval", sourceKind: "approval", inlineResolvable: false, whyNow: "Approval is pending a board decision.",
subject: { id: "appr-1", kind: "approval", title: "Approve the launch", href: "/PAP/approvals/appr-1", metadata: { issueId: "task-8" } }, relatedIssue: null,
closeModes: ["withdraw", "archive", "snooze"], obligationKey: "approval:appr-1", obligationLeadId: "approval", activityAt: now, audience: forMe } as unknown as AttentionItem;
const review = { id: "review", sourceKind: "review", inlineResolvable: false, whyNow: "Issue is in review with a linked pending approval.",
subject: { id: "task-8", kind: "issue", identifier: "PAP-8", title: "Launch page", href: "/PAP/issues/PAP-8" }, relatedIssue: null, closeModes: ["archive", "snooze"],
obligationKey: "approval:appr-1", obligationLeadId: "approval", activityAt: now, audience: forMe } as unknown as AttentionItem;
fixture.byView = { mine: [run, blocked, approval, review] };
fixture.viewCounts = { mine: 2, unassigned: 0, company: 0 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
// Two obligations, two cards; the evidence cards are never cards of their own.
// By type, the approval's decision comes before the run's problem (PAP-775).
expect([...container.querySelectorAll("[data-attention-card]")].map((card) => card.getAttribute("data-attention-card"))).toEqual(["approval", "failed"]);
expect([...container.querySelectorAll("[data-needs-you-section]")].map((section) => section.getAttribute("data-needs-you-section"))).toEqual(["problems"]);
const runCard = container.querySelector<HTMLElement>('[data-attention-card="failed"]')!;
expect(runCard.querySelector('[data-attention-evidence="blocked"]')?.textContent)
.toBe("Blocked by this run: PAP-7 Nightly sync — Automatic retries exhausted. Retry or reassign the task.");
const approvalCard = container.querySelector<HTMLElement>('[data-attention-card="approval"]')!;
expect(approvalCard.querySelector('[data-attention-evidence="review"]')?.textContent)
.toBe("Task waiting on this approval: PAP-8 Launch page — Issue is in review with a linked pending approval.");
expect(approvalCard.querySelector('[data-attention-evidence="review"] a')?.getAttribute("href")).toBe("/PAP/issues/PAP-8");
for (const card of [runCard, approvalCard]) {
const evidence = card.querySelector("[data-attention-evidence]")!;
const footer = card.querySelector("[data-decision-footer]")!;
expect(evidence.compareDocumentPosition(footer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}
// The run keeps the one action for the blocked work.
await click("Retry");
expect(fixture.retryTask).toHaveBeenCalledWith("task-7", { idempotencyKey: expect.any(String), observedRunId: "run-7" });
client.clear();
});
const boardMember = (membershipRole: string) => ({ userId: "reviewer", source: "session", isInstanceAdmin: false, companyIds: ["company"],
memberships: [{ companyId: "company", membershipRole, status: "active" }] });
it("opens Everyone for a board member, names another person's card and asks before withdrawing it (PAP-768)", async () => {
fixture.boardAccess = boardMember("admin");
fixture.url = "/decisions?view=all";
const dottas = { ...mine("d1", "instigator"), audience: { kind: "user", userId: "dotta", reason: "instigator" }, closeModes: ["withdraw", "archive", "snooze"],
subject: { id: "d1", kind: "interaction", title: "Dotta's confirmation", metadata: { issueId: "task-d1" } } } as unknown as AttentionItem;
fixture.byView = { all: [mine("m1", "addressee"), dottas] };
fixture.viewCounts = { mine: 1, unassigned: 0, company: 0, archived: 0, all: 2 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
// PAP-775: Everyone is "All assignees" in the toolbar's assignee filter, and
// "Assigned to you" returns to the page.
const assignee = () => container.querySelector<HTMLSelectElement>('select[aria-label="Filter decisions by assignee"]')!;
await vi.waitFor(() => expect(assignee().value).toBe("all"));
expect(assignee().selectedOptions[0]?.textContent).toBe("All assignees");
expect(container.querySelector("[data-needs-you-summary]")?.textContent).toBe("2 decisions");
expect(fixture.listCalls).toContainEqual({ all: true, view: "all" });
expect(client.getQueryCache().find({ queryKey: ["attention", "company", "reviewer", "all"], exact: false })).toBeTruthy();
const card = container.querySelector<HTMLElement>('[data-attention-card="d1"]')!;
expect(card.querySelector("[data-attention-type-label]")?.textContent).toBe("@Dotta Confirm or decline");
expect(card.querySelector("[data-attention-for-user]")?.textContent).toBe("@Dotta ");
expect(container.querySelector('[data-attention-card="m1"] [data-attention-for-user]')).toBeNull();
const menu = card.querySelector<HTMLElement>("[data-attention-close-menu]")!;
await act(async () => {
menu.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
menu.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => document.querySelector<HTMLElement>('[data-attention-close-mode="withdraw"]')!.click()); await flush();
const dialog = document.querySelector<HTMLElement>("[data-attention-withdraw-dialog]")!;
expect(dialog.textContent).toContain("This is for @Dotta. Withdraw anyway?");
expect(fixture.close).not.toHaveBeenCalled();
await act(async () => [...dialog.querySelectorAll("button")].find((button) => button.textContent === "Withdraw request")!.click());
await flush(); await flush();
expect(fixture.close).toHaveBeenCalledWith("company", "issue_thread_interaction", "d1", expect.objectContaining({ mode: "withdraw" }));
expect(container.querySelector('[data-attention-card="d1"]')).toBeNull();
client.clear();
});
it("keeps Everyone from a viewer: no tab, and ?view=all opens For you (PAP-768)", async () => {
fixture.boardAccess = boardMember("viewer");
fixture.url = "/decisions?view=all";
fixture.byView = { mine: [mine("m1", "addressee")], all: [mine("m1", "addressee"), mine("m2", "addressee")] };
fixture.viewCounts = { mine: 1, unassigned: 0, company: 0, archived: 0, all: 2 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client); await flush();
// A viewer has no Everyone — ?view=all falls back to For you (the page), and
// the assignee filter offers no "All assignees" and no other people (PAP-775).
const assignee = container.querySelector<HTMLSelectElement>('select[aria-label="Filter decisions by assignee"]')!;
expect(assignee.value).toBe("me");
expect([...assignee.options].map((option) => option.textContent)).toEqual(["Assigned to you", "No owner yet", "Company alerts"]);
expect(container.querySelector('[data-attention-view="all"]')).toBeNull();
expect(fixture.listCalls.some((call) => (call as { view?: string }).view === "all")).toBe(false);
client.clear();
});
it("shows a budget hard stop and its override approval as one card with Increase limit and Keep paused (PAP-768)", async () => {
const now = new Date().toISOString();
const owner = { kind: "user", userId: "reviewer", reason: "budget_owner", alsoInCompany: true };
const stop = { id: "stop", sourceKind: "budget_alert", inlineResolvable: true, whyNow: "Budget hard stop was reached.",
decisionVerbs: [{ id: "raise_budget_and_resume", label: "Raise limit" }, { id: "keep_paused", label: "Keep paused" }],
subject: { id: "incident-1", kind: "budget_incident", title: "Spendy Agent budget hard stop", href: "/PAP/costs", metadata: { thresholdType: "hard", amountObserved: 10_000, amountLimit: 10_000, approvalId: "appr-b" } },
detail: { kind: "budget", observedPercent: 100, amountObserved: 10_000, amountLimit: 10_000, images: [] },
relatedIssue: null, closeModes: ["archive", "snooze"], obligationKey: "approval:appr-b", obligationLeadId: "stop", activityAt: now, audience: owner } as unknown as AttentionItem;
const approval = { id: "approval-b", sourceKind: "approval", inlineResolvable: true, whyNow: "Approval is pending a board decision.",
decisionVerbs: [{ id: "approve", label: "Approve" }, { id: "reject", label: "Reject" }],
subject: { id: "appr-b", kind: "approval", title: "budget override required", href: "/PAP/approvals/appr-b", metadata: { type: "budget_override_required" } }, relatedIssue: null,
closeModes: ["withdraw", "archive", "snooze"], obligationKey: "approval:appr-b", obligationLeadId: "stop", activityAt: now, audience: owner } as unknown as AttentionItem;
fixture.byView = { mine: [stop, approval] };
fixture.viewCounts = { mine: 1, unassigned: 0, company: 1, archived: 0, all: 1 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
expect([...container.querySelectorAll("[data-attention-card]")].map((card) => card.getAttribute("data-attention-card"))).toEqual(["stop"]);
const card = container.querySelector<HTMLElement>('[data-attention-card="stop"]')!;
expect(card.querySelector('[data-attention-evidence="approval-b"]')?.textContent).toBe("Approval this answers: budget override required — Approval is pending a board decision.");
expect(card.querySelector("[data-attention-type-label]")?.textContent).toBe("Resolve a budget stop");
const buttons = () => [...card.querySelectorAll("button")].map((button) => button.textContent);
// PAP-775: the new limit is asked for only once you choose to increase it; the primary action is last.
expect(buttons().slice(-2)).toEqual(["Keep paused", "Increase limit…"]);
expect(card.querySelector('input[inputmode="decimal"]')).toBeNull();
await click("Increase limit…");
expect(card.querySelector('input[inputmode="decimal"]')).not.toBeNull();
expect(buttons().slice(-3)).toEqual(["Keep paused", "Cancel", "Raise limit & resume"]);
// The approval's own Approve / Reject would skip the budget; they are not offered.
expect(buttons()).not.toContain("Approve");
expect(buttons()).not.toContain("Reject");
// Keep paused answers the stop, which rejects its approval on the server.
await click("Keep paused");
expect(fixture.resolveIncident).toHaveBeenCalledWith("company", "incident-1", { action: "keep_paused" });
client.clear();
});
it("shows a view-specific empty state", async () => {
fixture.url = "/decisions?view=unassigned";
fixture.byView = { unassigned: [] };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
expect(container.textContent).toContain("No cards need an owner.");
client.clear();
});
it("opens a plan card's plan as a side-panel tab and closes the tab again (PAP-770)", async () => {
fixture.pending = [{ ...fixture.pending[0], subject: { ...fixture.pending[0].subject, metadata: { issueId: "task-1", targetDocumentKey: "plan" } } }, fixture.pending[1]];
const client = await renderPage();
// PAP-775 (IA5): the plan opens from a row in the card's panel, not a footer button.
const row = (verb: "Open" | "Close") => container.querySelector<HTMLElement>(`button[aria-label="${verb} Plan in the sidebar"]`);
expect(container.querySelectorAll('button[aria-label$="in the sidebar"]')).toHaveLength(1);
expect(row("Open")?.textContent).toBe("PlanOpen in sidebar");
await act(async () => row("Open")!.click()); await flush();
expect(fixture.setPanelVisible).toHaveBeenLastCalledWith(true);
expect(row("Close")?.textContent).toBe("PlanOpen in sidebar · Showing");
const panel = fixture.openPanel.mock.calls.at(-1)?.[0] as { props: { tabs: Array<{ id: string; label: string }>; activeTabId: string; reviewable: AttentionItem[] } };
expect(panel.props.tabs).toEqual([expect.objectContaining({ id: `review:${fixture.pending[0].id}` })]);
expect(panel.props.activeTabId).toBe(`review:${fixture.pending[0].id}`);
// The + launcher offers every card with a document to review.
expect(panel.props.reviewable.map(item => item.id)).toEqual([fixture.pending[0].id]);
expect(container.querySelector("[data-reviewing]")).toBeTruthy();
await act(async () => row("Close")!.click()); await flush();
expect(row("Open")).not.toBeNull();
const after = fixture.openPanel.mock.calls.at(-1)?.[0] as { props: { tabs: unknown[] } };
expect(after.props.tabs).toEqual([]);
expect(container.querySelector("[data-reviewing]")).toBeNull();
client.clear();
});
it("shows who is asking, why it is yours, and the task's status before its ID (UI2, UI3, UI5, A3)", async () => {
fixture.byView = { mine: [{ ...mine("m1", "addressee"), requester: { kind: "agent", id: "agent-cos", name: "Chief of Staff" },
relatedIssue: { id: "task-m1", identifier: "PAP-21", title: "Launch", status: "in_progress", href: "/PAP/issues/PAP-21" } } as unknown as AttentionItem,
{ ...mine("m2", "assignee"), requester: { kind: "user", id: "dotta", name: null } } as unknown as AttentionItem] };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const agentCard = container.querySelector<HTMLElement>('[data-attention-card="m1"]')!;
expect(agentCard.querySelector('[data-attention-requester="agent"]')?.textContent).toBe("from Chief of Staff");
// Your own card says why in the type label's tooltip, never your own name (PAP-775).
const label = agentCard.querySelector<HTMLElement>("[data-attention-type-label]")!;
expect(label.textContent).toBe("Confirm or decline");
expect(label.title).toBe("Why me? Sent to you");
expect(agentCard.querySelector('[data-attention-audience]')).toBeNull();
expect(agentCard.querySelector("[data-attention-for-user]")).toBeNull();
// The footer's task link: status icon, then "ID · title", then ↗.
const link = agentCard.querySelector<HTMLElement>("[data-attention-task-link]")!;
expect(link.querySelector('[title="In Progress"], [aria-label="In Progress"]')).not.toBeNull();
expect(link.textContent).toMatch(/^In Progress.*PAP-21 · Launch$/);
const personCard = container.querySelector<HTMLElement>('[data-attention-card="m2"]')!;
expect(personCard.querySelector('[data-attention-requester="user"]')?.textContent).toContain("from Dotta");
client.clear();
});
it("omits the requester avatar and line when the viewer made the request", async () => {
fixture.byView = { mine: [{ ...mine("own", "addressee"), requester: { kind: "user", id: "reviewer", name: "Dotta" } } as unknown as AttentionItem] };
const client = await renderPage();
expect(container.querySelector('[data-attention-requester="user"]')).toBeNull();
client.clear();
});
it("says No owner yet with Claim on an ownerless card (A2)", async () => {
fixture.url = "/decisions?view=unassigned";
fixture.byView = { unassigned: [pool] };
fixture.viewCounts = { mine: 0, unassigned: 1, company: 0 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const corner = container.querySelector<HTMLElement>('[data-attention-audience="pool"]')!;
expect(corner.textContent).toBe("No owner yetClaim");
client.clear();
});
it("puts problems under a Problems heading apart from decisions, in words and not colour alone (IA7, UI6)", async () => {
fixture.byView = { mine: [{ ...company, id: "run", inlineResolvable: false, audience: { kind: "user", userId: "reviewer", reason: "assignee" } } as unknown as AttentionItem, mine("decision", "addressee")] };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const kinds = Object.fromEntries([...container.querySelectorAll("[data-attention-card]")].map((card) => [card.getAttribute("data-attention-card"), [card.getAttribute("data-card-kind"), card.getAttribute("data-card-type")]]));
expect(kinds).toEqual({ decision: ["decision", "confirm"], run: ["problem", "failed_run"] });
// Counts live in one line; the later Problems group keeps its own label.
const sections = [...container.querySelectorAll<HTMLElement>("[data-needs-you-section]")];
expect(sections.map((section) => section.textContent)).toEqual(["Problems"]);
expect(container.querySelector("[data-needs-you-summary]")?.textContent).toBe("1 decision · 1 problem");
// In document order, each card sits under its section's heading.
expect([...container.querySelectorAll("[data-needs-you-section], [data-attention-card]")].map((node) => node.getAttribute("data-needs-you-section") ?? node.getAttribute("data-attention-card")))
.toEqual(["decision", "problems", "run"]);
// The heading is the signal; the card carries no "Problem" label of its own.
expect(container.querySelector("[data-attention-problem]")).toBeNull();
expect(container.querySelector('[data-attention-card="run"] [data-attention-type-label]')?.textContent).toBe("Fix a failed run");
client.clear();
});
it("filters cards by who they are for from the toolbar (PAP-775)", async () => {
fixture.boardAccess = boardMember("admin");
fixture.url = "/decisions?view=all";
const dottas = { ...mine("d1", "instigator"), audience: { kind: "user", userId: "dotta", reason: "instigator" },
subject: { id: "d1", kind: "interaction", title: "Dotta's confirmation", metadata: { issueId: "task-d1" } } } as unknown as AttentionItem;
fixture.byView = { all: [mine("m1", "addressee"), dottas, pool] };
fixture.viewCounts = { mine: 1, unassigned: 1, company: 0, archived: 0, all: 3 };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const assignee = () => container.querySelector<HTMLSelectElement>('select[aria-label="Filter decisions by assignee"]')!;
const choose = async (value: string) => { await act(async () => { assignee().value = value; assignee().dispatchEvent(new Event("change", { bubbles: true })); }); await flush(); };
const cards = () => [...container.querySelectorAll("[data-attention-card]")].map(card => card.getAttribute("data-attention-card"));
await vi.waitFor(() => expect(assignee().value).toBe("all"));
// The viewer is "Assigned to you", never a person option of their own.
expect([...assignee().options].map(option => [option.value, option.textContent])).toEqual([
["me", "Assigned to you"], ["all", "All assignees"], ["user:dotta", "Dotta"], ["unassigned", "No owner yet"], ["company", "Company alerts"]]);
expect(cards()).toEqual(["m1", "d1", "pool"]);
// One person narrows Everyone, and the person lives in the URL.
await choose("user:dotta");
expect(container.querySelector("[data-location]")?.textContent).toBe("?view=all&for=dotta");
expect(assignee().value).toBe("user:dotta");
expect(cards()).toEqual(["d1"]);
// Back to everyone drops the person.
await choose("all");
expect(container.querySelector("[data-location]")?.textContent).toBe("?view=all");
expect(cards()).toEqual(["m1", "d1", "pool"]);
// "Assigned to you" is the page itself.
await choose("me");
expect(container.querySelector("[data-location]")?.textContent).toBe("");
client.clear();
});
it("defaults the assignee filter to Assigned to you in For you, where every card is yours (PAP-775)", async () => {
fixture.byView = { mine: [mine("m1", "addressee"), mine("m2", "assignee")] };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
await vi.waitFor(() => expect(container.querySelector('[data-attention-card="m2"]')).toBeTruthy());
const assignee = container.querySelector<HTMLSelectElement>('select[aria-label="Filter decisions by assignee"]')!;
expect(assignee.value).toBe("me");
expect(assignee.getAttribute("data-attention-assignee")).toBe("me");
// The old "For anyone" filter is gone.
expect(container.querySelector("[data-attention-audience-filter]")).toBeNull();
expect(container.querySelector("[data-needs-you-summary]")?.textContent).toBe("2 decisions");
client.clear();
});
it("opens a document a card links to as its own side-panel tab, tied to the card (PAP-775, IA5)", async () => {
const approval = { id: "approval", sourceKind: "approval", inlineResolvable: true, whyNow: "Approval is pending a board decision.",
subject: { id: "appr-1", kind: "approval", title: "Hire a designer", href: "/PAP/approvals/appr-1", metadata: {} }, relatedIssue: null,
closeModes: ["withdraw", "archive", "snooze"], obligationKey: null, obligationLeadId: null, activityAt: new Date().toISOString(),
audience: { kind: "user", userId: "reviewer", reason: "requester" } } as unknown as AttentionItem;
fixture.byView = { mine: [approval] };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const card = container.querySelector<HTMLElement>('[data-attention-card="approval"]')!;
const link = document.createElement("a");
link.href = "/PAP/issues/PAP-12#document-design";
link.textContent = "design notes";
card.querySelector("h2")!.after(link);
await act(async () => link.click()); await flush();
const panel = fixture.openPanel.mock.calls.at(-1)?.[0] as { props: { tabs: Array<{ id: string; type: string; payload: { itemId: string; artifact?: unknown } }>; activeTabId: string } };
expect(panel.props.tabs).toEqual([expect.objectContaining({ id: "review:approval:document:PAP-12:design", type: "design notes",
payload: { kind: "review", itemId: "approval", artifact: { kind: "document", issueId: "PAP-12", key: "design", title: "design notes" } } })]);
expect(panel.props.activeTabId).toBe("review:approval:document:PAP-12:design");
// The card is highlighted while something it mentions is open (UI9).
expect(card.getAttribute("data-reviewing")).toBe("true");
client.clear();
});
it("approves an approval on the card and opens it in the side panel (UX1, IA5)", async () => {
const approval = { id: "approval", sourceKind: "approval", inlineResolvable: true, whyNow: "Approval is pending a board decision.",
decisionVerbs: [{ id: "approve", label: "Approve" }, { id: "reject", label: "Reject" }],
subject: { id: "appr-1", kind: "approval", title: "Hire a designer", href: "/PAP/approvals/appr-1", metadata: {} }, relatedIssue: null,
closeModes: ["withdraw", "archive", "snooze"], obligationKey: null, obligationLeadId: null, activityAt: new Date().toISOString(),
audience: { kind: "user", userId: "reviewer", reason: "requester" } } as unknown as AttentionItem;
fixture.byView = { mine: [approval] };
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await render(client);
const card = container.querySelector<HTMLElement>('[data-attention-card="approval"]')!;
expect(card.querySelector("h2")?.textContent).toBe("Approve “Hire a designer”?");
expect(card.textContent).not.toContain("Open to review");
expect(card.querySelector("[data-attention-type-label]")?.textContent).toBe("Approve a request");
// PAP-775: View details · Reject · Approve, primary last; the note and Request
// revision stay in the side panel.
const footer = [...card.querySelectorAll("button")].map((button) => button.textContent).filter((text) => ["View details", "Reject", "Approve"].includes(text ?? ""));
expect(footer).toEqual(["View details", "Reject", "Approve"]);
expect(card.querySelector("textarea")).toBeNull();
expect(card.textContent).not.toContain("Request revision");
await act(async () => [...card.querySelectorAll("button")].find((button) => button.textContent === "View details")!.click()); await flush();
const panel = fixture.openPanel.mock.calls.at(-1)?.[0] as { props: { tabs: Array<{ id: string; type: string }> } };
expect(panel.props.tabs).toEqual([expect.objectContaining({ id: "review:approval", type: "Approval" })]);
await act(async () => [...card.querySelectorAll("button")].find((button) => button.textContent === "Approve")!.click()); await flush();
expect(approvals.approve).toHaveBeenCalledWith("appr-1", undefined);
client.clear();
});
});
@@ -1,10 +1,10 @@
import { useQueryView } from "@/components/QueryView";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { ArrowUpRight, History, Loader2 } from "lucide-react";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { useCallback, useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, ArrowUpRight, ChevronDown, FileText, Image as ImageIcon, Loader2 } from "lucide-react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import type { Agent, AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
import { attentionCardType, type AttentionCloseMode, type AttentionItem, type AttentionPersonalView } from "@paperclipai/shared";
import { accessApi } from "@/api/access";
import { attentionApi } from "@/api/attention";
import { agentsApi } from "@/api/agents";
import { useCompany } from "@/context/CompanyContext";
@@ -12,175 +12,253 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { usePanel } from "@/context/PanelContext";
import { useSidebar } from "@/context/SidebarContext";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { useAttentionBadge, useAttentionCloseActions, useAttentionEveryoneView, useAttentionViewFeed, useClaimAttentionItem, useDismissAttentionReceipt } from "@/hooks/useAttentionViews";
import { useNavigate, useSearchParams } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { coordinatorAttentionQueryKey, COORDINATOR_RECENT_DECISION_MS, recentDecisionReceipts } from "@/lib/coordinator-attention";
import { COORDINATOR_ASSIGNEE_PARAM, COORDINATOR_RECENT_DECISION_MS, DEFAULT_DECISION_HISTORY_SCOPE, coordinatorAssignee, coordinatorAssigneeTarget, coordinatorAudienceKey, coordinatorCardDetail, coordinatorCardTitle, recentDecisionReceipts, type CoordinatorAssignee } from "@/lib/coordinator-attention";
import { attentionDetailLine, isInlineResolvable } from "@/lib/attention";
import { ATTENTION_VIEW_PARAM, attentionAudienceLineNamesResponders, parseAttentionView } from "@/lib/attention-views";
import { splitArchivedAttentionItems, splitStaleAttentionItems } from "@/lib/attention-close";
import { attentionObligationEvidence, foldAttentionObligations } from "@/lib/attention-obligations";
import { buildCompanyUserLabelMap } from "@/lib/company-members";
import { AttentionCloseMenu, AttentionForOtherGate, AttentionHiddenChip, AttentionSnoozedGroup, AttentionStaleChip, AttentionStaleGroup } from "@/components/AttentionCloseMenu";
import { BudgetAlertActions, FailedRunActions } from "@/components/AttentionInlineVerbs";
import { AttentionInteractionResolver } from "@/components/AttentionInteractionResolver";
import { StalledReviewActions } from "@/components/StalledReviewActions";
import { ApprovalResolver, JoinRequestResolver } from "@/components/AttentionQueueRow";
import { AttentionObligationEvidence } from "@/components/AttentionObligationEvidence";
import { DecisionHistoryPanel } from "@/components/DecisionHistoryPanel";
import { DecisionResolver } from "@/components/DecisionResolver";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "@/components/PropertiesPanel";
import { SidePanelTab } from "@/components/side-panel";
import { DecisionCardFooter } from "@/components/DecisionCardFooter";
import { useSidePanelTabs } from "@/components/side-panel";
import { ToolbarSearch } from "@/components/ToolbarSearch";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { NativeSelect } from "@/components/ui/select";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { parseCssTimeMs } from "@/components/task-chat/motion-tokens";
import { readIssueReviewPolicyMetadata } from "@/lib/review-policy";
import { timeAgo } from "@/lib/timeAgo";
import { CoordinatorDecisionReceipt as Receipt } from "./CoordinatorDecisionReceipt";
import { CoordinatorTaskMotion } from "./CoordinatorTaskCommitment";
import { companyProjectsQuery } from "@/lib/company-projects-query";
import { CoordinatorReviewPanel, needsYouArtifact, needsYouArtifactLabel, needsYouMentionedArtifact, type NeedsYouArtifact } from "./CoordinatorReviewPanel";
import { BlockedTaskCardBody, RecoveryCardBody, ReviewTaskCardBody } from "./NeedsYouCardBodies";
import { NEEDS_YOU_SECTIONS, NeedsYouPanelRow, needsYouSection, type NeedsYouSection } from "./NeedsYouCardKit";
import { NeedsYouAudience, NeedsYouReceiptCard, NeedsYouRequester, NeedsYouTaskLink, NeedsYouTypeLabel, needsYouTaskRef } from "./NeedsYouCardParts";
import { CoordinatorNeedsYouSidePanel, needsYouReviewTab, needsYouReviewTabId, needsYouTabArtifact, type NeedsYouReviewPayload } from "./CoordinatorNeedsYouSidePanel";
type Group = "none" | "task" | "agent" | "project";
type Group = "type" | "none" | "task" | "agent" | "project";
function HistoryPanel({ companyId, agents, onClose, mobile = false }: { companyId: string; agents: Agent[]; onClose: () => void; mobile?: boolean }) {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [outcome, setOutcome] = useState("");
const [agentId, setAgentId] = useState("");
const [projectId, setProjectId] = useState("");
const [groupBy, setGroupBy] = useState<Group>("none");
const [sort, setSort] = useState<"newest" | "oldest">("newest");
const projects = useQuery({ ...companyProjectsQuery(companyId) });
const history = useInfiniteQuery({
queryKey: [...queryKeys.attention(companyId), "history", { search, outcome, agentId, projectId, sort }],
queryFn: ({ pageParam }) => attentionApi.history(companyId, { search, outcome, agentId, projectId, sort, cursor: pageParam, limit: 40 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.nextCursor ?? undefined,
});
const historyView = useQueryView(history);
const historyWaiting = ["loading", "reconnecting"].includes(historyView.kind);
const items = history.data?.pages.flatMap((page) => page.items) ?? [];
const groups = new Map<string, typeof items>();
for (const item of items) {
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle ?? "Other tasks" : groupBy === "agent" ? item.agentName ?? "Other agents" : groupBy === "project" ? item.projectName ?? "No project" : "";
groups.set(label, [...(groups.get(label) ?? []), item]);
}
const slot = typeof document !== "undefined" ? document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID) : null;
const tab = <SidePanelTab id="decision-history" label="Decision history" active onSelect={() => {}} onClose={onClose} />;
return <div className="space-y-5" data-coordinator-decision-history>
{!mobile ? <>{slot ? createPortal(tab, slot) : tab}<h2 className="text-lg font-medium">Decision history</h2></> : null}
<ToolbarSearch query={search} onQueryChange={setSearch} label="Search decision history" placeholder="Search decisions or task IDs…" />
<div className="grid grid-cols-2 gap-2">
<NativeSelect aria-label="Filter decision outcomes" value={outcome} onChange={(event) => setOutcome(event.target.value)}>
<option value="">All outcomes</option><option value="accepted">Approved</option><option value="answered">Answered</option><option value="rejected">Changes requested</option><option value="decided">Decided</option><option value="cancelled">Cancelled</option><option value="expired">Expired</option>
</NativeSelect>
<NativeSelect aria-label="Sort decision history" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}><option value="newest">Newest first</option><option value="oldest">Oldest first</option></NativeSelect>
<NativeSelect aria-label="Filter decision history by agent" value={agentId} onChange={(event) => setAgentId(event.target.value)} className="col-span-2"><option value="">All agents</option>{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</NativeSelect>
<NativeSelect aria-label="Filter decision history by project" value={projectId} onChange={(event) => setProjectId(event.target.value)} className="col-span-2"><option value="">All projects</option>{(projects.data ?? []).map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}</NativeSelect>
<NativeSelect aria-label="Group decision history" value={groupBy} onChange={(event) => setGroupBy(event.target.value as Group)} className="col-span-2"><option value="none">No grouping</option><option value="task">By task</option><option value="agent">By agent</option><option value="project">By project</option></NativeSelect>
</div>
{historyWaiting ? <p role="status" className="text-sm text-muted-foreground">Loading decisions…</p> : null}
{(historyView.kind === "error") ? <div role="alert" className="space-y-2 text-sm"><p>Decision history could not be loaded.</p><Button variant="outline" onClick={() => void history.refetch()}>Retry</Button></div> : null}
{!historyWaiting && !(historyView.kind === "error") && !items.length ? <p className="text-sm text-muted-foreground">No decisions match these filters.</p> : null}
<div className="space-y-4">{[...groups].map(([label, entries]) => <section key={label} className="space-y-3">{label ? <h3 className="text-sm font-medium text-muted-foreground">{label}</h3> : null}{entries.map((item) => <div key={item.id} className="space-y-1"><Receipt item={item} onOpen={navigate} /><p className="px-1 text-xs text-muted-foreground"><time dateTime={item.resolvedAt}>{new Date(item.resolvedAt).toLocaleString()}</time></p></div>)}</section>)}</div>
{history.hasNextPage ? <Button variant="outline" disabled={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>{history.isFetchingNextPage ? "Loading…" : "Load more decisions"}</Button> : null}
</div>;
/** Rule 7: a card that cannot answer inline links to the exact control, named by its verb. */
function openLabel(item: AttentionItem) {
return (item.decisionVerbs ?? []).find((verb) => verb.id === "open_recovery" || verb.id === "inspect")?.label ?? "Open to review";
}
/** Say each thing once (IA4): a quoted excerpt of the title is not a second line. */
function sameText(left: string, right: string) {
const plain = (text: string) => text.trim().replace(/^[“"']+|[”"']+$/g, "").replace(/[.\s]+$/, "").trim().toLocaleLowerCase();
const a = plain(left);
const b = plain(right);
return a === b || b.includes(`“${a}”`.toLocaleLowerCase()) || b.endsWith(a);
}
/** A document key as a name ("email-draft" → "Email draft") until its title loads. */
function documentKeyLabel(key: string) {
const words = key.replace(/[-_]+/g, " ").trim();
return words.charAt(0).toLocaleUpperCase() + words.slice(1);
}
const EMPTY_VIEW_TEXT: Record<AttentionPersonalView, string> = {
mine: "You're caught up.",
unassigned: "No cards need an owner.",
company: "No company alerts right now.",
archived: "Nothing archived. Cards you archive or snooze for yourself show here.",
all: "No open cards for anyone.",
};
export function CoordinatorNeedsYou() {
const { selectedCompanyId } = useCompany();
const { userId } = useCoordinatorWorkspaceEnabled();
const { setBreadcrumbs } = useBreadcrumbs();
const { openPanel, closePanel, setPanelVisible } = usePanel();
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
const { isMobile } = useSidebar();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkDecisionId = searchParams.get("decisionId");
// For you is the default; the other views live in the URL (PAP-749 Rule 3).
// Everyone opens for a board member only (PAP-768).
const everyone = useAttentionEveryoneView(selectedCompanyId);
const view = parseAttentionView(searchParams.get(ATTENTION_VIEW_PARAM), { everyone });
// Everyone can be narrowed to one person (PAP-775); the person lives in the URL too.
const forUserId = view === "all" ? searchParams.get(COORDINATOR_ASSIGNEE_PARAM) : null;
const setView = useCallback((next: typeof view, person: string | null = null) => setSearchParams(current => {
const params = new URLSearchParams(current);
if (next === "mine") params.delete(ATTENTION_VIEW_PARAM); else params.set(ATTENTION_VIEW_PARAM, next);
if (person) params.set(COORDINATOR_ASSIGNEE_PARAM, person); else params.delete(COORDINATOR_ASSIGNEE_PARAM);
return params;
}, { replace: true }), [setSearchParams]);
const assignee = coordinatorAssignee(view, forUserId);
const reducedMotion = useReducedMotion();
const [query, setQuery] = useState("");
const [agentFilter, setAgentFilter] = useState("");
const [projectFilter, setProjectFilter] = useState("");
const [groupBy, setGroupBy] = useState<Group>("none");
const [sort, setSort] = useState<"newest" | "oldest">("newest");
const [groupBy, setGroupBy] = useState<Group>("type");
const [historyOpen, setHistoryOpen] = useState(false);
// Focus (PAP-770): documents a card asks about open as side-panel tabs, the
// same chrome as task detail. Decision history is a main-pane view instead.
const reviewTabs = useSidePanelTabs<NeedsYouReviewPayload>({ initialState: { tabs: [], activeTabId: null } });
const [reviewPanelOpen, setReviewPanelOpen] = useState(false);
const [now, setNow] = useState(Date.now);
const [settling, setSettling] = useState<AttentionItem[]>([]);
const [savedIds, setSavedIds] = useState<Set<string>>(() => new Set());
useEffect(() => { setSettling([]); setSavedIds(new Set()); }, [selectedCompanyId, userId]);
const [savedIds, setSavedIds] = useState<Map<string, { version: number; fingerprint: string; closed: boolean }>>(() => new Map());
// The Stale (N) group starts collapsed in each view; the Archived view's
// Snoozed group starts open (PAP-749 plan §3).
const [staleOpen, setStaleOpen] = useState(false);
const [snoozedOpen, setSnoozedOpen] = useState(true);
useEffect(() => { setSettling([]); setSavedIds(new Map()); setStaleOpen(false); setSnoozedOpen(true); }, [selectedCompanyId, userId, view]);
const archivedView = view === "archived";
const [renderLimit, setRenderLimit] = useState(40);
const feed = useQuery({ queryKey: coordinatorAttentionQueryKey(selectedCompanyId!),
queryFn: () => attentionApi.list(selectedCompanyId!, { all: true }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
// Keyed by person and view; the For you key is the one the badge reads.
const feed = useAttentionViewFeed(selectedCompanyId, view);
useEffect(() => {
setSavedIds(current => {
const pending = new Map([...current].filter(([, hidden]) => hidden.closed || hidden.version === feed.dataUpdatedAt));
return pending.size === current.size ? current : pending;
});
}, [feed.dataUpdatedAt]);
const feedView = useQueryView(feed);
const feedWaiting = ["loading", "reconnecting"].includes(feedView.kind);
const feedWaiting = feedView.kind === "loading" || feedView.kind === "reconnecting";
const { viewCounts } = useAttentionBadge(selectedCompanyId);
const claim = useClaimAttentionItem(selectedCompanyId);
const receiptActions = useDismissAttentionReceipt(selectedCompanyId);
const directory = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId });
const userLabelMap = useMemo(() => buildCompanyUserLabelMap(directory.data?.users), [directory.data?.users]);
// A closed card leaves the list at once; a withdrawn one comes back as a
// receipt in Recent Decisions after the history refetch. In the Archived
// view a new snooze time keeps the card (it only changes group), and a
// restored card leaves it; elsewhere an Undo brings the card straight back.
const hideCard = useCallback((item: AttentionItem, closed = false) => {
setSavedIds(current => new Map(current).set(`${item.sourceKind}:${item.subject.id}`, { version: feed.dataUpdatedAt, fingerprint: item.changeFingerprint, closed }));
}, [feed.dataUpdatedAt]);
const showCard = useCallback((item: AttentionItem) => {
setSavedIds(current => { const next = new Map(current); next.delete(`${item.sourceKind}:${item.subject.id}`); return next; });
}, []);
const onCardClosed = useCallback((item: AttentionItem, mode: AttentionCloseMode) => {
if (archivedView && mode !== "withdraw") return;
hideCard(item, mode === "withdraw");
}, [archivedView, hideCard]);
const onCardRestored = useCallback((item: AttentionItem) => (archivedView ? hideCard : showCard)(item), [archivedView, hideCard, showCard]);
const closeActions = useAttentionCloseActions(selectedCompanyId, { userLabelMap, onClosed: onCardClosed, onRestored: onCardRestored });
const agentQuery = useQuery({ queryKey: queryKeys.agents.list(selectedCompanyId!), queryFn: () => agentsApi.list(selectedCompanyId!), enabled: !!selectedCompanyId });
// Full history lives in its own paginated tab. Only the newest bounded page
// is fetched here; its window expires visually without deleting any record.
const recent = useQuery({ queryKey: [...queryKeys.attention(selectedCompanyId!), "history", "recent"],
queryFn: () => attentionApi.history(selectedCompanyId!, { limit: 100, since: new Date(Date.now() - COORDINATOR_RECENT_DECISION_MS).toISOString() }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
const recentView = useQueryView(recent);
// History has one home, the main-pane Decision history view (PAP-775, IA3/IA4).
// The newest page is read here only so an answered card can turn into its
// receipt in place before it leaves, and so a card already answered elsewhere
// never shows twice.
const recent = useQuery({ queryKey: [...queryKeys.attention(selectedCompanyId!), "history", "recent", DEFAULT_DECISION_HISTORY_SCOPE],
queryFn: () => attentionApi.history(selectedCompanyId!, { limit: 100, scope: DEFAULT_DECISION_HISTORY_SCOPE, since: new Date(Date.now() - COORDINATOR_RECENT_DECISION_MS).toISOString() }), enabled: !!selectedCompanyId, refetchOnWindowFocus: true });
const agentMap = useMemo(() => new Map((agentQuery.data ?? []).map((agent) => [agent.id, agent])), [agentQuery.data]);
const recentItems = useMemo(() => recentDecisionReceipts(recent.data?.items ?? [], now), [recent.data, now]);
const settlingIds = useMemo(() => new Set(settling.map(item => `${item.sourceKind}:${item.subject.id}`)), [settling]);
const visibleRecentItems = useMemo(() => recentItems.filter((item) => {
if (settlingIds.has(item.id)) return false;
if (agentFilter && item.agentId !== agentFilter) return false;
if (projectFilter && item.projectId !== projectFilter) return false;
return [item.title, item.issueTitle, item.issueIdentifier, item.agentName, item.projectName, item.label]
.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase());
}).sort((a, b) => (sort === "oldest" ? 1 : -1) * (Date.parse(a.resolvedAt) - Date.parse(b.resolvedAt))),
[recentItems, settlingIds, agentFilter, projectFilter, query, sort]);
const recentGroups = useMemo(() => {
const result = new Map<string, DecisionHistoryItem[]>();
for (const item of visibleRecentItems) {
const label = groupBy === "task" ? item.issueIdentifier ?? item.issueTitle ?? "Other tasks"
: groupBy === "agent" ? item.agentName ?? "Other agents"
: groupBy === "project" ? item.projectName ?? "No project" : "";
result.set(label, [...(result.get(label) ?? []), item]);
}
return [...result];
}, [visibleRecentItems, groupBy]);
// One card for each obligation (PAP-749 Rule 11): evidence folds into its
// lead before the page hides, splits or groups anything.
const obligations = useMemo(() => foldAttentionObligations(feed.data?.items ?? []), [feed.data]);
const pendingItems = useMemo(() => {
const receipts = new Set(recentItems.map((item) => item.id));
const items = new Map((feed.data?.items ?? []).map((item) => [`${item.sourceKind}:${item.subject.id}`, item]));
const items = new Map(obligations.cards.map((item) => [`${item.sourceKind}:${item.subject.id}`, item]));
for (const item of settling) if (!items.has(`${item.sourceKind}:${item.subject.id}`)) items.set(`${item.sourceKind}:${item.subject.id}`, item);
return [...items].filter(([key]) => settlingIds.has(key) || (!receipts.has(key) && !savedIds.has(key))).map(([, item]) => item);
}, [feed.data, settling, settlingIds, recentItems, savedIds]);
return [...items].filter(([key, item]) => {
const hidden = savedIds.get(key);
const locallyHidden = hidden && (hidden.closed ? hidden.fingerprint === item.changeFingerprint : hidden.version === feed.dataUpdatedAt);
return settlingIds.has(key) || (!receipts.has(key) && !locallyHidden);
}).map(([, item]) => item);
}, [obligations, settling, settlingIds, recentItems, savedIds, feed.dataUpdatedAt]);
const filtered = useMemo(() => pendingItems.filter((item) => {
const agentId = String(item.subject.metadata?.createdByAgentId ?? item.subject.metadata?.originAgentId ?? "");
if (agentFilter && agentId !== agentFilter && item.originAgentName !== agentMap.get(agentFilter)?.name) return false;
if (projectFilter && item.project?.id !== projectFilter) return false;
if (forUserId && coordinatorAudienceKey(item) !== `user:${forUserId}`) return false;
return [item.subject.title, item.relatedIssue?.title, item.relatedIssue?.identifier, item.originAgentName, item.project?.name, attentionDetailLine(item)].join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase());
}).sort((a, b) => (sort === "oldest" ? 1 : -1) * (Date.parse(a.activityAt) - Date.parse(b.activityAt))), [pendingItems, agentFilter, projectFilter, query, sort, agentMap]);
}).sort((a, b) => Date.parse(b.activityAt) - Date.parse(a.activityAt)), [pendingItems, agentFilter, projectFilter, forUserId, query, agentMap]);
// Stale cards go to the collapsed group at the bottom of each view (Rule 10).
// The Archived view keeps stale cards in place with their chip, and puts
// snoozed cards in their own group (plan §3).
const { current: currentItems, stale: staleItems, snoozed: snoozedItems } = useMemo(() => {
if (archivedView) {
const { archived, snoozed } = splitArchivedAttentionItems(filtered);
return { current: archived, stale: [] as AttentionItem[], snoozed };
}
return { ...splitStaleAttentionItems(filtered), snoozed: [] as AttentionItem[] };
}, [archivedView, filtered]);
const groups = useMemo(() => {
const result = new Map<string, AttentionItem[]>();
for (const item of filtered.slice(0, renderLimit)) {
const label = groupBy === "task" ? item.relatedIssue?.identifier ?? item.relatedIssue?.title ?? "Other decisions"
for (const item of currentItems.slice(0, renderLimit)) {
const label = groupBy === "type" ? needsYouSection(item) : groupBy === "task" ? item.relatedIssue?.identifier ?? item.relatedIssue?.title ?? "Other decisions"
: groupBy === "agent" ? item.originAgentName ?? "Other agents" : groupBy === "project" ? item.project?.name ?? "No project" : "";
result.set(label, [...(result.get(label) ?? []), item]);
}
return [...result];
}, [filtered, groupBy, renderLimit]);
// By type, decisions come before problems (Paper "A · Compact cards").
return groupBy === "type" ? [...result].sort(([a], [b]) => (a === "decisions" ? 0 : 1) - (b === "decisions" ? 0 : 1)) : [...result];
}, [currentItems, groupBy, renderLimit]);
useEffect(() => {
if (!deepLinkDecisionId || !selectedCompanyId || !pendingItems.some(item => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId)) return;
if (!deepLinkDecisionId || !selectedCompanyId) return;
if (!pendingItems.some(item => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId)) {
if (!feedWaiting && everyone && view !== "all") setView("all");
return;
}
// A saved decision link takes precedence over local toolbar filters and the
// rendering window. Consume it only after its actual card has mounted.
if (query || agentFilter || projectFilter) { setQuery(""); setAgentFilter(""); setProjectFilter(""); return; }
const index = filtered.findIndex(item => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId);
if (forUserId) { setView("all"); return; }
if (staleItems.some(item => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId)) setStaleOpen(true);
const index = currentItems.findIndex(item => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId);
if (index >= renderLimit) { setRenderLimit(Math.ceil((index + 1) / 40) * 40); return; }
const card = document.getElementById(`coordinator-decision-${deepLinkDecisionId}`);
if (!card) return;
card.focus({ preventScroll: true });
card.scrollIntoView({ block: "center", behavior: reducedMotion ? "auto" : "smooth" });
setSearchParams(current => { const next = new URLSearchParams(current); next.delete("decisionId"); return next; }, { replace: true });
}, [deepLinkDecisionId, selectedCompanyId, pendingItems, filtered, query, agentFilter, projectFilter, renderLimit, reducedMotion, setSearchParams]);
}, [deepLinkDecisionId, selectedCompanyId, pendingItems, currentItems, staleItems, feedWaiting, everyone, view, query, agentFilter, projectFilter, forUserId, setView, renderLimit, reducedMotion, setSearchParams]);
const projects = useMemo(() => [...new Map([
...pendingItems.flatMap((item) => item.project ? [[item.project.id, item.project.name] as const] : []),
...recentItems.flatMap((item) => item.projectId ? [[item.projectId, item.projectName ?? "Unnamed project"] as const] : []),
]).entries()], [pendingItems, recentItems]);
const filtersActive = Boolean(query.trim() || agentFilter || projectFilter);
const closeHistory = useCallback(() => { setHistoryOpen(false); closePanel(); }, [closePanel]);
const showHistory = () => { setHistoryOpen(true); if (!isMobile) setPanelVisible(true); };
useEffect(() => { setBreadcrumbs([{ label: "Inbox" }]); }, [setBreadcrumbs]);
useEffect(() => {
setSettling([]); setHistoryOpen(false); setNow(Date.now()); closePanel();
return closePanel;
}, [selectedCompanyId, closePanel]);
useEffect(() => {
if (!historyOpen || !selectedCompanyId || isMobile) { closePanel(); return; }
// Assigned to you, everyone the viewer may see, one person, no owner yet, or company alerts.
const assigneeOptions = useMemo(() => {
const people = [...userLabelMap].filter(([id]) => id !== userId).sort(([, a], [, b]) => a.localeCompare(b));
return [
{ value: "me" as CoordinatorAssignee, label: "Assigned to you" },
...(everyone ? [{ value: "all" as CoordinatorAssignee, label: "All assignees" }, ...people.map(([id, name]) => ({ value: `user:${id}` as CoordinatorAssignee, label: name }))] : []),
{ value: "unassigned" as CoordinatorAssignee, label: "No owner yet" },
{ value: "company" as CoordinatorAssignee, label: "Company alerts" },
];
}, [userLabelMap, userId, everyone]);
const chooseAssignee = (value: CoordinatorAssignee) => { const target = coordinatorAssigneeTarget(value, userId); setView(target.view, target.forUserId); };
const closeHistory = useCallback(() => setHistoryOpen(false), []);
const showHistory = () => setHistoryOpen(true);
// Cards whose artifact can open beside them: the + launcher lists these.
const reviewable = useMemo(() => pendingItems.filter(item => !!needsYouArtifact(item)), [pendingItems]);
const { openTab: openReviewTab, closeTab: closeReviewTab, selectTab: selectReviewTab, reorderTabs: reorderReviewTabs, resetTabs: resetReviewTabs } = reviewTabs;
const showReview = useCallback((item: AttentionItem, artifact?: NeedsYouArtifact | null) => {
openReviewTab(needsYouReviewTab(item, artifact));
setReviewPanelOpen(true);
setPanelVisible(true);
openPanel(<HistoryPanel companyId={selectedCompanyId} agents={agentQuery.data ?? []} onClose={closeHistory} />);
}, [historyOpen, selectedCompanyId, isMobile, agentQuery.data, openPanel, closePanel, closeHistory, setPanelVisible]);
}, [openReviewTab, setPanelVisible]);
// The frame's close control hides the panel; its tabs come back with the next Review.
useEffect(() => { if (!panelVisible) setReviewPanelOpen(false); }, [panelVisible]);
// A resolved or closed card's tabs close with it.
useEffect(() => {
const open = new Set(pendingItems.map(item => item.id));
for (const tab of reviewTabs.tabs) if (!open.has(tab.payload.itemId)) closeReviewTab(tab.id);
}, [pendingItems, reviewTabs.tabs, closeReviewTab]);
useEffect(() => { setBreadcrumbs([{ label: "Needs You" }]); }, [setBreadcrumbs]);
useEffect(() => {
setSettling([]); setHistoryOpen(false); setReviewPanelOpen(false); resetReviewTabs({ tabs: [], activeTabId: null }); setNow(Date.now()); closePanel();
return closePanel;
}, [selectedCompanyId, closePanel, resetReviewTabs]);
useEffect(() => {
if (!selectedCompanyId || isMobile || !reviewPanelOpen) { closePanel(); return; }
openPanel(<CoordinatorNeedsYouSidePanel tabs={reviewTabs.tabs} activeTabId={reviewTabs.activeTabId} items={pendingItems} reviewable={reviewable}
onSelectTab={selectReviewTab} onCloseTab={closeReviewTab} onReorderTabs={reorderReviewTabs} onOpenItem={item => showReview(item)}
renderReview={(item, tab) => { const artifact = needsYouTabArtifact(item, tab.payload); return artifact ? <CoordinatorReviewPanel key={tab.id} companyId={selectedCompanyId} item={item} artifact={artifact}
agentMap={agentMap} currentUserId={userId} userLabelMap={userLabelMap} onOpenTask={navigate} onResolved={() => closeReviewTab(tab.id)} /> : null; }} />);
}, [reviewPanelOpen, reviewTabs.tabs, reviewTabs.activeTabId, pendingItems, reviewable, selectedCompanyId, isMobile, agentMap, userId, userLabelMap, navigate, openPanel, closePanel, selectReviewTab, closeReviewTab, reorderReviewTabs, showReview]);
useEffect(() => {
const next = recentItems.reduce((earliest, item) => Math.min(earliest, Date.parse(item.resolvedAt) + COORDINATOR_RECENT_DECISION_MS), Infinity);
if (!Number.isFinite(next)) return;
@@ -189,7 +267,7 @@ export function CoordinatorNeedsYou() {
}, [recentItems]);
const onResolved = (item: AttentionItem) => {
const id = `${item.sourceKind}:${item.subject.id}`;
setSavedIds(current => new Set([...current, id]));
hideCard(item, true);
setSettling(current => current.some(entry => entry.id === item.id) ? current : [...current, item]);
void recent.refetch().then(result => {
if (result.isError) setSettling(current => current.filter(entry => `${entry.sourceKind}:${entry.subject.id}` !== id));
@@ -197,53 +275,164 @@ export function CoordinatorNeedsYou() {
void feed.refetch();
};
const duration = typeof document === "undefined" ? 0 : parseCssTimeMs(getComputedStyle(document.documentElement).getPropertyValue("--motion-duration-base")) / 1_000;
const renderCard = (item: AttentionItem) => {
const issueId = item.relatedIssue?.id ?? String(item.subject.metadata?.issueId ?? "");
const interaction = item.sourceKind === "issue_thread_interaction" && isInlineResolvable(item) && issueId;
const decision = item.sourceKind === "decision" && isInlineResolvable(item);
const stalledReview = item.sourceKind === "review" && item.subject.kind === "issue" && isInlineResolvable(item) && item.subject.metadata?.reviewAttentionState === "stalled";
const failedRun = item.sourceKind === "failed_run" && isInlineResolvable(item);
const budget = item.sourceKind === "budget_alert" && isInlineResolvable(item);
// PAP-775 (UX1): approvals and join requests are answered on the card.
const approval = item.sourceKind === "approval" && isInlineResolvable(item);
const joinRequest = item.sourceKind === "join_request" && isInlineResolvable(item);
const taskRef = needsYouTaskRef(item);
// null when there is no task, so the footer keeps its actions on the right (UI4).
const taskLink = taskRef?.href ? <NeedsYouTaskLink task={taskRef} onOpen={navigate} /> : null;
const id = `${item.sourceKind}:${item.subject.id}`;
const receipt = settlingIds.has(id) ? recentItems.find(entry => entry.id === id) : undefined;
const phase = !receipt ? "pending" : ["cancelled", "expired"].includes(receipt.outcome) ? "closed" : "resolved";
const refetchAll = () => { void feed.refetch(); void recent.refetch(); };
// Focus (PAP-770, PAP-775 IA5): a card about an artifact opens it beside the card.
const artifact = isMobile ? null : needsYouArtifact(item);
// The card stays highlighted while any of its artifacts is open beside it (UI9).
const panelOpen = !isMobile && reviewPanelOpen && panelVisible;
const activeTab = panelOpen ? reviewTabs.tabs.find(tab => tab.id === reviewTabs.activeTabId) : undefined;
const reviewing = !!activeTab && activeTab.payload.itemId === item.id;
const reviewingOwn = !!artifact && panelOpen && reviewTabs.activeTabId === needsYouReviewTabId(item);
// The artifact opens beside the card from the first row of its context panel (IA5).
const artifactRow = artifact && artifact.kind !== "approval" ? <NeedsYouPanelRow
icon={artifact.kind === "images" ? <ImageIcon /> : <FileText />}
title={artifact.kind === "document" && !artifact.title ? (artifact.key === "plan" ? "Plan" : documentKeyLabel(artifact.key)) : needsYouArtifactLabel(artifact)}
meta={reviewingOwn ? "Open in sidebar · Showing" : "Open in sidebar"}
label={`${reviewingOwn ? "Close" : "Open"} ${needsYouArtifactLabel(artifact)} in the sidebar`}
onOpen={() => reviewingOwn ? closeReviewTab(needsYouReviewTabId(item)) : showReview(item)} /> : null;
// A plan, document, approval or attachment the card links to opens as a
// side-panel tab beside the card, not a page change (PAP-775, IA5).
// A modified click still opens the link itself.
const openMentionedDocument = (event: ReactMouseEvent) => {
if (isMobile || event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
const link = (event.target as HTMLElement).closest<HTMLAnchorElement>("a[href]");
const mentioned = link ? needsYouMentionedArtifact(link.getAttribute("href")!, link.textContent) : null;
if (!mentioned) return;
event.preventDefault();
showReview(item, mentioned);
};
const title = coordinatorCardTitle(item);
const detail = coordinatorCardDetail(item, attentionDetailLine(item));
// The ask, with one plain line under it (UI1).
const heading = <div className="space-y-1.5"><h2>{title}</h2>{detail && !sameText(detail, title) ? <p className="text-sm text-muted-foreground">{detail}</p> : null}</div>;
const footerContext = <AttentionObligationEvidence item={item} evidence={attentionObligationEvidence(obligations, item)} />;
const type = attentionCardType(item);
return <motion.div id={item.sourceKind === "decision" ? `coordinator-decision-${item.subject.id}` : undefined} tabIndex={item.sourceKind === "decision" ? -1 : undefined} layout={reducedMotion ? false : "position"} layoutId={reducedMotion ? undefined : id} transition={{ duration }} key={id}>
<CoordinatorTaskMotion eventId={`decision:${selectedCompanyId}:${userId}:${id}`} phase={phase} createdAt={item.activityAt} className="coordinator-decision-motion"
onSettled={() => setSettling(current => current.filter(entry => `${entry.sourceKind}:${entry.subject.id}` !== id))}>
{receipt ? <Receipt item={receipt} onOpen={navigate} userLabelMap={userLabelMap} /> : <article onClickCapture={openMentionedDocument}
className="coordinator-decision-card space-y-3"
data-attention-card={item.id} data-card-type={type} data-card-kind={needsYouSection(item) === "problems" ? "problem" : "decision"} data-reviewing={reviewing || undefined}>
{/* What to do (with whose card it is) on the left; who asked, when, and ⋯ on the right (Paper "A · Compact cards"). */}
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2">
<NeedsYouTypeLabel item={item} currentUserId={userId} userLabelMap={userLabelMap} />
<AttentionHiddenChip item={item} />
<AttentionStaleChip item={item} />
</div>
<div className="flex shrink-0 items-center gap-3" data-attention-context>
<NeedsYouAudience item={item} currentUserId={userId} userLabelMap={userLabelMap} onClaim={entry => claim.mutate(entry)} claiming={claim.isPending && claim.variables?.id === item.id} />
<NeedsYouRequester item={item} agentMap={agentMap} userLabelMap={userLabelMap} currentUserId={userId} />
<time dateTime={item.activityAt} title={new Date(item.activityAt).toLocaleString()} className="shrink-0 text-xs leading-4.5 text-muted-foreground">{timeAgo(item.activityAt)}</time>
<AttentionCloseMenu item={item} currentUserId={userId} userLabelMap={userLabelMap}
busy={closeActions.closingItemId === item.id || closeActions.transferringItemId === item.id}
onWithdraw={(entry, reason) => closeActions.close({ item: entry, mode: "withdraw", reason })}
onArchive={entry => closeActions.close({ item: entry, mode: "archive" })}
onSnooze={(entry, until) => closeActions.close({ item: entry, mode: "snooze", until })}
onTransfer={(entry, target) => closeActions.transfer({ item: entry, userId: target })}
onRestore={archivedView ? entry => void closeActions.restore(entry) : undefined} />
</div>
</div>
<AttentionForOtherGate item={item} currentUserId={userId} userLabelMap={userLabelMap}>
{interaction ? <AttentionInteractionResolver footerSlot={taskLink} artifactRow={artifactRow} presentation="coordinator-decision" audienceStated={attentionAudienceLineNamesResponders(item, userId)} companyId={selectedCompanyId!} issueId={issueId} interactionId={item.subject.id} shareReviseDraft agentMap={agentMap} currentUserId={userId} onResolved={resolved => {
if (!resolved || resolved.status === "pending") { refetchAll(); return; }
onResolved(item);
}} />
: decision ? <DecisionResolver companyId={selectedCompanyId!} decisionId={item.subject.id} originIssue={item.relatedIssue} agentMap={agentMap} leading={taskLink} onResolved={() => onResolved(item)} />
: stalledReview ? <><div className="space-y-1.5"><h2>Choose how this task continues</h2><p className="text-sm text-muted-foreground">This task is still in review with no active reviewer or continuation. Return it to work, request changes, or mark the task done.</p></div><BlockedTaskCardBody item={item} companyId={selectedCompanyId!} agentMap={agentMap} userLabelMap={userLabelMap} leading={taskLink} footerContext={footerContext} onOpen={navigate} onResolved={refetchAll} /></>
: failedRun ? <>{heading}<FailedRunActions item={item} companyId={selectedCompanyId!} leading={taskLink} footerContext={footerContext} presentation="card" onResolved={refetchAll} /></>
: budget ? <>{heading}<BudgetAlertActions item={item} companyId={selectedCompanyId!} leading={taskLink} footerContext={footerContext} presentation="card" onResolved={refetchAll} /></>
: approval ? <>{heading}<ApprovalResolver item={item} companyId={selectedCompanyId!} toggle={taskLink} footerContext={footerContext} presentation="card" onViewDetails={isMobile ? undefined : () => showReview(item)} shareDraft onResolved={() => onResolved(item)} /></>
: joinRequest ? <>{heading}<JoinRequestResolver item={item} companyId={selectedCompanyId!} toggle={taskLink} presentation="card" onResolved={() => onResolved(item)} /></>
: type === "review_task" ? <>{heading}<ReviewTaskCardBody item={item} companyId={selectedCompanyId!} agentMap={agentMap} userLabelMap={userLabelMap} leading={taskLink} footerContext={footerContext} onOpen={navigate} onResolved={refetchAll} /></>
: type === "blocked_task" ? <>{heading}<BlockedTaskCardBody item={item} companyId={selectedCompanyId!} agentMap={agentMap} userLabelMap={userLabelMap} leading={taskLink} footerContext={footerContext} onOpen={navigate} onResolved={refetchAll} /></>
: type === "recovery" ? <>{heading}<RecoveryCardBody item={item} companyId={selectedCompanyId!} leading={taskLink} footerContext={footerContext} onOpen={navigate} onResolved={refetchAll} /></>
: <>{heading}{footerContext}<DecisionCardFooter leading={taskLink}><Button disabled={!item.subject.href} onClick={() => { if (item.subject.href) navigate(item.subject.href); }}>{openLabel(item)}<ArrowUpRight className="size-4" /></Button></DecisionCardFooter></>}
</AttentionForOtherGate>
</article>}
</CoordinatorTaskMotion>
</motion.div>;
};
if (!selectedCompanyId) return <p className="text-sm text-muted-foreground">Choose a company to see its decisions.</p>;
return <LayoutGroup id="coordinator-decisions"><section className="coordinator-needs-you mx-auto w-full max-w-4xl space-y-6" aria-label="Inbox">
{isMobile ? <Sheet open={historyOpen} onOpenChange={open => { if (!open) closeHistory(); }}>
<SheetContent side="bottom" aria-describedby={undefined} className="mobile-task-side-panel inset-0 h-dvh max-h-dvh w-full max-w-none gap-0 border-0 p-0 pt-(--sz-safe-top) pb-(--sz-safe-bottom)">
<SheetHeader><SheetTitle>Decision history</SheetTitle></SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto p-4"><HistoryPanel companyId={selectedCompanyId} agents={agentQuery.data ?? []} onClose={closeHistory} mobile /></div>
</SheetContent>
</Sheet> : null}
<header className="flex items-center justify-between gap-3"><h1 className="text-2xl font-medium">Inbox</h1><Button variant="ghost" onClick={showHistory}><History className="size-4" />Decision history</Button></header>
<div data-canonical-toolbar="coordinator-decisions" role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
<ToolbarSearch query={query} onQueryChange={setQuery} label="Search decisions" placeholder="Search decisions or task IDs…" />
// Decision history is a view of the main pane (PAP-770), not a side panel
// that repeats Recent Decisions beside them.
if (historyOpen) return <section className="coordinator-needs-you mx-auto w-full max-w-4xl space-y-6" aria-label="Decision history" data-coordinator-decision-history>
<Button variant="ghost" size="sm" onClick={closeHistory}><ArrowLeft className="size-4" />Needs You</Button>
<DecisionHistoryPanel companyId={selectedCompanyId} agents={agentQuery.data ?? []} userLabelMap={userLabelMap} header={<h1 className="text-2xl font-medium">Decision history</h1>} />
</section>;
const receipts = view === "mine" ? feed.data?.receipts ?? [] : [];
const counts = { decisions: 0, problems: 0, updates: receipts.length };
for (const item of currentItems) counts[needsYouSection(item)]++;
const summarySections = (Object.keys(counts) as NeedsYouSection[]).filter(section => counts[section] > 0);
if (!summarySections.length) summarySections.push("decisions");
const sectionHeading = (section: NeedsYouSection) => <h2 className="text-base font-medium" data-needs-you-section={section}>
{NEEDS_YOU_SECTIONS[section].title}
</h2>;
return <LayoutGroup id="coordinator-decisions"><section className="coordinator-needs-you mx-auto w-full max-w-4xl space-y-6" aria-label="Needs You">
{archivedView ? <div className="flex items-center gap-2"><Button variant="ghost" size="icon-sm" aria-label="Back to Needs You" onClick={() => setView("mine")}><ArrowLeft className="size-4" /></Button><h1 className="text-2xl font-medium">Archived</h1></div> : null}
{/* Assigned to you by default (PAP-775); the assignee filter widens to all
assignees, one person, no owner yet or company alerts. Archived and
Decision history sit in View. */}
<div role="toolbar" aria-label="Decisions controls" className="flex flex-wrap items-center gap-2">
<div className="min-w-44 flex-1"><ToolbarSearch query={query} onQueryChange={setQuery} label="Search decisions" placeholder="Search…" /></div>
<NativeSelect aria-label="Filter decisions by agent" value={agentFilter} onChange={(event) => setAgentFilter(event.target.value)} className="w-auto"><option value="">All agents</option>{(agentQuery.data ?? []).map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}</NativeSelect>
<NativeSelect aria-label="Filter decisions by project" value={projectFilter} onChange={(event) => setProjectFilter(event.target.value)} className="w-auto"><option value="">All projects</option>{projects.map(([id, name]) => <option key={id} value={id}>{name}</option>)}</NativeSelect>
<NativeSelect aria-label="Group decisions" value={groupBy} onChange={(event) => setGroupBy(event.target.value as Group)} className="w-auto"><option value="none">No grouping</option><option value="task">By task</option><option value="agent">By agent</option><option value="project">By project</option></NativeSelect>
<NativeSelect aria-label="Sort decisions" value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className="w-auto"><option value="newest">Newest first</option><option value="oldest">Oldest first</option></NativeSelect>
{assignee ? <NativeSelect aria-label="Filter decisions by assignee" data-attention-assignee={assignee} value={assignee} onChange={(event) => chooseAssignee(event.target.value as CoordinatorAssignee)} className="w-auto">
{assigneeOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
{assignee.startsWith("user:") && !assigneeOptions.some(option => option.value === assignee) ? <option value={assignee}>Someone else</option> : null}
</NativeSelect> : null}
<NativeSelect aria-label="Group decisions" value={groupBy} onChange={(event) => setGroupBy(event.target.value as Group)} className="w-auto"><option value="type">By type</option><option value="task">By task</option><option value="agent">By agent</option><option value="project">By project</option><option value="none">No grouping</option></NativeSelect>
<DropdownMenu>
<DropdownMenuTrigger asChild><Button variant="ghost" size="sm" data-attention-view-menu>View<ChevronDown className="size-4" /></Button></DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setView("archived")}>Archived{viewCounts.archived ? ` · ${viewCounts.archived}` : ""}</DropdownMenuItem>
<DropdownMenuItem onSelect={showHistory}>Decision history</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{feed.data ? <p className="text-sm text-muted-foreground" data-needs-you-summary>
{summarySections.map((section, index) => {
const label = NEEDS_YOU_SECTIONS[section].title.toLocaleLowerCase();
return <span key={section}>{index ? " · " : ""}{counts[section]} {counts[section] === 1 ? label.slice(0, -1) : label}</span>;
})}
</p> : null}
{feedWaiting ? <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading decisions…</p> : null}
{(feedView.kind === "error") ? <div role="alert" className="space-y-2"><p className="text-sm">Decisions could not be loaded.</p><Button variant="outline" onClick={() => void feed.refetch()}>Retry</Button></div> : null}
{!feedWaiting && !(feedView.kind === "error") && filtered.length === 0 ? <div className="space-y-3 py-8 text-center"><p className="text-sm text-muted-foreground">{filtersActive && !visibleRecentItems.length ? "No decisions match these filters." : pendingItems.length ? "No open decisions match these filters." : "You're caught up."}</p><Button variant="ghost" onClick={showHistory}>Open decision history<ArrowUpRight className="size-4" /></Button></div> : null}
{groups.map(([label, items]) => <div key={label} className="space-y-4">{label ? <h2 className="text-sm font-medium text-muted-foreground">{label}</h2> : null}{items.map((item) => {
const issueId = item.relatedIssue?.id ?? String(item.subject.metadata?.issueId ?? "");
const interaction = item.sourceKind === "issue_thread_interaction" && isInlineResolvable(item) && issueId;
const decision = item.sourceKind === "decision" && isInlineResolvable(item);
const stalledReview = item.sourceKind === "review" && item.subject.kind === "issue" && isInlineResolvable(item) && item.subject.metadata?.reviewAttentionState === "stalled";
const taskRef = item.relatedIssue ?? (item.subject.kind === "issue" ? item.subject : null);
const taskLink = taskRef?.href ? <button type="button" onClick={() => { if (taskRef.href) navigate(taskRef.href); }} className="flex max-w-full items-center gap-2 text-left text-sm text-muted-foreground hover:text-foreground"><span className="shrink-0">{taskRef.identifier ?? "Open task"}</span><span className="truncate">{taskRef.title}</span><ArrowUpRight className="size-4 shrink-0" /></button> : null;
const id = `${item.sourceKind}:${item.subject.id}`;
const receipt = settlingIds.has(id) ? recentItems.find(entry => entry.id === id) : undefined;
const phase = !receipt ? "pending" : ["cancelled", "expired"].includes(receipt.outcome) ? "closed" : "resolved";
return <motion.div id={item.sourceKind === "decision" ? `coordinator-decision-${item.subject.id}` : undefined} tabIndex={item.sourceKind === "decision" ? -1 : undefined} layout={reducedMotion ? false : "position"} layoutId={reducedMotion ? undefined : id} transition={{ duration }} key={id}>
<CoordinatorTaskMotion eventId={`decision:${selectedCompanyId}:${userId}:${id}`} phase={phase} createdAt={item.activityAt} className="coordinator-decision-motion"
onSettled={() => setSettling(current => current.filter(entry => `${entry.sourceKind}:${entry.subject.id}` !== id))}>
{receipt ? <Receipt item={receipt} onOpen={navigate} /> : <article className="coordinator-decision-card space-y-4 rounded-xl border border-border bg-card p-6">
{interaction ? <AttentionInteractionResolver footerSlot={taskLink} presentation="coordinator-decision" companyId={selectedCompanyId} issueId={issueId} interactionId={item.subject.id} agentMap={agentMap} currentUserId={userId} onResolved={resolved => {
if (!resolved || resolved.status === "pending") { void feed.refetch(); void recent.refetch(); return; }
onResolved(item);
}} />
: decision ? <DecisionResolver companyId={selectedCompanyId} decisionId={item.subject.id} originIssue={item.relatedIssue} agentMap={agentMap} onResolved={() => onResolved(item)} />
: stalledReview ? <><h2 className="text-lg font-medium">Choose how this task continues</h2><p className="text-sm">This task is still in review with no active reviewer or continuation. Return it to work, request changes, or mark the task done.</p><StalledReviewActions footerSlot={taskLink} issueId={item.subject.id} companyId={selectedCompanyId} reviewPolicy={readIssueReviewPolicyMetadata(item.subject.metadata)} approveLabel="Mark task done" onResolved={() => { void feed.refetch(); void recent.refetch(); }} /></>
: <><h2 className="text-lg font-medium">{item.subject.title ?? item.whyNow}</h2><p className="text-sm">{attentionDetailLine(item) ?? item.whyNow}</p><Button disabled={!item.subject.href} onClick={() => { if (item.subject.href) navigate(item.subject.href); }}>Open to review<ArrowUpRight className="size-4" /></Button></>}
{!interaction && !stalledReview ? taskLink : null}
</article>}
</CoordinatorTaskMotion>
</motion.div>;
})}</div>)}
{filtered.length > renderLimit ? <Button variant="outline" onClick={() => setRenderLimit((value) => value + 40)}>Show more decisions</Button> : null}
{visibleRecentItems.length ? <section className="space-y-3" aria-label="Recent Decisions"><h2 className="text-sm font-medium text-muted-foreground">Recent Decisions</h2>{recentGroups.map(([label, items]) => <div key={label} className="space-y-3">{label ? <h3 className="text-sm font-medium text-muted-foreground">{label}</h3> : null}{items.map((item) => <motion.div layout={!reducedMotion} layoutId={reducedMotion ? undefined : item.id} transition={{ duration }} key={item.id}><Receipt item={item} onOpen={navigate} /></motion.div>)}</div>)}</section> : null}
{(recentView.kind === "error") ? <p role="alert" className="text-sm text-muted-foreground">Recent decisions could not be loaded. <button className="underline" onClick={() => void recent.refetch()}>Retry</button></p> : null}
{feedView.kind === "error" ? <QueryErrorState error={feedView.error} onRetry={feedView.retry} /> : null}
{!feedWaiting && feedView.kind !== "error" && currentItems.length === 0 && !receipts.length ? <div className="space-y-3 py-8 text-center"><p className="text-sm text-muted-foreground">{staleItems.length ? "No current cards. Stale cards are below." : snoozedItems.length ? "No archived cards. Snoozed cards are below." : pendingItems.length ? "No open decisions match these filters." : EMPTY_VIEW_TEXT[view]}</p></div> : null}
{groups.map(([label, items], index) => <div key={label} className="space-y-3" role={groupBy === "type" ? "group" : undefined} aria-label={groupBy === "type" ? NEEDS_YOU_SECTIONS[label as NeedsYouSection].title : undefined}>
{groupBy === "type" ? index > 0 ? sectionHeading(label as NeedsYouSection) : null : label ? <h2 className="text-sm font-medium text-muted-foreground">{label}</h2> : null}
{items.map(renderCard)}
</div>)}
{currentItems.length > renderLimit ? <Button variant="outline" onClick={() => setRenderLimit((value) => value + 40)}>Show more decisions</Button> : null}
<AttentionStaleGroup items={staleItems} open={staleOpen} onToggle={() => setStaleOpen(open => !open)} onWithdrawAll={closeActions.withdrawAll}
busy={closeActions.bulkPending} currentUserId={userId} userLabelMap={userLabelMap} titleOf={coordinatorCardTitle}>
{staleItems.map(renderCard)}
</AttentionStaleGroup>
{/* Snoozed by you: each card says when it comes back (PAP-749 plan §3). */}
<AttentionSnoozedGroup count={snoozedItems.length} open={snoozedOpen} onToggle={() => setSnoozedOpen(open => !open)}>
{snoozedItems.map(renderCard)}
</AttentionSnoozedGroup>
{/* What other people did with your requests (PAP-749 plan §6): not in the badge. */}
{receipts.length ? <div className="space-y-3" aria-label="Updates">
{currentItems.length || staleItems.length || snoozedItems.length ? sectionHeading("updates") : null}
{receipts.map(entry => <NeedsYouReceiptCard key={entry.id} receipt={entry} userLabelMap={userLabelMap} onOpen={navigate}
onDismiss={receiptActions.dismiss} dismissing={receiptActions.dismissingId === entry.id} />)}
</div> : null}
</section></LayoutGroup>;
}
@@ -0,0 +1,109 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { FileText, Plus } from "lucide-react";
import type { AttentionItem } from "@paperclipai/shared";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "@/components/PropertiesPanel";
import { SidePanelLauncher, SidePanelTabs, type SidePanelLauncherItem, type SidePanelLauncherSection, type SidePanelTabItem, type SidePanelTabRecord } from "@/components/side-panel";
import { Button } from "@/components/ui/button";
import { needsYouArtifact, needsYouArtifactId, needsYouArtifactLabel, type NeedsYouArtifact } from "./CoordinatorReviewPanel";
/**
* A Needs You side-panel tab: what one pending card is deciding on (a plan,
* an approval, attachments), or an artifact the card mentions (PAP-775).
* Every tab belongs to its card, so it shows the card's own actions and
* closes when the card leaves.
*/
export interface NeedsYouReviewPayload {
kind: "review";
itemId: string;
/** A mentioned artifact; absent for the card's own artifact. */
artifact?: NeedsYouArtifact;
}
/** The card's own artifact keeps `review:<card>`; a mentioned one adds its own id. */
export function needsYouReviewTabId(item: AttentionItem, artifact?: NeedsYouArtifact | null) {
const own = needsYouArtifact(item);
if (!artifact || (own && needsYouArtifactId(own, item) === needsYouArtifactId(artifact, item))) return `review:${item.id}`;
return `review:${item.id}:${needsYouArtifactId(artifact, item)}`;
}
/** The artifact a tab shows: the mentioned one, else the card's own. */
export function needsYouTabArtifact(item: AttentionItem, payload: NeedsYouReviewPayload) {
return payload.artifact ?? needsYouArtifact(item);
}
function documentLabel(item: AttentionItem, artifact?: NeedsYouArtifact | null) {
const shown = artifact ?? needsYouArtifact(item);
return shown ? needsYouArtifactLabel(shown) : "Document";
}
/** "NEE-39 plan": the artifact and its task, where several are listed or open. */
export function needsYouReviewTabLabel(item: AttentionItem, artifact?: NeedsYouArtifact | null) {
const task = item.relatedIssue?.identifier;
const label = documentLabel(item, artifact);
return task ? `${task} ${label.toLocaleLowerCase()}` : item.subject.title ? `${label}: ${item.subject.title}` : label;
}
export function needsYouReviewTab(item: AttentionItem, artifact?: NeedsYouArtifact | null): SidePanelTabRecord<NeedsYouReviewPayload> {
const id = needsYouReviewTabId(item, artifact);
const mentioned = id !== needsYouReviewTabId(item) ? artifact ?? undefined : undefined;
return { id, type: documentLabel(item, artifact), label: needsYouReviewTabLabel(item, artifact), ariaLabel: needsYouReviewTabLabel(item, artifact), closable: true, payload: { kind: "review", itemId: item.id, artifact: mentioned } };
}
/**
* Focus (PAP-770): the Needs You side panel uses the task-detail panel's
* chrome: a tab per opened document, a + launcher listing the other
* documents waiting on you, and the frame's own expand and close controls.
* The page owns the tab state, so a card and its tab stay in step.
*/
export function CoordinatorNeedsYouSidePanel({ tabs, activeTabId, items, reviewable, onSelectTab, onCloseTab, onReorderTabs, onOpenItem, renderReview }: {
tabs: SidePanelTabRecord<NeedsYouReviewPayload>[];
activeTabId: string | null;
/** Every pending card, so a tab for a mentioned artifact finds its card. */
items: AttentionItem[];
reviewable: AttentionItem[];
onSelectTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onReorderTabs: (ordered: string[]) => void;
onOpenItem: (item: AttentionItem) => void;
renderReview: (item: AttentionItem, tab: SidePanelTabRecord<NeedsYouReviewPayload>) => ReactNode;
}) {
const [launcherOpen, setLauncherOpen] = useState(false);
const [slot, setSlot] = useState<HTMLElement | null>(null);
useEffect(() => { setSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID)); }, []);
const byId = useMemo(() => new Map(items.map(item => [item.id, item])), [items]);
// A tab says "Plan" until a second plan opens; then each names its task.
const visualTabs = useMemo<SidePanelTabItem[]>(() => tabs.map(tab => ({
id: tab.id, type: tab.type, ariaLabel: tab.ariaLabel, closable: true, icon: <FileText />,
label: tabs.filter(other => other.type === tab.type).length > 1 ? tab.label : tab.type,
})), [tabs]);
const sections = useMemo<SidePanelLauncherSection[]>(() => [{
id: "waiting",
label: "Waiting on you",
items: reviewable.map((item): SidePanelLauncherItem => ({
id: item.id,
label: needsYouReviewTabLabel(item),
description: item.relatedIssue?.title ?? undefined,
searchText: [item.relatedIssue?.identifier, item.relatedIssue?.title, item.subject.title].filter(Boolean).join(" "),
icon: <FileText />,
alreadyOpen: tabs.some(tab => tab.id === needsYouReviewTabId(item)),
})),
}], [reviewable, tabs]);
const select = (entry: SidePanelLauncherItem) => {
const item = byId.get(entry.id);
if (item) onOpenItem(item);
};
const launcher = <SidePanelLauncher sections={sections} onSelect={select} presentation="popover" open={launcherOpen} onOpenChange={setLauncherOpen}
emptyMessage="Nothing else is waiting on you."
trigger={<Button type="button" variant="ghost" size="icon-sm" className="h-(--side-panel-tab-height) w-(--side-panel-tab-height) shrink-0 rounded-md text-muted-foreground hover:text-foreground focus-visible:text-foreground" aria-label="Open a new tab"><Plus aria-hidden /></Button>} />;
const header = <SidePanelTabs tabs={visualTabs} activeTabId={activeTabId} onActiveTabChange={onSelectTab} onCloseTab={onCloseTab}
onReorderTabs={onReorderTabs} addControl={launcher} appearance="streamlined-task" />;
const active = tabs.find(tab => tab.id === activeTabId) ?? null;
const item = active ? byId.get(active.payload.itemId) ?? null : null;
return <div className="flex min-h-full flex-col" data-coordinator-side-panel>
{slot ? createPortal(header, slot) : header}
{!active ? <SidePanelLauncher sections={sections} onSelect={select} emptyMessage="Nothing else is waiting on you." />
: item ? renderReview(item, active)
: <p className="text-sm text-muted-foreground">This is no longer waiting on you.</p>}
</div>;
}
@@ -0,0 +1,193 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionItem } from "@paperclipai/shared";
import { pendingRequestConfirmationInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import { issuesApi } from "@/api/issues";
import { ThemeProvider } from "@/context/ThemeContext";
import { approvalsApi } from "@/api/approvals";
import { ApprovalResolver } from "@/components/AttentionQueueRow";
import { CoordinatorReviewPanel, needsYouArtifact, needsYouMentionedArtifact } from "./CoordinatorReviewPanel";
vi.mock("@/api/issues", () => ({ issuesApi: { getDocument: vi.fn(), getDocumentRevision: vi.fn(), listInteractions: vi.fn() } }));
vi.mock("@/components/AttentionInteractionResolver", () => ({ AttentionInteractionResolver: () => <div data-testid="panel-interaction-resolver" /> }));
vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn(async () => [{ id: "mgr", name: "Dottabot" }]) } }));
vi.mock("@/api/approvals", () => ({ approvalsApi: { approve: vi.fn(), reject: vi.fn(), requestRevision: vi.fn(), get: vi.fn() } }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("needsYouMentionedArtifact (PAP-775)", () => {
it("reads a task document, an approval and an attachment from a card's links", () => {
expect(needsYouMentionedArtifact("/NEE/issues/NEE-12#document-design", "design notes"))
.toMatchObject({ kind: "document", issueId: "NEE-12", key: "design", title: "design notes" });
expect(needsYouMentionedArtifact("/NEE/approvals/appr-1")).toEqual({ kind: "approval", approvalId: "appr-1" });
expect(needsYouMentionedArtifact("/api/attachments/att-1/content", "screenshot.png"))
.toEqual({ kind: "attachment", assetId: "att-1", name: "screenshot.png" });
});
it("preserves pinned document revisions, annotations, and the document owner", () => {
expect(needsYouMentionedArtifact("/NEE/issues/NEE-12#document-launch%20plan&revision=rev-1&interaction=req-1&documentIssue=NEE-9&thread=thread-2"))
.toMatchObject({ kind: "document", issueId: "NEE-9", key: "launch plan", documentTarget: { revisionId: "rev-1", interactionId: "req-1", threadId: "thread-2", documentIssueId: "NEE-9" } });
expect(needsYouMentionedArtifact("/NEE/issues/NEE-12#document-%invalid")).toBeNull();
});
it.each(["notes.pdf", "notes.txt", "clip.mp4", "Download file", undefined])("keeps unsupported attachment %s as a normal browser link", name => {
expect(needsYouMentionedArtifact("/api/attachments/att-1/content", name)).toBeNull();
});
it("leaves every other link alone", () => {
expect(needsYouMentionedArtifact("/NEE/issues/NEE-12")).toBeNull();
expect(needsYouMentionedArtifact("https://example.com/issues/X-1#document-plan")).toBeNull();
});
});
describe("ApprovalResolver shared between a card and its side panel (PAP-775)", () => {
let container: HTMLDivElement;
let root: Root;
const item = { id: "approval", sourceKind: "approval", updatedAt: "2026-11-04T00:00:00.000Z",
subject: { id: "appr-1", kind: "approval", title: "Ship it", metadata: {} } } as unknown as AttentionItem;
beforeEach(() => {
sessionStorage.clear();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("mirrors the decision note and sends the approval once", async () => {
let resolveApprove!: (value: unknown) => void;
vi.mocked(approvalsApi.approve).mockImplementation(() => new Promise(resolve => { resolveApprove = resolve; }) as never);
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}>
<div data-view="card"><ApprovalResolver item={item} companyId="company" toggle={null} shareDraft /></div>
<div data-view="panel"><ApprovalResolver item={item} companyId="company" toggle={null} shareDraft /></div>
</QueryClientProvider>));
const note = (view: string) => container.querySelector<HTMLTextAreaElement>(`[data-view="${view}"] textarea`)!;
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
await act(async () => {
setter.call(note("card"), "Looks right");
note("card").dispatchEvent(new Event("input", { bubbles: true }));
});
expect(note("panel").value).toBe("Looks right");
const approve = (view: string) => [...container.querySelectorAll<HTMLButtonElement>(`[data-view="${view}"] button`)].find(button => button.textContent === "Approve")!;
await act(async () => approve("card").click());
// Sending from one view disables the other, so nothing is sent twice.
expect(approve("panel").disabled).toBe(true);
await act(async () => approve("panel").click());
expect(approvalsApi.approve).toHaveBeenCalledTimes(1);
expect(approvalsApi.approve).toHaveBeenCalledWith("appr-1", "Looks right");
await act(async () => resolveApprove({}));
client.clear();
});
it.each([["ask_user_questions", false, null], ["request_checkbox_confirmation", false, null], ["request_item_verdicts", false, null], ["suggest_tasks", false, null], ["request_confirmation", true, null], ["request_confirmation", true, "deliverable"], ["request_confirmation", false, "tool_action"], ["request_confirmation", false, "secret"], ["request_confirmation", false, "connection"]] as const)("shares only synchronized %s actions in the artifact panel", async (kind, shared, requestKind) => {
const question = { ...item, sourceKind: "issue_thread_interaction", subject: { ...item.subject, metadata: { issueId: "task", kind, requestKind } } } as AttentionItem;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}>
<CoordinatorReviewPanel companyId="company" item={question} artifact={{ kind: "images" }} agentMap={new Map()} currentUserId="scott"
onOpenTask={vi.fn()} onResolved={vi.fn()} />
</QueryClientProvider>));
expect(Boolean(container.querySelector('[data-testid="panel-interaction-resolver"]'))).toBe(shared);
expect(container.textContent?.includes("Answer on the inbox card")).toBe(!shared);
client.clear();
});
it("opens the original attachment through a browser link instead of task navigation", async () => {
const onOpen = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}>
<CoordinatorReviewPanel companyId="company" item={item} artifact={{ kind: "attachment", assetId: "image with spaces", name: "screenshot.png" }}
agentMap={new Map()} currentUserId="scott" onOpenTask={onOpen} onResolved={vi.fn()} />
</QueryClientProvider>));
await act(async () => container.querySelector("img")!.dispatchEvent(new Event("error")));
expect(container.querySelector("img")).toBeNull();
expect(container.textContent).toContain("This file cannot be previewed.");
const link = [...container.querySelectorAll<HTMLAnchorElement>("a")].find(entry => entry.textContent === "Open file");
expect(link?.getAttribute("href")).toBe("/api/attachments/image%20with%20spaces/content");
expect(link?.target).toBe("_blank");
expect(onOpen).not.toHaveBeenCalled();
client.clear();
});
it("loads the pinned revision and retains the full document link", async () => {
vi.mocked(issuesApi.getDocumentRevision).mockResolvedValue({ title: "Pinned plan", body: "Revision under review" } as never);
const artifact = needsYouMentionedArtifact("/NEE/issues/NEE-12#document-plan&revision=rev-1&interaction=req-1&documentIssue=NEE-9&thread=thread-2")!;
const onOpen = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}>
<ThemeProvider><CoordinatorReviewPanel companyId="company" item={item} artifact={artifact} agentMap={new Map()} currentUserId="scott" onOpenTask={onOpen} onResolved={vi.fn()} /></ThemeProvider>
</QueryClientProvider>));
for (let tick = 0; tick < 3; tick++) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
expect(issuesApi.getDocumentRevision).toHaveBeenCalledExactlyOnceWith("NEE-9", "plan", "rev-1");
expect(issuesApi.getDocument).not.toHaveBeenCalled();
expect(container.textContent).toContain("Revision under review");
await act(async () => [...container.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent?.includes("Open in"))!.click());
expect(onOpen.mock.calls[0]?.[0]).toContain("#document-plan&thread=thread-2&revision=rev-1&interaction=req-1&documentIssue=NEE-9");
client.clear();
});
it("reviews the exact target of a plan request owned by another task", async () => {
const request = { ...pendingRequestConfirmationInteraction, id: "request-1", issueId: "request-task", payload: {
...pendingRequestConfirmationInteraction.payload, target: { type: "issue_document" as const, issueId: "document-owner", key: "plan", revisionId: "pinned-revision" },
} };
vi.mocked(issuesApi.listInteractions).mockResolvedValue([request]);
vi.mocked(issuesApi.getDocumentRevision).mockClear().mockResolvedValue({ title: "Correct plan", body: "The plan being approved" } as never);
vi.mocked(issuesApi.getDocument).mockClear().mockResolvedValue({ title: "Wrong plan", body: "The request task's current plan" } as never);
const plan = { ...item, id: "plan-request", sourceKind: "issue_thread_interaction", subject: { ...item.subject, id: request.id, metadata: { issueId: "request-task", targetDocumentKey: "plan" } },
relatedIssue: { id: "request-task", kind: "issue", companyId: "company", title: "Request task", identifier: "NEE-12", status: "in_progress", href: "/NEE/issues/NEE-12", metadata: {} } } as AttentionItem;
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><ThemeProvider>
<CoordinatorReviewPanel companyId="company" item={plan} artifact={needsYouArtifact(plan)!} agentMap={new Map()} currentUserId="scott" onOpenTask={vi.fn()} onResolved={vi.fn()} />
</ThemeProvider></QueryClientProvider>));
for (let tick = 0; tick < 5; tick++) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
expect(issuesApi.getDocumentRevision).toHaveBeenCalledExactlyOnceWith("document-owner", "plan", "pinned-revision");
expect(issuesApi.getDocument).not.toHaveBeenCalled();
expect(container.textContent).toContain("The plan being approved");
client.clear();
});
it("confirms before the panel answers another person's request", async () => {
vi.mocked(approvalsApi.approve).mockClear().mockResolvedValue({} as never);
const other = { ...item, inlineResolvable: true, audience: { kind: "user", userId: "dotta", reason: "requester" } } as AttentionItem;
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}>
<CoordinatorReviewPanel companyId="company" item={other} artifact={{ kind: "images" }} agentMap={new Map()} currentUserId="scott"
userLabelMap={new Map([["dotta", "Dotta"]])} onOpenTask={vi.fn()} onResolved={vi.fn()} />
</QueryClientProvider>));
const button = (label: string) => [...container.querySelectorAll<HTMLButtonElement>("button")].find(entry => entry.textContent === label)!;
await act(async () => button("Approve").click());
expect(approvalsApi.approve).not.toHaveBeenCalled();
expect(container.textContent).toContain("This is for @Dotta");
await act(async () => button("Continue").click());
expect(approvalsApi.approve).toHaveBeenCalledTimes(1);
client.clear();
});
it("shows the hire on a card with View configuration, Reject and Approve hire, and no note", async () => {
vi.mocked(approvalsApi.get).mockResolvedValue({ payload: { name: "Researcher", role: "researcher", reportsTo: "mgr", budgetMonthlyCents: 5000 } } as never);
const hire = { ...item, subject: { ...item.subject, metadata: { type: "hire_agent" } } } as AttentionItem;
const onViewDetails = vi.fn();
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}>
<ApprovalResolver item={hire} companyId="company" toggle={null} presentation="card" onViewDetails={onViewDetails} shareDraft />
</QueryClientProvider>));
for (let tick = 0; tick < 3; tick++) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); });
expect(container.querySelector("textarea")).toBeNull();
const panel = container.querySelector("[data-needs-you-panel]")!;
expect(panel.textContent).toContain("Researcher · $50 / month");
expect(panel.textContent).toContain("Reports to Dottabot");
const labels = [...container.querySelectorAll("[data-decision-actions] button")].map(button => button.textContent);
expect(labels).toEqual(["View configuration", "Reject", "Approve hire"]);
await act(async () => [...container.querySelectorAll<HTMLButtonElement>("[data-decision-actions] button")][0]!.click());
expect(onViewDetails).toHaveBeenCalledTimes(1);
client.clear();
});
});
@@ -0,0 +1,215 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ArrowUpRight, Loader2 } from "lucide-react";
import type { Agent, AttentionItem } from "@paperclipai/shared";
import { approvalsApi } from "@/api/approvals";
import { issuesApi } from "@/api/issues";
import { buildDocumentAnnotationHash, parseDocumentAnnotationHash, type DocumentAnnotationHashTarget } from "@/lib/document-annotation-hash";
import { AttentionForOtherGate } from "@/components/AttentionCloseMenu";
import { queryKeys } from "@/lib/queryKeys";
import { attentionDetailImages, attentionImageUrl, isInlineResolvable } from "@/lib/attention";
import { AttentionInteractionResolver } from "@/components/AttentionInteractionResolver";
import { ApprovalPayloadRenderer } from "@/components/ApprovalPayload";
import { ApprovalResolver } from "@/components/AttentionQueueRow";
import { Button } from "@/components/ui/button";
import { useQueryView } from "@/components/QueryView";
import { MarkdownBody } from "@/components/MarkdownBody";
import { isImageFilename } from "@/components/task-chat/task-chat-attachments";
/** The document a card asks you to review, when it has one (a plan approval today). */
export function attentionReviewDocumentKey(item: AttentionItem): string | null {
if (item.sourceKind !== "issue_thread_interaction") return null;
const key = item.subject.metadata?.targetDocumentKey;
return typeof key === "string" && key ? key : null;
}
/**
* What a card is deciding on, when it can open beside the card (PAP-775,
* IA5/IA6): a document such as a plan, an approval's request, or the
* screenshots attached to the task.
*/
export type NeedsYouArtifact =
| { kind: "document"; key: string; issueId: string; title?: string; documentTarget?: DocumentAnnotationHashTarget; requestIssueId?: string }
| { kind: "approval"; approvalId?: string }
| { kind: "images" }
| { kind: "attachment"; assetId: string; name?: string };
export function needsYouArtifact(item: AttentionItem): NeedsYouArtifact | null {
const issueId = item.relatedIssue?.id ?? (typeof item.subject.metadata?.issueId === "string" ? item.subject.metadata.issueId : null);
const key = attentionReviewDocumentKey(item);
if (key && issueId) return { kind: "document", key, issueId, requestIssueId: issueId };
if (item.sourceKind === "approval") return { kind: "approval" };
if (attentionDetailImages(item).length > 0) return { kind: "images" };
return null;
}
/**
* An artifact a card's text links to (PAP-775): another document of a task
* (`/NEE/issues/NEE-12#document-design`), an approval, or an attachment. It
* opens beside the card like the card's own artifact. `null` for any other
* link, which keeps its normal behaviour.
*/
export function needsYouMentionedArtifact(href: string, name?: string | null): NeedsYouArtifact | null {
let url: URL;
try { url = new URL(href, "http://paperclip.local"); } catch { return null; }
if (url.origin !== "http://paperclip.local" && typeof window !== "undefined" && url.origin !== window.location.origin) return null;
const doc = parseDocumentAnnotationHash(url.hash);
const issue = url.pathname.match(/\/issues\/([^/]+)\/?$/);
if (doc && issue) {
try { return { kind: "document", issueId: doc.documentIssueId ?? decodeURIComponent(issue[1]), key: doc.documentKey, title: name?.trim() || undefined, ...(url.hash.includes("&") ? { documentTarget: doc } : {}) }; }
catch { return null; }
}
const approval = url.pathname.match(/\/approvals\/([^/]+)\/?$/);
if (approval) return { kind: "approval", approvalId: decodeURIComponent(approval[1]) };
const attachment = url.pathname.match(/^\/api\/attachments\/([^/]+)\/content\/?$/);
if (attachment && name && isImageFilename(name.trim())) return { kind: "attachment", assetId: decodeURIComponent(attachment[1]), name: name.trim() };
return null;
}
/** One id per artifact, so the same plan or approval never opens twice. */
export function needsYouArtifactId(artifact: NeedsYouArtifact, item: AttentionItem) {
if (artifact.kind === "document" && artifact.requestIssueId) return `document:interaction:${item.subject.id}`;
if (artifact.kind === "document") return `document:${artifact.issueId}:${artifact.key}${artifact.documentTarget?.revisionId ? `:${artifact.documentTarget.revisionId}` : ""}`;
if (artifact.kind === "approval") return `approval:${artifact.approvalId ?? item.subject.id}`;
if (artifact.kind === "attachment") return `attachment:${artifact.assetId}`;
return `images:${item.id}`;
}
/** "Plan", "Approval" or "Attachments": the artifact's name on its tab and button. */
export function needsYouArtifactLabel(artifact: NeedsYouArtifact) {
if (artifact.kind === "approval") return "Approval";
if (artifact.kind === "images") return "Attachments";
if (artifact.kind === "attachment") return artifact.name ?? "Attachment";
return artifact.key === "plan" ? "Plan" : artifact.title ?? artifact.key;
}
export interface CoordinatorReviewPanelProps {
companyId: string;
item: AttentionItem;
artifact: NeedsYouArtifact;
agentMap: Map<string, Agent>;
currentUserId: string | null | undefined;
userLabelMap?: ReadonlyMap<string, string>;
onOpenTask: (href: string) => void;
onResolved: () => void;
}
/**
* Focus review panel (PAP-770, PAP-775): the artifact a card asks about,
* opened beside the card. It links to the exact spot in the task and pins the
* card's own actions to the bottom. Both read the same record, so answering
* here or on the card resolves both (one record, many views).
*/
export function CoordinatorReviewPanel(props: CoordinatorReviewPanelProps) {
const { artifact, item } = props;
const requestIssueId = artifact.kind === "document" ? artifact.requestIssueId : undefined;
const request = useQuery({
queryKey: queryKeys.issues.interactions(requestIssueId ?? ""),
queryFn: () => issuesApi.listInteractions(requestIssueId!),
enabled: Boolean(requestIssueId),
});
const requestView = useQueryView(request);
if (!requestIssueId) return <CoordinatorReviewPanelContent {...props} />;
if (requestView.kind === "loading" || requestView.kind === "reconnecting") {
return <p role="status" className="text-sm text-muted-foreground">Loading the requested document…</p>;
}
const interaction = request.data?.find(entry => entry.id === item.subject.id);
const target = interaction?.kind === "request_confirmation" ? interaction.payload.target : null;
if (requestView.kind === "error" || !target || target.type !== "issue_document") {
return <div role="alert" className="space-y-2 text-sm"><p>The requested document could not be loaded.</p><Button variant="outline" onClick={() => void request.refetch()}>Retry</Button></div>;
}
const resolved: NeedsYouArtifact = { kind: "document", key: target.key, issueId: target.issueId ?? requestIssueId, requestIssueId,
documentTarget: { documentKey: target.key, revisionId: target.revisionId, interactionId: interaction!.id,
documentIssueId: target.issueId ?? requestIssueId, threadId: null, commentId: null, viewer: null } };
return <CoordinatorReviewPanelContent {...props} artifact={resolved} />;
}
function CoordinatorReviewPanelContent({ companyId, item, artifact, agentMap, currentUserId, userLabelMap, onOpenTask, onResolved }: CoordinatorReviewPanelProps) {
const task = item.relatedIssue;
const label = needsYouArtifactLabel(artifact);
const ownDocument = artifact.kind === "document" && (artifact.issueId === task?.id || artifact.requestIssueId === task?.id);
const approvalId = artifact.kind === "approval" ? artifact.approvalId ?? item.subject.id : null;
// "/NEE": the company prefix of the card's own links, for a mentioned artifact's link.
const prefix = (task?.href ?? item.subject.href ?? "").match(/^\/[^/]+/)?.[0] ?? "";
const exactHref = artifact.kind === "document" ? `${ownDocument && task?.href ? task.href : `${prefix}/issues/${artifact.requestIssueId ?? artifact.issueId}`}${buildDocumentAnnotationHash(artifact.documentTarget ?? { documentKey: artifact.key, threadId: null, commentId: null })}`
: artifact.kind === "approval" ? (approvalId === item.subject.id ? item.subject.href : `${prefix}/approvals/${approvalId}`)
: artifact.kind === "attachment" ? attachmentUrl(artifact.assetId)
: task?.href ?? item.subject.href;
const interactionIssueId = item.sourceKind === "issue_thread_interaction"
? item.relatedIssue?.id ?? (typeof item.subject.metadata?.issueId === "string" ? item.subject.metadata.issueId : null)
: null;
// Confirmation drafts/actions synchronize across views. Other interaction
// kinds keep one editable form on the card while their artifact is open.
const requestKind = item.subject.metadata?.requestKind;
const sharedInteractionActions = interactionIssueId && item.subject.metadata?.kind === "request_confirmation"
&& (requestKind == null || requestKind === "deliverable");
return <div className="flex min-h-full flex-col gap-4" data-coordinator-review-panel={item.id} data-artifact={artifact.kind}>
<div className="min-h-0 flex-1">
{artifact.kind === "document" ? <DocumentArtifact issueId={artifact.issueId} documentKey={artifact.key} label={label} revisionId={artifact.documentTarget?.revisionId} />
: artifact.kind === "approval" ? <ApprovalArtifact approvalId={approvalId!} title={approvalId === item.subject.id ? item.subject.title : null} />
: artifact.kind === "attachment" ? <AttachmentArtifact key={artifact.assetId} assetId={artifact.assetId} name={label} />
: <ImagesArtifact item={item} />}
</div>
{exactHref ? artifact.kind === "attachment" ? <a href={exactHref} target="_blank" rel="noreferrer" className="flex max-w-full items-center gap-2 text-left text-sm text-muted-foreground hover:text-foreground">
<span className="shrink-0">Open file</span><ArrowUpRight className="size-4 shrink-0" />
</a> : <button type="button" onClick={() => onOpenTask(exactHref)} className="flex max-w-full items-center gap-2 text-left text-sm text-muted-foreground hover:text-foreground">
<span className="shrink-0">{`Open in ${artifact.kind === "approval" && !task ? "approvals" : artifact.kind === "document" && !ownDocument ? artifact.issueId : task?.identifier ?? "task"}`}</span><ArrowUpRight className="size-4 shrink-0" />
</button> : null}
{/* The card's own actions, pinned to the bottom of the artifact. */}
{/* Only the buttons the card itself offers (UX1). */}
{sharedInteractionActions || (item.sourceKind === "approval" && isInlineResolvable(item)) ? <div className="coordinator-review-actions sticky bottom-0 -mx-4 border-t border-border bg-card px-4 py-3">
<AttentionForOtherGate key={`${item.id}:${item.audience?.userId}`} item={item} currentUserId={currentUserId} userLabelMap={userLabelMap}>
{sharedInteractionActions ? <AttentionInteractionResolver presentation="coordinator-decision" companyId={companyId} issueId={interactionIssueId!} interactionId={item.subject.id} shareReviseDraft
agentMap={agentMap} currentUserId={currentUserId} userLabelMap={userLabelMap}
onResolved={resolved => { if (resolved && resolved.status !== "pending") onResolved(); }} />
: <ApprovalResolver item={item} companyId={companyId} toggle={null} presentation="panel" shareDraft onResolved={onResolved} />}
</AttentionForOtherGate>
</div> : interactionIssueId ? <p className="text-sm text-muted-foreground">Answer on the inbox card while reviewing this artifact.</p> : null}
</div>;
}
function DocumentArtifact({ issueId, documentKey, label, revisionId }: { issueId: string; documentKey: string; label: string; revisionId?: string }) {
const doc = useQuery<{ title: string | null; body: string }>({
queryKey: revisionId ? [...queryKeys.issues.document(issueId, documentKey), "revision", revisionId] : queryKeys.issues.document(issueId, documentKey),
queryFn: () => revisionId ? issuesApi.getDocumentRevision(issueId, documentKey, revisionId) : issuesApi.getDocument(issueId, documentKey),
});
return <>
{/* Say the title once (IA4): a body that opens with it already shows it. */}
{doc.data?.body.trimStart().toLocaleLowerCase().startsWith(`# ${(doc.data.title ?? label).toLocaleLowerCase()}`) ? null : <h2 className="mb-4 text-lg font-medium">{doc.data?.title ?? label}</h2>}
{doc.isPending ? <p className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading {label.toLocaleLowerCase()}…</p>
: doc.isError || !doc.data ? <p className="text-sm text-muted-foreground">This document couldn't be loaded. Open it in the task instead.</p>
: <MarkdownBody>{doc.data.body}</MarkdownBody>}
</>;
}
function ApprovalArtifact({ approvalId, title }: { approvalId: string; title: string | null }) {
const approval = useQuery({ queryKey: queryKeys.approvals.detail(approvalId), queryFn: () => approvalsApi.get(approvalId) });
return <>
<h2 className="mb-4 text-lg font-medium">{title ?? "Approval"}</h2>
{approval.isPending ? <p className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading approval…</p>
: approval.isError || !approval.data ? <p className="text-sm text-muted-foreground">This approval couldn't be loaded. Open it instead.</p>
: <ApprovalPayloadRenderer type={approval.data.type} payload={approval.data.payload} hidePrimaryTitle />}
</>;
}
const attachmentUrl = (assetId: string) => `/api/attachments/${encodeURIComponent(assetId)}/content`;
function AttachmentArtifact({ assetId, name }: { assetId: string; name: string }) {
const [previewFailed, setPreviewFailed] = useState(false);
return <>
<h2 className="mb-4 text-lg font-medium">{name}</h2>
{previewFailed ? <p className="text-sm text-muted-foreground">This file cannot be previewed. Open the original file below.</p>
: <img src={attachmentUrl(assetId)} alt={name} onError={() => setPreviewFailed(true)} className="w-full rounded-md border border-border bg-muted object-contain" />}
</>;
}
function ImagesArtifact({ item }: { item: AttentionItem }) {
const images = attentionDetailImages(item);
return <>
<h2 className="mb-4 text-lg font-medium">Attachments</h2>
<div className="space-y-3">
{images.map((image, index) => <img key={`${image.assetId}-${index}`} src={attentionImageUrl(image.assetId)} alt={image.alt ?? ""} loading="lazy"
className="w-full rounded-md border border-border bg-muted object-contain" />)}
</div>
</>;
}
@@ -5,7 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CoordinatorTaskMotion } from "./CoordinatorTaskCommitment";
import { TaskChatCompactInteractionCard } from "../task-chat/TaskChatCompactInteractionCard";
import { pendingSuggestedTasksInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import { pendingRequestConfirmationInteraction, pendingSuggestedTasksInteraction } from "@/fixtures/issueThreadInteractionFixtures";
import { claimReviseDraftWork, getReviseDraft, resetReviseDraftsForTests, reviseDraftKey, updateReviseDraft } from "@/lib/revise-draft-store";
import { ThemeProvider } from "@/context/ThemeContext";
import { TooltipProvider } from "../ui/tooltip";
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true, loaded: true, userId: "viewer" }) }));
@@ -13,11 +15,14 @@ vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: any) => <a h
const summaries = vi.hoisted(() => ({ rows: null as null | Array<Record<string, unknown>> }));
vi.mock("@/api/issues", () => ({ issuesApi: { summaries: vi.fn(async () => summaries.rows ?? [{ id: "task-created", identifier: "PAPER-108", title: "Write the launch post", status: "todo" }]) } }));
vi.mock("../task-chat/TaskChatRichInput", () => ({ TaskChatRichInput: ({ value, onChange, disabled }: { value: string; onChange: (value: string) => void; disabled: boolean }) => <textarea value={value} onChange={event => onChange(event.target.value)} disabled={disabled} /> }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
let host: HTMLDivElement;
let client: QueryClient;
beforeEach(() => {
sessionStorage.clear(); resetReviseDraftsForTests();
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() });
@@ -31,6 +36,33 @@ function render(interaction: typeof pendingSuggestedTasksInteraction, onAcceptIn
}
describe("Coordinator task commitment", () => {
it("shares a pending confirmation's revision draft and in-flight action with the inbox", async () => {
const interaction = pendingRequestConfirmationInteraction;
const target = interaction.payload.target;
const key = reviseDraftKey(interaction.id, target?.type === "issue_document" ? target.revisionId : null);
updateReviseDraft(key, { revising: true, reason: "Start with a smaller change" });
let finish!: () => void;
const reject = vi.fn(() => new Promise<void>(resolve => { finish = resolve; }));
await act(() => root.render(<QueryClientProvider client={client}><TooltipProvider>
<ThemeProvider><TaskChatCompactInteractionCard interaction={interaction} onAcceptInteraction={vi.fn()} onRejectInteraction={reject} showPlanPreview={false} /></ThemeProvider>
</TooltipProvider></QueryClientProvider>));
expect(host.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe("Start with a smaller change");
const field = host.querySelector<HTMLTextAreaElement>("textarea")!;
await act(() => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(field, "Use the revised scope");
field.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(getReviseDraft(key).reason).toBe("Use the revised scope");
const send = [...host.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Request revisions")!;
await act(() => send.click());
expect(getReviseDraft(key).working).toBe("reject");
expect(claimReviseDraftWork(key, "accept")).toBe(false);
expect(send.disabled).toBe(true);
await act(() => finish());
expect(reject).toHaveBeenCalledExactlyOnceWith(interaction, "Use the revised scope");
expect(getReviseDraft(key)).toMatchObject({ revising: false, reason: "", working: null });
});
it("does not claim creation when acceptance is still pending or failed", async () => {
let fail!: (reason: Error) => void;
const accept = vi.fn(() => new Promise<void>((_, reject) => { fail = reject; }));
@@ -0,0 +1,155 @@
import type { ReactNode } from "react";
import { FileText, TriangleAlert } from "lucide-react";
import type { Agent, AttentionItem } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { readIssueReviewPolicyMetadata } from "@/lib/review-policy";
import { DecisionCardFooter } from "@/components/DecisionCardFooter";
import { StalledReviewActions } from "@/components/StalledReviewActions";
import { Button } from "@/components/ui/button";
import { NeedsYouPanel, NeedsYouPanelNote, NeedsYouPanelRow } from "./NeedsYouCardKit";
/**
* Bodies of the Needs You cards whose real action lives on the task (PAP-775,
* Paper "10", "13", "14"): the inset panel and the footer. There is no inline
* review verdict for a covered review, no inline reassign, and resolving a
* recovery also sets the task's next status, so their primary action opens the
* task. A stalled review keeps its three inline review verbs.
*/
export interface NeedsYouCardBodyProps {
item: AttentionItem;
companyId: string;
agentMap?: ReadonlyMap<string, Agent>;
userLabelMap?: ReadonlyMap<string, string>;
/** The task link at the left of the footer. */
leading?: ReactNode;
footerContext?: ReactNode;
onResolved?: () => void;
/** In-app navigation (react-router `navigate`); a plain link without it. */
onOpen?: (href: string) => void;
}
const metadataString = (item: AttentionItem, key: string) => {
const value = item.subject.metadata?.[key];
return typeof value === "string" && value ? value : null;
};
/** "NEE-19 · Review pilot consent copy". */
const taskLine = (task: { identifier?: string | null; title?: string | null }) =>
[task.identifier, task.title].filter(Boolean).join(" · ") || "Task";
/** The assignee's name, from the same agent and people lists the page reads. */
function assigneeName(item: AttentionItem, agentMap?: ReadonlyMap<string, Agent>, userLabelMap?: ReadonlyMap<string, string>): string | null {
const agentId = metadataString(item, "assigneeAgentId");
const userId = metadataString(item, "assigneeUserId");
if (agentId) return agentMap?.get(agentId)?.name ?? null;
return userId ? userLabelMap?.get(userId) ?? null : null;
}
/** The primary "Open …" action: in-app navigation when given, else a link. */
function OpenButton({ href, onOpen, variant = "default", children }: { href: string; onOpen?: (href: string) => void; variant?: "default" | "outline"; children: ReactNode }) {
if (onOpen) return <Button type="button" size="sm" variant={variant} onClick={() => onOpen(href)}>{children}</Button>;
return <Button asChild size="sm" variant={variant}><Link to={href}>{children}</Link></Button>;
}
/**
* 10 "Review a task": a task in review assigned to you. The verdict is given on
* the task, where the review stage, its policy and the comment live, so the
* card opens it.
*/
export function ReviewTaskCardBody({ item, companyId, agentMap, userLabelMap, leading, footerContext, onOpen }: NeedsYouCardBodyProps) {
const assignee = assigneeName(item, agentMap, userLabelMap);
const href = item.subject.href;
// The feed carries no acceptance checklist yet, so the panel is the state line.
return <>
<NeedsYouPanel>
<NeedsYouPanelNote>{["In review", assignee ? `Assigned to ${assignee}` : null].filter(Boolean).join(" · ")}</NeedsYouPanelNote>
</NeedsYouPanel>
{footerContext}
<DecisionCardFooter leading={leading}>
{href ? <OpenButton href={href} onOpen={onOpen}>Open task</OpenButton> : null}
</DecisionCardFooter>
</>;
}
/**
* 13 "Unblock a task": the task that is in the way (the blocker, or the
* stalled review itself) and what it blocks. A stalled review is answered here
* with its review verbs; any other blocker opens the task that is in the way.
*/
export function BlockedTaskCardBody({ item, companyId, agentMap, userLabelMap, leading, footerContext, onResolved, onOpen }: NeedsYouCardBodyProps) {
const assignee = assigneeName(item, agentMap, userLabelMap);
const stalledReview = item.sourceKind === "review";
const blocking = item.detail?.kind === "blocker" ? item.detail.blockingIssue : null;
// "/NEE": the company prefix of the card's own links.
const prefix = (item.subject.href ?? "").match(/^\/[^/]+/)?.[0] ?? "";
const blockingHref = blocking?.id || blocking?.identifier ? `${prefix}/issues/${blocking.identifier ?? blocking.id}` : null;
const href = blockingHref ?? item.subject.href;
// A human-owned unblock names the blocker and blocks the subject; a stalled
// blocker chain is the subject itself and blocks its related task.
const blocked = blocking ? item.subject : item.relatedIssue?.id !== item.subject.id ? item.relatedIssue : null;
const blockedCount = item.detail?.kind === "blocker" ? item.detail.blockedTaskCount ?? 0 : 0;
const status = item.subject.status ? `${item.subject.status[0]!.toUpperCase()}${item.subject.status.slice(1).replaceAll("_", " ")}` : null;
const meta = stalledReview ? ["Review stalled", assignee].filter(Boolean).join(" · ")
: blocking ? item.whyNow
: [status, assignee].filter(Boolean).join(" · ") || undefined;
const note = stalledReview ? "No reviewer, approval or run is moving this review."
: blocked ? `Blocks ${taskLine(blocked)}${blockedCount > 1 ? ` and ${blockedCount - 1} more` : ""}`
: blockedCount > 0 ? `Blocks ${blockedCount} ${blockedCount === 1 ? "task" : "tasks"}`
: null;
const panel = <NeedsYouPanel>
{/* Without a separate blocker the task is the footer's, so the row only says its state (IA4). */}
{blocking ? <NeedsYouPanelRow icon={<FileText />} title={taskLine(blocking)} meta={meta}
onOpen={href && onOpen ? () => onOpen(href) : undefined} /> : <NeedsYouPanelRow icon={<FileText />} title={meta ?? "Blocked"} />}
{note ? <NeedsYouPanelNote>{note}</NeedsYouPanelNote> : null}
</NeedsYouPanel>;
if (stalledReview) {
return <>
{panel}
<StalledReviewActions presentation="card" footerSlot={leading} issueId={item.subject.id} companyId={companyId}
reviewPolicy={readIssueReviewPolicyMetadata(item.subject.metadata)} approveLabel="Mark task done" onResolved={() => onResolved?.()} />
</>;
}
return <>
{panel}
{footerContext}
<DecisionCardFooter leading={leading}>
{href ? <OpenButton href={href} onOpen={onOpen}>{blockingHref ? "Open blocking task" : "Open task"}</OpenButton> : null}
</DecisionCardFooter>
</>;
}
const RECOVERY_KIND_LABELS: Record<string, string> = {
missing_disposition: "No next step chosen",
deliberate_wait_without_target: "Waiting on nothing",
stranded_assigned_issue: "Task stranded",
workspace_validation: "Workspace unavailable",
configuration_validation: "Configuration missing",
active_run_watchdog: "Run gone quiet",
issue_graph_liveness: "Task needs a next step",
};
/**
* 14 "Recover a run": what stopped and the next step. Resolving a recovery
* also sets the task's next status, so it is done in the recovery control on
* the task (PAP-749 Rule 7), which the card opens.
*/
export function RecoveryCardBody({ item, leading, footerContext, onOpen }: NeedsYouCardBodyProps) {
const kind = metadataString(item, "kind");
const title = [
kind ? RECOVERY_KIND_LABELS[kind] ?? `${kind[0]!.toUpperCase()}${kind.slice(1).replaceAll("_", " ")}` : "Recovery",
item.subject.status === "escalated" ? "Recovery escalated" : "Recovery paused",
].join(" · ");
const href = item.subject.href;
return <>
<NeedsYouPanel>
<NeedsYouPanelRow icon={<TriangleAlert className="text-amber-600 dark:text-amber-300" />} title={title} />
{/* The ask above is the recovery's next action; this says where it is done. */}
<NeedsYouPanelNote>Resolve, reassign or cancel it in the recovery control on the task.</NeedsYouPanelNote>
</NeedsYouPanel>
{footerContext}
<DecisionCardFooter leading={leading}>
{href ? <OpenButton href={href} onOpen={onOpen}>Open recovery</OpenButton> : null}
</DecisionCardFooter>
</>;
}
@@ -0,0 +1,94 @@
import type { ReactNode } from "react";
import { ArrowUpRight } from "lucide-react";
import { attentionCardKind, attentionCardType, type AttentionCardType, type AttentionItem } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
/**
* The parts every Needs You card is built from (PAP-775, Paper "A · Compact
* cards"): a type label that says what to do, an inset context panel, and a
* footer with the task link on the left and the actions on the right. Each
* card type fills the panel and the actions with its own content.
*/
const CARD_TYPE_LABELS: Record<AttentionCardType, string> = {
answer_questions: "Answer questions",
make_choice: "Make a choice",
review_plan: "Review a plan",
confirm: "Confirm or decline",
suggested_tasks: "Accept suggested tasks",
review_deliverable: "Review a deliverable",
review_items: "Review items",
board_approval: "Approve a request",
join_request: "Review a join request",
tool_access: "Approve tool or secret access",
connect_service: "Connect a service",
review_task: "Review a task",
failed_run: "Fix a failed run",
budget_stop: "Resolve a budget stop",
blocked_task: "Unblock a task",
recovery: "Recover a run",
};
const APPROVAL_LABELS: Record<string, string> = {
hire_agent: "Approve an agent hire",
approve_ceo_strategy: "Approve a strategy",
budget_override_required: "Approve a budget override",
};
/** The card's type, said as what to do ("Review a plan"). */
export function needsYouCardLabel(item: AttentionItem): string {
const type = attentionCardType(item);
if (type === "board_approval") return APPROVAL_LABELS[String(item.subject.metadata?.type ?? "")] ?? CARD_TYPE_LABELS.board_approval;
if (type === "failed_run" && item.sourceKind === "agent_error_alert") return "Fix an agent error";
if (type === "budget_stop" && item.subject.metadata?.thresholdType === "soft") return "Review a budget warning";
return CARD_TYPE_LABELS[type];
}
export type NeedsYouSection = "decisions" | "problems" | "updates";
export const NEEDS_YOU_SECTIONS: Record<NeedsYouSection, { title: string }> = {
decisions: { title: "Decisions" },
problems: { title: "Problems" },
updates: { title: "Updates" },
};
export function needsYouSection(item: AttentionItem): NeedsYouSection {
return attentionCardKind(item) === "problem" ? "problems" : "decisions";
}
/** The inset panel under the ask: what the decision is about. */
export function NeedsYouPanel({ children, className }: { children: ReactNode; className?: string }) {
return <div data-needs-you-panel className={cn("flex min-w-0 flex-col gap-2 rounded-md bg-background p-3", className)}>{children}</div>;
}
/**
* One line in the panel: an icon, a title and a meta line, with ↗ when it opens
* something (a document in the side panel, a run, a person).
*/
export function NeedsYouPanelRow({ icon, title, meta, onOpen, href, label }: {
icon?: ReactNode;
title: ReactNode;
meta?: ReactNode;
onOpen?: () => void;
href?: string;
/** Accessible name for the open control; defaults to the title text. */
label?: string;
}) {
const body = <>
{icon ? <span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">{icon}</span> : null}
<span className="flex min-w-0 grow basis-0 flex-col gap-0.5">
<span className="truncate text-(length:--text-compact) font-medium leading-5 text-foreground">{title}</span>
{meta ? <span className="truncate text-xs leading-4.5 text-muted-foreground">{meta}</span> : null}
</span>
{onOpen || href ? <ArrowUpRight className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" /> : null}
</>;
const className = "flex w-full min-w-0 items-center gap-2.5 text-left";
if (href) return <a href={href} aria-label={label} className={cn(className, "hover:[&_span]:text-foreground")}>{body}</a>;
if (onOpen) return <button type="button" aria-label={label} onClick={onOpen} className={cn(className, "rounded-sm hover:[&_span]:text-foreground")}>{body}</button>;
return <div className={className}>{body}</div>;
}
/** The small grey line at the bottom of a panel. */
export function NeedsYouPanelNote({ children }: { children: ReactNode }) {
return <p data-needs-you-note className="text-xs leading-4.5 text-muted-foreground">{children}</p>;
}
@@ -0,0 +1,158 @@
import { ArrowUpRight, Check, Hand, UserRound } from "lucide-react";
import { type Agent, type AttentionItem, type AttentionReceipt, type AttentionSubject } from "@paperclipai/shared";
import { AgentAvatar } from "@/components/AgentAvatar";
import { deriveInitials } from "@/components/Identity";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { StatusIcon } from "@/components/StatusIcon";
import { Button } from "@/components/ui/button";
import {
ATTENTION_NO_CLAIM_TEXT,
attentionClaimAlternative,
attentionReceiptLine,
attentionResponderText,
describeAttentionAudience,
} from "@/lib/attention-views";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
import { timeAgo } from "@/lib/timeAgo";
import { DecisionCardFooter } from "@/components/DecisionCardFooter";
import { NeedsYouPanel, NeedsYouPanelRow, needsYouCardLabel } from "./NeedsYouCardKit";
/**
* Who is asking (PAP-775, UI2): the requester's avatar and name, agent or
* person. A system alert nobody asked for shows nothing.
*/
export function NeedsYouRequester({ item, agentMap, userLabelMap, currentUserId }: {
item: AttentionItem;
agentMap: ReadonlyMap<string, Agent>;
userLabelMap?: ReadonlyMap<string, string> | null;
currentUserId?: string | null;
}) {
const requester = item.requester;
const base = "inline-flex min-w-0 items-center gap-2 text-xs leading-4.5 text-muted-foreground";
if (requester?.kind === "user") {
if (requester.id === currentUserId) return null;
const name = userLabelMap?.get(requester.id) ?? requester.name ?? "A teammate";
return <span data-attention-requester="user" className={base} title={name}>
<Avatar size="xs" aria-hidden="true"><AvatarFallback>{deriveInitials(name)}</AvatarFallback></Avatar><span className="truncate">from {name}</span>
</span>;
}
const agent = requester?.kind === "agent" ? agentMap.get(requester.id) : undefined;
const name = requester?.name ?? agent?.name ?? item.originAgentName;
if (!name) return null;
return <span data-attention-requester="agent" className={base} title={name}>
<AgentAvatar agent={agent ?? { id: requester?.id, name }} name={name} size={20} />
<span className="truncate">from {name}</span>
</span>;
}
/**
* The card's type, said as what to do (PAP-775, Paper "A · Compact cards").
* A card for someone else puts their name first ("@Dotta Confirm or decline");
* your own card doesn't name you. Why the card is yours stays in the tooltip.
*/
export function NeedsYouTypeLabel({ item, currentUserId, userLabelMap }: {
item: AttentionItem;
currentUserId: string | null | undefined;
userLabelMap?: ReadonlyMap<string, string> | null;
}) {
const description = currentUserId ? describeAttentionAudience(item, currentUserId) : null;
const forUserId = description?.kind === "other" ? description.userId : null;
const why = description?.kind === "mine" ? `Why me? ${description.text}${description.transferredByUserId ? ` @${userLabelMap?.get(description.transferredByUserId) ?? "someone"}` : ""}` : undefined;
return <span data-attention-type-label className="min-w-0 truncate text-xs leading-4.5 text-muted-foreground" title={why}>
{forUserId ? <span data-attention-for-user={forUserId} className="text-foreground">@{userLabelMap?.get(forUserId) ?? "someone"} </span> : null}
{needsYouCardLabel(item)}
</span>;
}
/**
* An ownerless card's corner (PAP-775, A2): "No owner yet" with Claim, or a
* link to assign it on its task. Your own and other people's cards say whose
* they are in the type label instead. The server re-checks every action.
*/
export function NeedsYouAudience({ item, currentUserId, userLabelMap, onClaim, claiming = false }: {
item: AttentionItem;
currentUserId: string | null | undefined;
userLabelMap?: ReadonlyMap<string, string> | null;
onClaim?: (item: AttentionItem) => void;
claiming?: boolean;
}) {
const description = currentUserId ? describeAttentionAudience(item, currentUserId) : null;
if (!description || description.kind === "mine" || description.kind === "other") return null;
if (description.kind === "company" && !description.claimable) return null;
const responders = attentionResponderText(item, description, userLabelMap);
const alternative = onClaim ? attentionClaimAlternative(item) : null;
return <div className="flex shrink-0 items-center gap-2 text-xs leading-4.5 text-muted-foreground" data-attention-audience={description.kind} title={responders ?? undefined}>
{description.kind === "pool" ? <span className="inline-flex items-center gap-1"><UserRound className="size-3.5" aria-hidden="true" />No owner yet</span> : null}
{description.claimable && onClaim ? <Button type="button" variant="outline" size="xs" disabled={claiming} onClick={(event) => { event.stopPropagation(); onClaim(item); }}>
<Hand className="h-3 w-3" />{claiming ? "Claiming…" : "Claim"}
</Button> : null}
{alternative?.kind === "assign" ? <Button asChild variant="outline" size="xs">
<Link to={alternative.href} disableIssueQuicklook data-attention-open-to-assign onClick={(event) => event.stopPropagation()}>Open to assign</Link>
</Button> : null}
{alternative?.kind === "none" ? <span data-attention-no-claim className="sr-only">{ATTENTION_NO_CLAIM_TEXT}</span> : null}
</div>;
}
/** The task a card belongs to, whichever field the feed put it in. */
export function needsYouTaskRef(item: AttentionItem): AttentionSubject | null {
return item.relatedIssue ?? (item.subject.kind === "issue" ? item.subject : null);
}
/**
* The footer's task link (PAP-775, UI4, UI5): the task's standard status icon,
* then its ID, then (when there is room) its title. Opens the exact spot in
* the task (IA5).
*/
export function NeedsYouTaskLink({ task, short = false, onOpen }: {
task: AttentionSubject | null;
short?: boolean;
onOpen: (href: string) => void;
}) {
if (!task?.href) return null;
const href = task.href;
return <button type="button" data-attention-task-link title={task.title ?? undefined} onClick={() => onOpen(href)}
className={cn("flex min-w-0 items-center gap-1.5 text-left text-(length:--text-compact) leading-5 text-muted-foreground hover:text-foreground", short ? "shrink-0" : "max-w-full")}>
{task.status ? <StatusIcon status={task.status} size="sm" /> : null}
<span className="min-w-0 truncate">{task.identifier ?? "Open task"}{short || !task.title ? null : ` · ${task.title}`}</span>
<ArrowUpRight className="size-4 shrink-0" />
</button>;
}
/**
* What someone else did with your request (PAP-775, Paper card 15; PAP-749
* plan §6): an Updates card. It isn't in the badge and has no ⋯ menu; Dismiss
* hides it for you, and it leaves on its own after 7 days.
*/
export function NeedsYouReceiptCard({ receipt, userLabelMap, onDismiss, onOpen, dismissing = false }: {
receipt: AttentionReceipt;
userLabelMap?: ReadonlyMap<string, string> | null;
onDismiss: (receipt: AttentionReceipt) => void;
onOpen: (href: string) => void;
dismissing?: boolean;
}) {
const actor = userLabelMap?.get(receipt.actorUserId) ?? "Someone";
const href = receipt.href ?? receipt.issue?.href ?? null;
const negative = receipt.outcome === "rejected" || receipt.outcome === "withdrawn";
return <article className="coordinator-decision-card space-y-3" data-attention-receipt={receipt.outcome} aria-label={attentionReceiptLine(receipt, userLabelMap)}>
<div className="flex items-center justify-between gap-4">
<span className="text-xs leading-4.5 text-muted-foreground">View a request update</span>
<div className="flex shrink-0 items-center gap-3 text-xs leading-4.5 text-muted-foreground">
<span className="inline-flex items-center gap-1"><Avatar size="xs" aria-hidden="true"><AvatarFallback>{deriveInitials(actor)}</AvatarFallback></Avatar>from {actor}</span>
<time dateTime={receipt.createdAt} title={new Date(receipt.createdAt).toLocaleString()}>{timeAgo(receipt.createdAt)}</time>
</div>
</div>
<div className="space-y-1.5">
<h2>{receipt.title ?? "Your request"}</h2>
{receipt.reason ? <p className="text-sm text-muted-foreground">{receipt.reason}</p> : null}
</div>
<NeedsYouPanel>
<NeedsYouPanelRow icon={negative ? <StatusIcon status="cancelled" /> : <Check className="text-(--status-task-icon-done)" />}
title={attentionReceiptLine(receipt, userLabelMap).replace(/@/g, "")} />
</NeedsYouPanel>
<DecisionCardFooter leading={receipt.issue?.href ? <NeedsYouTaskLink task={{ kind: "issue", id: receipt.issue.id, companyId: "", title: receipt.issue.title, identifier: receipt.issue.identifier, status: null, href: receipt.issue.href, metadata: {} }} onOpen={onOpen} /> : null}>
<Button type="button" variant="ghost" disabled={dismissing} onClick={() => onDismiss(receipt)}>Dismiss</Button>
{href ? <Button type="button" variant="outline" onClick={() => onOpen(href)}>View request</Button> : null}
</DecisionCardFooter>
</article>;
}
@@ -0,0 +1,41 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { QuestionForm } from "./QuestionForm";
vi.mock("./TaskChatRichInput", () => ({ TaskChatRichInput: () => null }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("question prompt Markdown", () => {
it.each(["default", "decision"] as const)("renders rich prompts as inert reference content in %s questions", async presentation => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
try {
await act(async () => root.render(<QueryClientProvider client={client}><ThemeProvider>
<QuestionForm id="rich-prompts" presentation={presentation} onSubmit={vi.fn()} questionSet={{
schema: "paperclip.question_set.v1", questions: [{
id: "first", required: true, answerMode: "single_select",
prompt: "**Choose** a [plan](https://example.com/plan):\n\n- Fast\n- Careful\n\n![Reference](https://example.com/reference.png)",
options: [{ id: "a", label: "Option A" }],
}, { id: "second", required: false, answerMode: "text", prompt: "Any notes?" }],
}} />
</ThemeProvider></QueryClientProvider>));
expect(container.querySelector("strong")?.textContent).toBe("Choose");
expect(container.querySelector('a[href="https://example.com/plan"]')?.textContent).toBe("plan");
expect([...container.querySelectorAll("li")].map(item => item.textContent)).toEqual(["Fast", "Careful"]);
expect(container.querySelector("[data-markdown-image-reference]")?.textContent).toContain("Reference");
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("p div")).toBeNull();
} finally {
await act(async () => root.unmount());
client.clear();
container.remove();
}
});
});
@@ -22,8 +22,8 @@ describe("QuestionForm initial text", () => {
beforeEach(() => { localStorage.clear(); submit.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); });
afterEach(async () => { await act(async () => root.unmount()); container.remove(); localStorage.clear(); });
const input = () => container.querySelector("textarea")!;
async function render(set = questionSet, initialResponse?: PaperclipQuestionResponse) {
await act(async () => root.render(<QuestionForm id="edit" questionSet={set} initialResponse={initialResponse} draftKey="question-initial-text" onSubmit={submit} />));
async function render(set = questionSet, initialResponse?: PaperclipQuestionResponse, presentation: "default" | "decision" = "default") {
await act(async () => root.render(<QuestionForm id="edit" presentation={presentation} questionSet={set} initialResponse={initialResponse} draftKey="question-initial-text" onSubmit={submit} />));
}
async function edit(text: string) {
await act(async () => {
@@ -32,6 +32,40 @@ describe("QuestionForm initial text", () => {
});
}
async function remount(set = questionSet) { await act(async () => root.unmount()); root = createRoot(container); await render(set); }
it.each([
["default", "single_select", "radio"],
["decision", "single_select", "radio"],
["default", "multi_select", "checkbox"],
] as const)("keeps Other in the choice group with matching selection behavior (%s, %s)", async (presentation, answerMode, role) => {
await render({ schema: "paperclip.question_set.v1", questions: [{
id: "choice", prompt: "Pick an option", required: true, answerMode,
options: [{ id: "a", label: "Option A" }, { id: "b", label: "Option B" }],
customAnswer: { enabled: true },
}] }, undefined, presentation);
const choices = [...container.querySelectorAll<HTMLButtonElement>(`[role="${role}"]`)];
const other = choices.find(button => button.textContent === "Other")!;
expect(choices).toHaveLength(3);
expect(other.closest('[role="radiogroup"], [role="group"]')).toBe(choices[0]!.closest('[role="radiogroup"], [role="group"]'));
expect(input()).toBeNull();
await act(async () => choices[0]!.click());
await act(async () => other.click());
expect(other.getAttribute("aria-checked")).toBe("true");
expect(choices[0]!.getAttribute("aria-checked")).toBe(answerMode === "single_select" ? "false" : "true");
await edit("My alternative");
await act(async () => other.click());
if (answerMode === "single_select") {
expect(other.getAttribute("aria-checked")).toBe("true");
expect(input().value).toBe("My alternative");
await act(async () => choices[0]!.click());
}
expect(other.getAttribute("aria-checked")).toBe("false");
expect(input()).toBeNull();
await act(async () => [...container.querySelectorAll("button")].find(button => button.textContent === "Submit answers")!.click());
expect(submit).toHaveBeenCalledOnce();
expect(submit.mock.calls[0]![0].answers.choice.selectedOptionIds).toEqual(["a"]);
expect(submit.mock.calls[0]![0].answers.choice.customText).toBeUndefined();
});
it("shows exact editable text without sending until the operator submits", async () => {
await render();
expect(input().value).toBe(" Draft\n漢字\n");
+53 -43
View File
@@ -12,6 +12,7 @@ import type {
} from "@paperclipai/adapter-utils";
import type { MentionOption } from "@/components/MarkdownEditor";
import { DecisionCardFooter } from "@/components/DecisionCardFooter";
import { NeedsYouPanel } from "@/components/coordinator/NeedsYouCardKit";
import { MarkdownBody } from "@/components/MarkdownBody";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -46,6 +47,8 @@ export interface QuestionFormProps {
footerSlot?: ReactNode;
id: string;
presentation?: "default" | "decision";
/** A decision card's ask already is this (single) question's prompt. */
promptStated?: boolean;
questionSet: PaperclipQuestionSet;
initialResponse?: PaperclipQuestionResponse | null;
implicitCustomAnswer?: boolean;
@@ -244,6 +247,7 @@ function QuestionFormForRequest({
footerSlot,
id,
presentation = "default",
promptStated = false,
questionSet,
initialResponse,
implicitCustomAnswer = false,
@@ -296,7 +300,7 @@ function QuestionFormForRequest({
const [inputUploading, setInputUploading] = useState(false);
const [error, setError] = useState<FormError | null>(null);
const [attemptedQuestions, setAttemptedQuestions] = useState<Record<string, boolean>>({});
const promptRef = useRef<HTMLParagraphElement>(null);
const promptRef = useRef<HTMLDivElement>(null);
const previousPage = useRef(page);
useEffect(() => {
@@ -384,7 +388,8 @@ function QuestionFormForRequest({
}
function toggleCustom() {
const active = !isCustomActive;
if (disabled || working || inputUploading) return;
const active = !multiple || !isCustomActive;
setCustomActive((current) => ({ ...current, [question.id]: active }));
updateAnswer({
...answer,
@@ -422,6 +427,7 @@ function QuestionFormForRequest({
});
if (draftKey) clearDraft(draftKey);
} catch (cause) {
if (cause instanceof DOMException && cause.name === "AbortError") return;
setError({
message:
cause instanceof Error
@@ -519,41 +525,21 @@ function QuestionFormForRequest({
}
void submit();
}
return (
<div
key={question.id}
className="tc-question-page"
onKeyDown={(event) => {
if (
disabled ||
working ||
event.repeat ||
question.answerMode === "text" ||
event.metaKey ||
event.ctrlKey ||
event.altKey
)
return;
const target = event.target as HTMLElement;
if (target.matches("input, textarea, [contenteditable='true']")) return;
const optionIndex = Number.parseInt(event.key, 10) - 1;
const option = visibleOptions[optionIndex];
if (!option || optionIndex < 0 || optionIndex > 8) return;
event.preventDefault();
toggleOption(option.id);
}}
>
// A Needs You decision card puts the question in its inset panel under the
// card's ask, numbered when there are several (PAP-775).
const decision = presentation === "decision";
const body = <>
{questionSet.description ? (
<div
role="region"
aria-label="Question context"
tabIndex={0}
className="mb-3 max-h-96 overflow-auto text-sm text-muted-foreground"
className={cn("max-h-96 overflow-auto text-muted-foreground", decision ? "text-xs" : "mb-3 text-sm")}
>
<MarkdownBody mediaMode="reference" linkIssueReferences={false}>{questionSet.description}</MarkdownBody>
</div>
) : null}
{question.answerMode === "text" && presentation !== "decision" ? (
{question.answerMode === "text" && !decision ? (
<div className="mb-2 flex items-center gap-3 text-xs text-muted-foreground">
{question.answerMode === "text" ? <span>Write an answer</span> : null}
</div>
@@ -564,26 +550,24 @@ function QuestionFormForRequest({
{pagination}
</TaskChatComposerTakeoverControls>
) : (
<div className="mb-2 flex justify-end text-xs text-muted-foreground">
<div className={cn("flex justify-end text-xs text-muted-foreground", !decision && "mb-2")}>
{pagination}
</div>
)
) : null}
<div>
{question.header && presentation !== "decision" ? (
{question.header && !decision ? (
<p className="mb-1 text-xs font-medium text-muted-foreground">
{question.header}
</p>
) : null}
<p
ref={promptRef}
tabIndex={-1}
id={`${id}-${question.id}-prompt`}
data-decision-prompt={presentation === "decision" ? true : undefined}
className={presentation === "decision" ? "text-lg font-medium leading-relaxed text-foreground" : "text-sm font-medium leading-5 text-foreground"}
>
{question.prompt}
</p>
<div ref={promptRef} tabIndex={-1} className={cn("leading-5", decision ? "text-(length:--text-compact)" : "text-sm", decision && promptStated && "sr-only")}>
<div id={`${id}-${question.id}-prompt`} className="flex items-start gap-1 font-medium text-foreground">
{decision && questionSet.questions.length > 1 ? <span>{page + 1}.</span> : null}
<MarkdownBody mediaMode="reference" linkIssueReferences={false} className={cn("min-w-0", decision && "text-(length:--text-compact)")}>{question.prompt}</MarkdownBody>
</div>
{decision ? <span aria-hidden className="text-muted-foreground"> · {question.required ? "Required" : "Optional"}</span> : null}
</div>
{question.helpText ? (
<p className="mt-1 text-xs leading-4 text-muted-foreground">
{question.helpText}
@@ -591,7 +575,7 @@ function QuestionFormForRequest({
) : null}
</div>
{question.answerMode === "text" ? (
<div className="mt-3">
<div className={decision ? undefined : "mt-3"}>
<TaskChatRichInput
questionAnswer
ariaLabelledBy={`${id}-${question.id}-prompt`}
@@ -599,10 +583,10 @@ function QuestionFormForRequest({
value={answer.text ?? ""}
disabled={disabled || working != null}
onChange={(value) => updateAnswer({ text: value })}
placeholder="Write your answer"
placeholder={decision ? "Add context for the agent…" : "Write your answer"}
imageUploadHandler={imageUploadHandler}
mentions={mentions}
autoFocus
autoFocus={!decision}
onUploadingChange={setInputUploading}
onSubmit={() => {
progressOrSubmit();
@@ -612,7 +596,7 @@ function QuestionFormForRequest({
</div>
) : (
<div
className="mt-3 grid gap-1.5"
className={cn("grid gap-1.5", !decision && "mt-3")}
role={multiple ? "group" : "radiogroup"}
aria-labelledby={`${id}-${question.id}-prompt`}
>
@@ -689,6 +673,32 @@ function QuestionFormForRequest({
{currentError && attemptedQuestions[question.id] ? (
<p role="alert" className="mt-2 text-xs text-destructive">{currentError}</p>
) : null}
</>;
return (
<div
key={question.id}
className="tc-question-page"
onKeyDown={(event) => {
if (
disabled ||
working ||
event.repeat ||
question.answerMode === "text" ||
event.metaKey ||
event.ctrlKey ||
event.altKey
)
return;
const target = event.target as HTMLElement;
if (target.matches("input, textarea, [contenteditable='true']")) return;
const optionIndex = Number.parseInt(event.key, 10) - 1;
const option = visibleOptions[optionIndex];
if (!option || optionIndex < 0 || optionIndex > 8) return;
event.preventDefault();
toggleOption(option.id);
}}
>
{decision ? <NeedsYouPanel>{body}</NeedsYouPanel> : body}
<div aria-live="assertive">
{errorMessage ? (
<div className="mt-2 rounded-sm border border-destructive/60 bg-destructive/10 px-2.5 py-2 text-sm text-destructive">
@@ -61,6 +61,7 @@ import {
type SuggestTasksInteraction,
type SuggestedTaskTreeNode,
} from "@/lib/issue-thread-interactions";
import { claimReviseDraftWork, clearReviseDraft, getReviseDraft, releaseReviseDraftWork, reviseDraftKey, updateReviseDraft, useReviseDraft } from "@/lib/revise-draft-store";
import { cn } from "@/lib/utils";
import { QuestionForm } from "./QuestionForm";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
@@ -849,11 +850,12 @@ function ConfirmationCard({
mentions?: MentionOption[];
}) {
const preparingApproval = isInteractionPreparingApproval(interaction);
const [rejecting, setRejecting] = useState(false);
const [reason, setReason] = useState(() =>
draftKey ? loadStructuredDraft(draftKey, "") : "",
);
const [working, setWorking] = useState<"accept" | "reject" | null>(null);
const target = interaction.payload.target;
const sharedKey = reviseDraftKey(interaction.id, target?.type === "issue_document" ? target.revisionId : null);
const [draft, updateDraft] = useReviseDraft(sharedKey);
const { revising: rejecting, reason, working } = draft;
const setRejecting = (revising: boolean) => revising ? updateDraft({ revising }) : clearReviseDraft(sharedKey);
const setReason = (value: string) => updateDraft({ reason: value });
const [actionError, setActionError] = useState<string | null>(null);
const [revisionUploading, setRevisionUploading] = useState(false);
const collectsRejectReason = Boolean(
@@ -865,8 +867,17 @@ function ConfirmationCard({
interaction.payload.target?.type === "issue_document" &&
interaction.payload.target.key === "plan";
useEffect(() => {
if (draftKey) saveStructuredDraft(draftKey, reason);
}, [draftKey, reason]);
if (!draftKey) return;
const legacyReason = loadStructuredDraft(draftKey, "");
const current = getReviseDraft(sharedKey);
if (legacyReason && !current.reason && !current.revising && !current.working) {
updateReviseDraft(sharedKey, { reason: legacyReason, revising: true });
}
clearDraft(draftKey);
}, [draftKey, sharedKey]);
useEffect(() => {
if (interaction.status !== "pending") clearReviseDraft(sharedKey);
}, [interaction.status, sharedKey]);
async function resolve(action: "accept" | "reject") {
if (
action === "reject" &&
@@ -874,16 +885,17 @@ function ConfirmationCard({
!reason.trim()
)
return;
setWorking(action);
if (!claimReviseDraftWork(sharedKey, action)) return;
setActionError(null);
try {
if (action === "accept") await onAcceptInteraction?.(interaction);
else await onRejectInteraction?.(interaction, reason.trim() || undefined);
if (draftKey) clearDraft(draftKey);
clearReviseDraft(sharedKey);
} catch (error) {
setActionError(errorMessage(error));
} finally {
setWorking(null);
releaseReviseDraftWork(sharedKey);
}
}
@@ -17,6 +17,7 @@ import type { Agent, HeartbeatRun, IssueWorkMode } from "@paperclipai/shared";
import { agentsApi } from "@/api/agents";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { CoordinatorPresentation } from "@/components/coordinator/CoordinatorPresentation";
import { ThemeProvider } from "@/context/ThemeContext";
import { DRAFT_DEBOUNCE_MS } from "../../lib/composer-draft";
import {
loadDraftAttachments,
@@ -215,7 +216,7 @@ afterEach(() => {
});
function render(ui: ReactElement) {
flushSync(() => root!.render(ui));
flushSync(() => root!.render(<ThemeProvider>{ui}</ThemeProvider>));
}
async function flushAsync() {
@@ -63,6 +63,64 @@ describe("LiveUpdatesProvider issue invalidation", () => {
expect(client.getQueryState(unrelatedKey)?.isInvalidated).toBe(false);
client.clear();
});
describe("one record, many views (PAP-771)", () => {
// Signed-in sessions get only action, entityType and entityId; the
// server strips `details` from activity events.
const seed = (client: QueryClient, keys: ReadonlyArray<readonly unknown[]>) => {
for (const key of keys) client.setQueryData(key, { cached: true });
};
const invalidated = (client: QueryClient, key: readonly unknown[]) => client.getQueryState(key)?.isInvalidated === true;
it.each(["attention.claimed", "attention.transferred"])("refreshes the task's interactions after %s", action => {
const client = new QueryClient();
client.setQueryData(queryKeys.issues.detail("issue-1"), { id: "issue-1", identifier: "NEE-39" });
const feed = [...queryKeys.attention("company-1"), "user-2", "mine", "coordinator", "pending"];
seed(client, [queryKeys.issues.interactions("issue-1"), queryKeys.issues.interactions("NEE-39"), queryKeys.issues.interactions("issue-2"), feed]);
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
entityType: "issue", entityId: "issue-1", action,
}, { userId: "user-2", agentId: null });
expect(invalidated(client, queryKeys.issues.interactions("issue-1"))).toBe(true);
expect(invalidated(client, queryKeys.issues.interactions("NEE-39"))).toBe(true);
expect(invalidated(client, feed)).toBe(true);
expect(invalidated(client, queryKeys.issues.interactions("issue-2"))).toBe(false);
client.clear();
});
it("refreshes an open approval page when the approval is answered elsewhere", () => {
const client = new QueryClient();
const own = [queryKeys.approvals.detail("approval-1"), queryKeys.approvals.comments("approval-1"), queryKeys.approvals.issues("approval-1")];
seed(client, [...own, queryKeys.approvals.detail("approval-2")]);
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
entityType: "approval", entityId: "approval-1", action: "approval.approved",
}, { userId: "user-2", agentId: null });
for (const key of own) expect(invalidated(client, key)).toBe(true);
expect(invalidated(client, queryKeys.approvals.detail("approval-2"))).toBe(false);
client.clear();
});
it("refreshes an open decision, whose detail key sits outside the company prefix", () => {
const client = new QueryClient();
seed(client, [queryKeys.decisions.detail("decision-1"), queryKeys.decisions.detail("decision-2")]);
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
entityType: "decision", entityId: "decision-1", action: "decision.decided",
}, { userId: "user-2", agentId: null });
expect(invalidated(client, queryKeys.decisions.detail("decision-1"))).toBe(true);
expect(invalidated(client, queryKeys.decisions.detail("decision-2"))).toBe(false);
client.clear();
});
it.each(["budget.incident_resolved", "budget.hard_threshold_crossed", "budget.policy_upserted"])("refreshes the budget owner's Needs You after %s", action => {
const client = new QueryClient();
const feed = [...queryKeys.attention("company-1"), "scott", "mine", "coordinator", "pending"];
const keys = [feed, queryKeys.budgets.overview("company-1"), queryKeys.approvals.list("company-1", "pending"), queryKeys.approvals.detail("approval-9")];
seed(client, keys);
__liveUpdatesTestUtils.invalidateActivityQueries(client, "company-1", {
entityType: action === "budget.policy_upserted" ? "budget_policy" : "budget_incident", entityId: "incident-1", action,
}, { userId: "scott", agentId: null });
for (const key of keys) expect(invalidated(client, key)).toBe(true);
client.clear();
});
});
it("drops cached decision content when its access is revoked", () => {
const client = new QueryClient();
const receiptKey = ["issues", "decision-receipts", "chat-1", "user-1"];
+35
View File
@@ -1571,7 +1571,16 @@ function invalidateActivityQueries(
queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(companyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
const decisionProjectionChanged = entityType === "decision" ||
// Approvals are cards too: a decision by another person must leave every open list.
entityType === "approval" ||
// So are budget stops. Signed-in sessions get no `details`, so the
// `handledForUserId` check below never sees a budget receipt (PAP-771).
entityType === "budget_incident" || entityType === "budget_policy" ||
["inbox.dismissed", "inbox.snoozed", "inbox.restored"].includes(action ?? "") ||
// A claim moves a card between people's views (PAP-749), so open pages refetch.
action?.startsWith("attention.") ||
// A receipt for the person a card was for (PAP-749 plan §6): their For you updates without a reload.
typeof details?.handledForUserId === "string" ||
action?.startsWith("issue.thread_interaction_") ||
action === "issue.status_decision_recorded" ||
action === "issue.conversation_session_started" ||
@@ -1774,6 +1783,11 @@ function invalidateActivityQueries(
...invalidationOptions,
});
}
if (action === "attention.claimed" || action === "attention.transferred") {
// A claim or transfer stores a new addressee (PAP-749), so the
// audience line in task detail, chat and the card resolver changes.
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) });
}
if (
action?.startsWith("issue.thread_interaction_") ||
((action === "issue.updated" ||
@@ -1856,6 +1870,27 @@ function invalidateActivityQueries(
queryClient.invalidateQueries({
queryKey: queryKeys.approvals.list(companyId),
});
if (entityId) {
// An open approval page shows the answer given from Needs You or a task.
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.detail(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.comments(entityId) });
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.issues(entityId) });
}
return;
}
if (entityType === "decision") {
// Detail is keyed outside the `["decisions", companyId]` prefix above.
if (entityId) queryClient.invalidateQueries({ queryKey: queryKeys.decisions.detail(entityId) });
return;
}
if (entityType === "budget_incident" || entityType === "budget_policy") {
queryClient.invalidateQueries({ queryKey: queryKeys.budgets.overview(companyId) });
// Raise limit and Keep paused also answer the hard stop's override
// approval (PAP-768). The live payload has no approval ID.
queryClient.invalidateQueries({ queryKey: ["approvals", companyId] });
queryClient.invalidateQueries({ queryKey: ["approvals", "detail"] });
return;
}
@@ -201,6 +201,12 @@ function button(label: string) {
) as HTMLButtonElement | undefined;
}
it("explains all-agent defaults consistently on compact connector cards", () => {
renderNode(<ConnectionIntentInteractionBody interaction={pendingConnectionIntentInteraction} currentUserId={issueThreadInteractionFixtureMeta.currentUserId} addresseeLabel="Carol" compact />);
expect(document.body.textContent).toContain("New connections default to all agents");
expect(document.body.textContent).not.toContain("Access is added only for this agent");
});
beforeEach(() => {
sessionStorage.clear();
emailCredentialsMock.mockReset().mockResolvedValue([]);
@@ -36,9 +36,14 @@ import {
} from "./ConnectionSetupFlow";
import { DecisionCardFooter } from "@/components/DecisionCardFooter";
import { NeedsYouPanel, NeedsYouPanelNote, NeedsYouPanelRow } from "@/components/coordinator/NeedsYouCardKit";
export interface ConnectionIntentInteractionBodyProps {
footerSlot?: ReactNode;
/** A Needs You card (PAP-775): the service in an inset panel, the verbs in the footer. */
compact?: boolean;
/** The Needs You page's artifact row, first in the panel. */
artifactRow?: ReactNode;
interaction: ConnectionIntentInteraction;
currentUserId?: string | null;
addresseeLabel: string;
@@ -48,6 +53,8 @@ export interface ConnectionIntentInteractionBodyProps {
export function ConnectionIntentInteractionBody({
footerSlot,
compact = false,
artifactRow,
interaction,
currentUserId,
addresseeLabel,
@@ -74,6 +81,14 @@ export function ConnectionIntentInteractionBody({
const provider = isAi ? aiProviderSchema.safeParse(interaction.payload.serviceSlug).data : undefined;
const serviceName = provider ? AI_CONNECTION_CAPABILITIES[provider].name : interaction.payload.serviceName;
const isEmail = interaction.payload.purpose === "channel" && interaction.payload.serviceSlug === "agentmail";
const serviceLogo = <AppLogo
name={interaction.payload.serviceName}
brandKey={interaction.payload.serviceSlug.startsWith("connection:") ? undefined : interaction.payload.serviceSlug}
logoUrl={interaction.payload.serviceLogoUrl}
darkLogoUrl={interaction.payload.serviceDarkLogoUrl}
size={20}
compact
/>;
const accessRequest = interaction.payload.accessRequest;
const agentQuery = useQuery({
queryKey: ["agents", "detail", interaction.payload.requestingAgentId, interaction.companyId],
@@ -345,6 +360,38 @@ export function ConnectionIntentInteractionBody({
const needsRetry = interaction.payload.phase === "needs_retry";
const authorizing = interaction.payload.phase === "authorizing";
if (accessRequest && compact) {
const busy = completeMutation.isPending || declineMutation.isPending;
const askFirst = accessRequest.tools.filter(tool => tool.permission === "ask_first").length;
return <div id={focusTargetId} ref={focusTargetRef} tabIndex={-1} data-testid="connection-intent-focus-target">
<div data-testid="connection-intent-access-request" className="space-y-4">
<NeedsYouPanel>
{artifactRow}
<NeedsYouPanelRow icon={serviceLogo} title={`${interaction.payload.serviceName} · ${accessRequest.connectionName}`}
meta={`${accessRequest.tools.length} ${accessRequest.tools.length === 1 ? "tool" : "tools"} for ${interaction.payload.requestingAgentName}`} />
<ul aria-label="Tool permissions" className="flex max-h-48 flex-col gap-1 overflow-y-auto text-xs">
{accessRequest.tools.map(tool => <li key={tool.catalogEntryId} className="flex items-start justify-between gap-3">
<span className="min-w-0 break-all font-mono text-foreground">{tool.toolName}</span>
<span className="shrink-0 text-muted-foreground">{tool.permission === "allowed" ? "Allowed" : "Ask first"}</span>
</li>)}
</ul>
<NeedsYouPanelNote>
Access applies to {interaction.payload.requestingAgentName} only{askFirst > 0 ? ` · ${askFirst} still ${askFirst === 1 ? "asks" : "ask"} you first` : ""}.
</NeedsYouPanelNote>
</NeedsYouPanel>
{setupView.kind === "error" ? <QueryErrorState error={setupView.error} onRetry={setupView.retry} /> : null}
{completeMutation.error || declineMutation.error ? <p role="alert" className="text-sm text-destructive">{describeError(completeMutation.error ?? declineMutation.error).body}</p> : null}
{setupQuery.data?.canGrantAccess === false ? <p role="status" className="text-sm text-muted-foreground">Connection manager required.</p> : null}
<DecisionCardFooter leading={footerSlot}>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => declineMutation.mutate()}>Deny</Button>
<Button size="sm" disabled={busy || !setupQuery.data?.canGrantAccess} onClick={() => completeMutation.mutate(accessRequest.connectionId)}>
{completeMutation.isPending ? "Granting access…" : "Grant access"}
</Button>
</DecisionCardFooter>
</div>
</div>;
}
if (accessRequest) {
const busy = completeMutation.isPending || declineMutation.isPending;
return <div id={focusTargetId} ref={focusTargetRef} tabIndex={-1} data-testid="connection-intent-focus-target">
@@ -457,6 +504,21 @@ export function ConnectionIntentInteractionBody({
onCancel={() => { closeSetup(); returnFocusToCard(); }}
/> : setupContent;
const headline = readyForAdoption ? `Use your ${aiProviderName} account` : needsOwnAiConnection ? `Connect your ${aiProviderName} account` : isAi ? `${aiProviderName} authentication required` : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`;
const description = readyForAdoption
? `Your account is connected. Use it for ${interaction.payload.requestingAgentName} to resume this task.`
: needsOwnAiConnection
? `${interaction.payload.requestingAgentName} needs your own AI connection. Connect here and the task will resume automatically.`
: interaction.payload.purpose === "ai"
? "This task can’t run until the agent has a valid AI connection. Connect here and the task will resume automatically."
: isChatHandoff ? "Complete the human sign-in step in the saved setup. Your task continues when the connection is ready."
: isEmail ? "Connect AgentMail to create an email address for this agent."
: "New connections default to all agents; you can choose fewer during setup. Reusing an account adds access for this agent.";
// Paper "Connect a service": a plain connect names the service and its state
// in the panel; AI, email and chat setups keep their own headline there.
const plainConnect = !isAi && !isEmail && !isChatHandoff;
const retryMessage = "Authorization didn’t finish. Your previous choices are safe; try again.";
return (
<div
id={focusTargetId}
@@ -465,7 +527,16 @@ export function ConnectionIntentInteractionBody({
data-testid="connection-intent-focus-target"
>
<div data-testid="connection-intent-actions">
<div className="flex items-start gap-3">
{compact ? <div className="space-y-3">
<p className="text-sm text-muted-foreground">{description}</p>
<NeedsYouPanel>
{artifactRow}
<NeedsYouPanelRow icon={serviceLogo} title={plainConnect ? interaction.payload.serviceName : headline}
meta={plainConnect ? authorizing ? "Authorizing…" : "Not connected" : undefined} />
{needsRetry ? <NeedsYouPanelNote>{retryMessage}</NeedsYouPanelNote>
: plainConnect ? <NeedsYouPanelNote>Requested by {interaction.payload.requestingAgentName}.</NeedsYouPanelNote> : null}
</NeedsYouPanel>
</div> : <div className="flex items-start gap-3">
<AppLogo
name={interaction.payload.serviceName}
logoUrl={interaction.payload.serviceLogoUrl}
@@ -474,27 +545,18 @@ export function ConnectionIntentInteractionBody({
/>
<div>
<p className="font-medium text-foreground">
{readyForAdoption ? `Use your ${aiProviderName} account` : needsOwnAiConnection ? `Connect your ${aiProviderName} account` : isAi ? `${aiProviderName} authentication required` : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}
{headline}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{readyForAdoption
? `Your account is connected. Use it for ${interaction.payload.requestingAgentName} to resume this task.`
: needsOwnAiConnection
? `${interaction.payload.requestingAgentName} needs your own AI connection. Connect here and the task will resume automatically.`
: interaction.payload.purpose === "ai"
? "This task can’t run until the agent has a valid AI connection. Connect here and the task will resume automatically."
: isChatHandoff ? "Complete the human sign-in step in the saved setup. Your task continues when the connection is ready."
: isEmail ? "Connect AgentMail to create an email address for this agent."
: "New connections default to all agents; you can choose fewer during setup. Reusing an account adds access for this agent."}
{description}
</p>
</div>
</div>
</div>}
{needsRetry ? (
{needsRetry && !compact ? (
<p className="mt-4 flex items-center gap-2 text-sm text-destructive">
<RotateCcw className="h-4 w-4" />
Authorization didn’t finish. Your previous choices are safe; try
again.
{retryMessage}
</p>
) : null}
@@ -530,13 +592,14 @@ export function ConnectionIntentInteractionBody({
<Button type="button">
{authorizing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
) : compact ? null : (
<Plug className="h-4 w-4" />
)}
{authorizing
? "Continue setup"
: needsRetry
? "Try again"
: compact ? `Connect ${interaction.payload.serviceName}`
: setupQuery.data?.existingConnections.length ? "Connect / Use existing" : "Connect"}
</Button>
</DialogTrigger>
+119
View File
@@ -0,0 +1,119 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionItem } from "@paperclipai/shared";
import { queryKeys } from "@/lib/queryKeys";
import { useAttentionCloseActions, useClaimAttentionItem, type AttentionWithdrawAllResult } from "./useAttentionViews";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const fixture = vi.hoisted(() => ({
close: vi.fn(),
claim: vi.fn(),
transfer: vi.fn(),
pushToast: vi.fn(),
}));
vi.mock("@/api/attention", () => ({ attentionApi: { close: fixture.close, claim: fixture.claim, transfer: fixture.transfer } }));
vi.mock("@/api/inboxDismissals", () => ({ inboxDismissalsApi: { restore: vi.fn() } }));
vi.mock("@/context/ToastContext", () => ({ useOptionalToastActions: () => ({ pushToast: fixture.pushToast }) }));
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ userId: "scott" }) }));
const item = (id: string) => ({ id, sourceKind: "issue_thread_interaction", subject: { id, title: id }, dismissalKey: `attention:${id}` }) as unknown as AttentionItem;
const alreadyClosed = Object.assign(new Error("Already answered"), {
status: 409,
body: { error: "Already answered", details: { code: "attention_already_closed", resolvedByUserId: "dotta", resolvedByAgentId: null } },
});
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
let actions: ReturnType<typeof useAttentionCloseActions> | null = null;
let claim: ReturnType<typeof useClaimAttentionItem> | null = null;
const onClosed = vi.fn();
function Harness() {
actions = useAttentionCloseActions("company", { userLabelMap: new Map([["dotta", "Dotta"]]), onClosed });
claim = useClaimAttentionItem("company");
return null;
}
beforeEach(async () => {
fixture.close.mockReset();
fixture.claim.mockReset();
fixture.transfer.mockReset();
fixture.pushToast.mockReset();
onClosed.mockReset();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient();
await act(async () => root.render(<QueryClientProvider client={client}><Harness /></QueryClientProvider>));
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
describe("useAttentionCloseActions (PAP-749 task 2c)", () => {
it("withdraws each confirmed stale card and reports cards someone else closed first", async () => {
fixture.close
.mockResolvedValueOnce({})
.mockRejectedValueOnce(alreadyClosed)
.mockRejectedValueOnce(new Error("boom"));
let result: AttentionWithdrawAllResult | null = null;
await act(async () => { result = await actions!.withdrawAll([item("a"), item("b"), item("c")], " Out of date "); });
expect(fixture.close.mock.calls).toEqual([
["company", "issue_thread_interaction", "a", { mode: "withdraw", reason: "Out of date" }],
["company", "issue_thread_interaction", "b", { mode: "withdraw", reason: "Out of date" }],
["company", "issue_thread_interaction", "c", { mode: "withdraw", reason: "Out of date" }],
]);
expect(result!.withdrawn.map((entry) => entry.id)).toEqual(["a"]);
expect(result!.alreadyClosed.map((entry) => entry.id)).toEqual(["b"]);
expect(result!.failed.map((entry) => entry.id)).toEqual(["c"]);
expect(onClosed).toHaveBeenCalledTimes(1);
expect(fixture.pushToast).toHaveBeenCalledWith(expect.objectContaining({
title: "Withdrew 1 stale request",
body: "1 already answered by someone else. 1 could not be withdrawn.",
tone: "error",
}));
});
it('says "Already answered by @x" when a single close loses the race', async () => {
fixture.close.mockRejectedValueOnce(alreadyClosed);
await act(async () => { actions!.close({ item: item("a"), mode: "withdraw", reason: "" }); });
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
expect(fixture.pushToast).toHaveBeenCalledWith(expect.objectContaining({
title: "Could not withdraw this request",
body: "Already answered by @Dotta",
}));
expect(onClosed).not.toHaveBeenCalled();
});
});
describe("claim and transfer refresh every view of the card (PAP-771)", () => {
// Task detail caches interactions under its identifier; the card resolver uses the UUID.
const interactionCard = {
...item("interaction-1"),
subject: { id: "interaction-1", title: "Approve the plan?", metadata: { issueId: "issue-1" } },
relatedIssue: { id: "issue-1", identifier: "NEE-39" },
} as unknown as AttentionItem;
const views = [queryKeys.issues.interactions("issue-1"), queryKeys.issues.interactions("NEE-39"), ["issues", "pending-questions"]];
const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
it("claim", async () => {
fixture.claim.mockResolvedValueOnce({});
const invalidate = vi.spyOn(client, "invalidateQueries");
await act(async () => { claim!.mutate(interactionCard); });
await settle();
for (const queryKey of views) expect(invalidate).toHaveBeenCalledWith({ queryKey });
});
it("transfer", async () => {
fixture.transfer.mockResolvedValueOnce({});
const invalidate = vi.spyOn(client, "invalidateQueries");
await act(async () => { actions!.transfer({ item: interactionCard, userId: "dotta" }); });
await settle();
expect(fixture.transfer).toHaveBeenCalledWith("company", "issue_thread_interaction", "interaction-1", { userId: "dotta", reason: undefined });
for (const queryKey of views) expect(invalidate).toHaveBeenCalledWith({ queryKey });
});
});
+301
View File
@@ -0,0 +1,301 @@
import { useCallback, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AttentionCloseMode, AttentionItem, AttentionPersonalView, AttentionReceipt } from "@paperclipai/shared";
import { accessApi } from "@/api/access";
import { attentionApi } from "@/api/attention";
import { inboxDismissalsApi } from "@/api/inboxDismissals";
import { useOptionalToastActions } from "@/context/ToastContext";
import { useCoordinatorWorkspaceEnabled } from "@/hooks/useCoordinatorWorkspaceEnabled";
import { attentionAlreadyClosedText, attentionUserLabel, readAttentionAlreadyClosed } from "@/lib/attention-close";
import { EMPTY_ATTENTION_VIEW_COUNTS, attentionEveryoneViewAllowed } from "@/lib/attention-views";
import { invalidateAttentionRecordViews } from "@/lib/attention-record-views";
import { coordinatorAttentionQueryKey } from "@/lib/coordinator-attention";
import { queryKeys } from "@/lib/queryKeys";
interface AttentionViewFeedOptions {
enabled?: boolean;
refetchInterval?: number;
}
/**
* The complete pending feed of one view for the signed-in person (PAP-749 plan
* §1). It waits for the user ID, so the cache never holds a list without its
* owner in the key.
*/
export function useAttentionViewFeed(
companyId: string | null | undefined,
view: AttentionPersonalView,
options: AttentionViewFeedOptions = {},
) {
const { userId } = useCoordinatorWorkspaceEnabled();
return useQuery({
queryKey: coordinatorAttentionQueryKey(companyId ?? "", userId, view),
queryFn: () => attentionApi.list(companyId!, { all: true, view }),
enabled: !!companyId && !!userId && (options.enabled ?? true),
refetchOnWindowFocus: true,
refetchInterval: options.refetchInterval,
});
}
/**
* The one badge (PAP-749 plan §1, Rule 5) plus the per-view counts. Every
* surface reads the "For you" feed under the same key, so the sidebar, the
* mobile navigation and both Decisions pages show the same numbers.
*/
export function useAttentionBadge(companyId: string | null | undefined, options: AttentionViewFeedOptions = {}) {
const feed = useAttentionViewFeed(companyId, "mine", options);
return {
count: feed.data?.deskBadgeCount ?? 0,
viewCounts: feed.data?.viewCounts ?? EMPTY_ATTENTION_VIEW_COUNTS,
};
}
/**
* Whether the signed-in person may open Everyone (PAP-768): board members
* only. Reads the shared board access snapshot; false until it loads.
*/
export function useAttentionEveryoneView(companyId: string | null | undefined) {
const { userId } = useCoordinatorWorkspaceEnabled();
const boardAccess = useQuery({
queryKey: queryKeys.access.currentBoardAccess,
queryFn: () => accessApi.getCurrentBoardAccess(),
enabled: !!companyId && !!userId,
staleTime: 30_000,
});
return boardAccess.data?.userId === userId && attentionEveryoneViewAllowed(companyId, boardAccess.data);
}
/**
* Claim an ownerless card. Every attention list refetches afterwards, and so
* do task detail and chat, whose audience line names the new owner.
*/
export function useClaimAttentionItem(companyId: string | null | undefined) {
const queryClient = useQueryClient();
const toasts = useOptionalToastActions();
return useMutation({
mutationFn: (item: AttentionItem) => attentionApi.claim(companyId!, item.sourceKind, item.subject.id),
onSuccess: (_result, item) => {
toasts?.pushToast({
title: "Claimed",
body: item.subject.title ? `${item.subject.title} is now in For you.` : "The card is now in For you.",
tone: "success",
});
},
onError: (error) => {
toasts?.pushToast({
title: "Could not claim this card",
body: error instanceof Error ? error.message : undefined,
tone: "error",
});
},
onSettled: (_result, _error, item) => {
if (companyId) invalidateAttentionRecordViews(queryClient, companyId, item);
},
});
}
/**
* Dismiss a receipt at the top of For you (PAP-749 plan §6). It is personal
* presentation state, stored like an archived card under the receipt's key.
*/
export function useDismissAttentionReceipt(companyId: string | null | undefined) {
const queryClient = useQueryClient();
const toasts = useOptionalToastActions();
const mutation = useMutation({
mutationFn: (receipt: AttentionReceipt) => inboxDismissalsApi.dismiss(companyId!, receipt.dismissalKey),
onError: (error) => {
toasts?.pushToast({
title: "Could not dismiss this receipt",
body: error instanceof Error ? error.message : undefined,
tone: "error",
});
},
onSettled: () => {
if (companyId) void queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
},
});
return {
dismiss: mutation.mutate,
dismissingId: mutation.isPending ? mutation.variables?.id ?? null : null,
};
}
export interface AttentionCloseRequest {
item: AttentionItem;
mode: AttentionCloseMode;
reason?: string | null;
until?: string | null;
}
export interface AttentionWithdrawAllResult {
withdrawn: AttentionItem[];
alreadyClosed: AttentionItem[];
failed: AttentionItem[];
}
const CLOSE_TOAST_TITLE: Record<AttentionCloseMode, string> = {
withdraw: "Request withdrawn",
archive: "Archived for you",
snooze: "Snoozed for you",
};
/**
* The Close… menu verbs and "Withdraw all stale" (PAP-749 plan §2, Rule 6;
* §3). Every verb goes through the one close endpoint, which writes the
* `attention.closed` audit entry. Archive and snooze can be undone (Restore
* deletes the dismissal); withdraw cannot, because the agent is already awake.
* A 409 from a person who was first shows "Already answered by @x".
*/
export function useAttentionCloseActions(
companyId: string | null | undefined,
options: {
userLabelMap?: ReadonlyMap<string, string> | null;
/** Called after the server accepted a close, before the feed refetches. */
onClosed?: (item: AttentionItem, mode: AttentionCloseMode) => void;
/** Called when a close failed, so an optimistic hide can come back. */
onCloseFailed?: (item: AttentionItem, mode: AttentionCloseMode) => void;
/** Called after a Restore (or an Undo) succeeded, before the feed refetches. */
onRestored?: (item: AttentionItem) => void;
} = {},
) {
const queryClient = useQueryClient();
const toasts = useOptionalToastActions();
const { userLabelMap, onClosed, onCloseFailed, onRestored } = options;
const [bulkPending, setBulkPending] = useState(false);
const invalidate = useCallback(() => {
if (!companyId) return;
void queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.inboxDismissals(companyId) });
void queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(companyId) });
}, [companyId, queryClient]);
const errorBody = useCallback((error: unknown) => {
const details = readAttentionAlreadyClosed(error);
if (details) return attentionAlreadyClosedText(details, userLabelMap);
return error instanceof Error ? error.message : undefined;
}, [userLabelMap]);
/**
* Restore deletes your dismissal, so an archived or snoozed card shows in its
* view again (PAP-749 plan §3). Resolves `false` when the server refused, so
* a page can bring back a card it hid optimistically.
*/
const restore = useCallback(async (item: AttentionItem): Promise<boolean> => {
if (!companyId) return false;
let restored = true;
try {
await inboxDismissalsApi.restore(companyId, item.dismissalKey);
onRestored?.(item);
toasts?.pushToast({
id: `attention-restore-${item.id}`,
title: "Restored",
body: item.subject.title ?? undefined,
tone: "info",
});
} catch (error) {
restored = false;
toasts?.pushToast({ title: "Could not restore this card", body: errorBody(error), tone: "error" });
}
invalidate();
return restored;
}, [companyId, errorBody, invalidate, onRestored, toasts]);
const close = useMutation({
mutationFn: ({ item, mode, reason, until }: AttentionCloseRequest) =>
attentionApi.close(companyId!, item.sourceKind, item.subject.id, {
mode,
reason: reason?.trim() || undefined,
until: until ?? undefined,
// The version the person saw: the card returns only when it changes.
fingerprint: mode === "withdraw" ? undefined : item.changeFingerprint || undefined,
}),
onSuccess: (result, { item, mode }) => {
onClosed?.(item, mode);
toasts?.pushToast({
id: `attention-close-${item.id}`,
dedupeKey: `attention-close-${item.dismissalKey}`,
title: CLOSE_TOAST_TITLE[mode],
body: mode === "snooze" && result.until
? `Back ${new Date(result.until).toLocaleString()}.`
: item.subject.title ?? undefined,
tone: mode === "withdraw" ? "success" : "info",
...(mode === "withdraw" ? {} : { ttlMs: 8000, action: { label: "Undo", onClick: () => void restore(item) } }),
});
},
onError: (error, { item, mode }) => {
onCloseFailed?.(item, mode);
toasts?.pushToast({
title: mode === "withdraw" ? "Could not withdraw this request" : "Could not hide this card",
body: errorBody(error),
tone: "error",
});
},
onSettled: invalidate,
});
const transfer = useMutation({
mutationFn: ({ item, userId, reason }: { item: AttentionItem; userId: string; reason?: string | null }) =>
attentionApi.transfer(companyId!, item.sourceKind, item.subject.id, { userId, reason: reason?.trim() || undefined }),
onSuccess: (_result, { item, userId }) => {
toasts?.pushToast({
title: `Transferred to @${attentionUserLabel(userId, userLabelMap)}`,
body: item.subject.title ?? undefined,
tone: "success",
});
},
onError: (error) => {
toasts?.pushToast({ title: "Could not transfer this card", body: errorBody(error), tone: "error" });
},
onSettled: (_result, _error, { item }) => {
invalidate();
// Task detail and chat name the new owner too.
if (companyId) invalidateAttentionRecordViews(queryClient, companyId, item);
},
});
/**
* Withdraw each confirmed stale card, one at a time so one failure does not
* hide the others. A card that a different person closed first is reported,
* not retried.
*/
const withdrawAll = useCallback(async (items: AttentionItem[], reason?: string | null): Promise<AttentionWithdrawAllResult> => {
const result: AttentionWithdrawAllResult = { withdrawn: [], alreadyClosed: [], failed: [] };
if (!companyId || items.length === 0) return result;
setBulkPending(true);
try {
for (const item of items) {
try {
await attentionApi.close(companyId, item.sourceKind, item.subject.id, { mode: "withdraw", reason: reason?.trim() || undefined });
result.withdrawn.push(item);
onClosed?.(item, "withdraw");
} catch (error) {
(readAttentionAlreadyClosed(error) ? result.alreadyClosed : result.failed).push(item);
}
}
} finally {
setBulkPending(false);
invalidate();
}
const notes = [
result.alreadyClosed.length ? `${result.alreadyClosed.length} already answered by someone else.` : null,
result.failed.length ? `${result.failed.length} could not be withdrawn.` : null,
].filter(Boolean).join(" ");
toasts?.pushToast({
title: result.withdrawn.length === 1 ? "Withdrew 1 stale request" : `Withdrew ${result.withdrawn.length} stale requests`,
body: notes || undefined,
tone: result.failed.length ? "error" : "success",
});
return result;
}, [companyId, invalidate, onClosed, toasts]);
return {
close: close.mutate,
closingItemId: close.isPending ? close.variables?.item.id ?? null : null,
transfer: transfer.mutate,
transferringItemId: transfer.isPending ? transfer.variables?.item.id ?? null : null,
restore,
withdrawAll,
bulkPending,
};
}
+12 -6
View File
@@ -53,8 +53,9 @@ export function useInboxDismissals(companyId: string | null | undefined) {
});
const dismissMutation = useMutation({
mutationFn: ({ itemKey }: { itemKey: string }) => inboxDismissalsApi.dismiss(companyId!, itemKey),
onMutate: async ({ itemKey }) => {
mutationFn: ({ itemKey, fingerprint }: { itemKey: string; fingerprint?: string }) =>
inboxDismissalsApi.dismiss(companyId!, itemKey, fingerprint),
onMutate: async ({ itemKey, fingerprint }) => {
if (!companyId) return { previous: [] as typeof dismissals };
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<typeof dismissals>(queryKey) ?? [];
@@ -66,6 +67,7 @@ export function useInboxDismissals(companyId: string | null | undefined) {
userId: "me",
itemKey,
dismissedAt: now,
fingerprint: fingerprint ?? null,
createdAt: now,
updatedAt: now,
},
@@ -93,8 +95,8 @@ export function useInboxDismissals(companyId: string | null | undefined) {
}
const snoozeMutation = useMutation({
mutationFn: ({ itemKey, snoozedUntil }: { itemKey: string; snoozedUntil: string }) =>
inboxDismissalsApi.snooze(companyId!, itemKey, snoozedUntil),
mutationFn: ({ itemKey, snoozedUntil, fingerprint }: { itemKey: string; snoozedUntil: string; fingerprint?: string }) =>
inboxDismissalsApi.snooze(companyId!, itemKey, snoozedUntil, fingerprint),
onSettled: invalidateDismissalConsumers,
});
@@ -113,9 +115,13 @@ export function useInboxDismissals(companyId: string | null | undefined) {
const dismissMutate = dismissMutation.mutate;
const snoozeMutate = snoozeMutation.mutate;
const restoreMutate = restoreMutation.mutate;
const dismiss = useCallback((itemKey: string) => dismissMutate({ itemKey }), [dismissMutate]);
const dismiss = useCallback(
(itemKey: string, fingerprint?: string) => dismissMutate({ itemKey, fingerprint }),
[dismissMutate],
);
const snooze = useCallback(
(itemKey: string, snoozedUntil: string) => snoozeMutate({ itemKey, snoozedUntil }),
(itemKey: string, snoozedUntil: string, fingerprint?: string) =>
snoozeMutate({ itemKey, snoozedUntil, fingerprint }),
[snoozeMutate],
);
const restore = useCallback((itemKey: string) => restoreMutate({ itemKey }), [restoreMutate]);
+33 -4
View File
@@ -5148,6 +5148,12 @@ html:has(.design-proposal-theme) .design-navigation-group-label {
}
/* Reuse proposal geometry locally; the Coordinator navigation and conversation
hierarchy remain independent of the global Design Proposals theme. */
.coordinator-workspace .sidebar-shell-panel {
border-inline-end: var(--task-row-rule-width) solid var(--design-sidebar-border);
background: var(--primary-sidebar-background);
background-clip: padding-box;
}
.coordinator-workspace .sidebar-shell-panel .coordinator-sidebar { border-inline-end: 0; }
html:not(.dark) .coordinator-workspace [data-sidebar-account] { background: var(--primary-sidebar-background); }
html.dark .coordinator-workspace [data-sidebar-account] { background: var(--primary-sidebar-background); }
.coordinator-sidebar, .coordinator-task-list {
@@ -5185,13 +5191,32 @@ html:not(.dark) .coordinator-sidebar {
.coordinator-workspace .task-chat-composer-dock > :last-child { border-radius: var(--coordinator-card-radius); }
.coordinator-workspace [cmdk-root] { height: auto; flex: none; }
.coordinator-workspace [cmdk-list] { flex: none; }
.coordinator-decision-card { border: 1px solid var(--border); border-radius: var(--coordinator-card-radius, var(--radius-xl)); padding: var(--coordinator-card-inset, calc(var(--spacing) * 6)); background: var(--card); box-shadow: var(--coordinator-card-shadow); }
/* PAP-775 (Paper "A · Compact cards"): a 20px card with a type label, the ask,
an inset context panel and a footer of 32px actions. */
.coordinator-decision-card { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: calc(var(--spacing) * 5); background: var(--card); box-shadow: var(--coordinator-card-shadow); }
.coordinator-decision-card > .rounded-lg { border: 0; padding: 0; background: transparent; box-shadow: none; }
.coordinator-decision-card [data-testid="interaction-status-badge"], .coordinator-decision-card [data-testid="interaction-addressee-badge"] { display: none; }
.coordinator-decision-card :is(h2,h3) { font-size: var(--text-lg); line-height: var(--text-lg--line-height); font-weight: var(--font-weight-medium); }
.coordinator-decision-card :is(h2,h3) { font-size: var(--text-base); line-height: var(--text-base--line-height); font-weight: var(--font-weight-medium); }
.coordinator-decision-card :is(p, label) { font-size: var(--text-sm); line-height: var(--text-sm--line-height); }
.coordinator-decision-card [data-decision-prompt] { font-size: var(--text-lg); line-height: var(--text-lg--line-height); font-weight: var(--font-weight-medium); }
.coordinator-decision-card [data-attention-context] :is(p, span, button, time) { font-size: var(--text-xs); line-height: var(--text-xs--line-height); }
.coordinator-decision-card [data-decision-prompt] { font-size: var(--text-base); line-height: var(--text-base--line-height); font-weight: var(--font-weight-medium); }
/* The Needs You toolbar fits one row: the search takes what the filters leave. */
.coordinator-needs-you [data-toolbar-search] { width: 100%; min-width: 0; }
.coordinator-needs-you [data-toolbar-search] input { flex: 1; width: auto; }
.coordinator-decision-card [data-needs-you-panel] :is(p, label) { font-size: var(--text-compact); line-height: calc(var(--spacing) * 5); }
.coordinator-decision-card [data-needs-you-panel] :is(p, label, span).text-xs { font-size: var(--text-xs); line-height: calc(var(--spacing) * 4.5); }
.coordinator-decision-card [data-decision-actions] :is(button, a):is([data-slot="button"], [data-variant]) { height: calc(var(--spacing) * 8); padding-inline: calc(var(--spacing) * 2.5); font-size: var(--text-compact); }
/* The footer owns its top spacing; nested stacks and empty live regions must
not add margins around it. Match the card's bottom and right inset. */
.coordinator-decision-card [data-decision-footer] { margin-block: 0; padding-block-start: calc(var(--spacing) * 5); row-gap: calc(var(--spacing) * 5); }
.coordinator-decision-card :has(+ [data-decision-footer]),
.coordinator-decision-card :has(+ [data-decision-action-row]),
.coordinator-decision-card :has(+ [aria-live]:empty) { margin-block-end: 0; }
.coordinator-decision-card [id^="interaction-"]:focus { outline-color: var(--ring); }
/* Focus review panel (PAP-770): the artifact is the context, so the pinned
footer keeps only the card's actions — not its summary, audience or prompt. */
.coordinator-review-actions [data-presentation] > :not(:last-child),
.coordinator-review-actions [data-presentation] > :last-child > div > :first-child:has([data-decision-prompt]) { display: none; }
@media (max-width: 767px) {
.coordinator-workspace { --coordinator-card-inset: calc(var(--spacing) * 5); }
}
@@ -5217,8 +5242,12 @@ html:not(.dark) .coordinator-sidebar {
background: var(--card);
}
.coordinator-task-commitment-content { position: relative; min-width: 0; }
.coordinator-decision-motion { border-radius: var(--coordinator-card-radius, var(--radius-xl)); box-shadow: var(--coordinator-card-shadow); }
.coordinator-decision-motion { border-radius: var(--radius-lg); box-shadow: var(--coordinator-card-shadow); }
/* The motion shell owns the single 1px border and shadow. */
.coordinator-decision-motion > .coordinator-task-commitment-content > :is(.coordinator-decision-card, .coordinator-decision-receipt) { border: 0; box-shadow: none; }
/* Highlight the existing border when its artifact is open, keeping the same width. */
.coordinator-decision-card[data-reviewing],
.coordinator-decision-motion:has(.coordinator-decision-card[data-reviewing]) { border-color: var(--ring); }
.coordinator-task-commitment[data-motion="arrive"] { animation: coordinator-task-arrive var(--coordinator-task-arrive-duration) var(--coordinator-task-ease) both; }
.coordinator-task-light { display: none; position: absolute; inset: 1px; border-radius: inherit; overflow: hidden; pointer-events: none; }
.coordinator-task-commitment[data-motion="gather"] > .coordinator-task-light { display: block; background: radial-gradient(ellipse at 25% 20%, color-mix(in oklab, var(--foreground) 8%, transparent), transparent 75%); animation: coordinator-task-gather var(--coordinator-task-light-duration) var(--coordinator-task-ease) both; }
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import type { AttentionItem } from "@paperclipai/shared";
import {
attentionAlreadyClosedText,
attentionCanTransfer,
attentionCanWithdraw,
attentionHiddenState,
attentionHiddenText,
attentionOtherAudienceUserId,
attentionTransferTargets,
planStaleWithdraw,
readAttentionAlreadyClosed,
splitArchivedAttentionItems,
splitStaleAttentionItems,
} from "./attention-close";
const labels = new Map([["scott", "Scott"], ["dotta", "Dotta"], ["riley", "Riley"]]);
function item(id: string, overrides: Partial<AttentionItem> = {}) {
return {
id,
sourceKind: "issue_thread_interaction",
subject: { id: `${id}-subject` },
audience: { kind: "user", userId: "scott", reason: "addressee" },
stale: null,
closeModes: ["withdraw", "archive", "snooze"], obligationKey: null, obligationLeadId: null,
...overrides,
} as unknown as AttentionItem;
}
describe("attention close helpers (PAP-749 task 2c)", () => {
it("offers withdraw only where the server lists it, and transfer only for interactions", () => {
expect(attentionCanWithdraw(item("a"))).toBe(true);
expect(attentionCanWithdraw(item("b", { sourceKind: "failed_run", closeModes: ["archive", "snooze"] }))).toBe(false);
expect(attentionCanTransfer(item("a"))).toBe(true);
expect(attentionCanTransfer(item("c", { sourceKind: "approval" }))).toBe(false);
});
it("names the other person only for a card addressed to someone else", () => {
expect(attentionOtherAudienceUserId(item("a"), "scott")).toBeNull();
expect(attentionOtherAudienceUserId(item("a"), "dotta")).toBe("scott");
expect(attentionOtherAudienceUserId(item("p", { audience: { kind: "pool", userId: null, reason: "none" } }), "scott")).toBeNull();
expect(attentionOtherAudienceUserId(item("c", { audience: { kind: "company", userId: null, reason: "none" } }), "scott")).toBeNull();
});
it("transfers to everyone but the viewer and the current owner", () => {
const forDotta = item("d", { audience: { kind: "user", userId: "dotta", reason: "addressee" } });
expect(attentionTransferTargets(forDotta, "scott", labels).map((target) => target.label)).toEqual(["Riley"]);
expect(attentionTransferTargets(item("x", { sourceKind: "decision" }), "scott", labels)).toEqual([]);
});
it("splits stale cards from current ones, keeping order", () => {
const items = [item("1"), item("2", { stale: { reason: "aged" } }), item("3"), item("4", { stale: { reason: "issue_closed" } })];
const { current, stale } = splitStaleAttentionItems(items);
expect(current.map((entry) => entry.id)).toEqual(["1", "3"]);
expect(stale.map((entry) => entry.id)).toEqual(["2", "4"]);
});
it("withdraws in bulk only cards with a real withdraw that are not required gates", () => {
const { withdrawable, required, kept } = planStaleWithdraw([
item("q"),
item("budget", { sourceKind: "budget_alert", closeModes: ["archive", "snooze"] }),
item("hire", { sourceKind: "approval", closeModes: ["archive", "snooze"], required: true }),
item("approval", { sourceKind: "approval", required: true }),
item("governed", { required: true }),
]);
expect(withdrawable.map((entry) => entry.id)).toEqual(["q"]);
// Rule 10: a stale required card goes to a person; it never leaves in bulk.
expect(required.map((entry) => entry.id)).toEqual(["approval", "governed"]);
expect(kept.map((entry) => entry.id)).toEqual(["budget", "hire"]);
});
it('reads the 409 and says "Already answered by @x"', () => {
const error = Object.assign(new Error("Already answered"), {
status: 409,
body: { error: "Already answered", details: { code: "attention_already_closed", resolvedByUserId: "dotta", resolvedByAgentId: null } },
});
const details = readAttentionAlreadyClosed(error)!;
expect(attentionAlreadyClosedText(details, labels)).toBe("Already answered by @Dotta");
expect(readAttentionAlreadyClosed(new Error("boom"))).toBeNull();
expect(attentionAlreadyClosedText({ ...details, resolvedByUserId: null, resolvedByAgentId: "agent-1" }, labels)).toBe("Already answered by an agent");
});
});
describe("Archived view helpers (PAP-749 task 2e)", () => {
const hidden = (id: string, kind: "dismiss" | "snooze", snoozedUntil: string | null = null, isActive = true) =>
item(id, { dismissal: { kind, dismissedAt: "2026-10-29T12:00:00.000Z", snoozedUntil, isActive } });
it("reads how a card is hidden for you, and nothing for a card you did not hide", () => {
expect(attentionHiddenState(hidden("a", "dismiss"))).toEqual({ kind: "archived", since: "2026-10-29T12:00:00.000Z" });
expect(attentionHiddenState(hidden("s", "snooze", "2026-10-31T09:00:00.000Z")))
.toEqual({ kind: "snoozed", since: "2026-10-29T12:00:00.000Z", until: "2026-10-31T09:00:00.000Z" });
// A card that changed (inactive dismissal) is back in its view, not hidden.
expect(attentionHiddenState(hidden("back", "dismiss", null, false))).toBeNull();
expect(attentionHiddenState(item("plain"))).toBeNull();
});
it('says "Archived <date>" and "Back <time>"', () => {
expect(attentionHiddenText({ kind: "archived", since: "2026-10-29T12:00:00.000Z" })).toMatch(/^Archived /);
expect(attentionHiddenText({ kind: "snoozed", since: "2026-10-29T12:00:00.000Z", until: "2026-10-31T09:00:00.000Z" })).toMatch(/^Back /);
});
it("puts archived cards first and the Snoozed group soonest first", () => {
const later = hidden("later", "snooze", "2026-11-05T09:00:00.000Z");
const sooner = hidden("sooner", "snooze", "2026-10-31T09:00:00.000Z");
const archived = hidden("archived", "dismiss");
const { archived: archivedItems, snoozed } = splitArchivedAttentionItems([later, archived, sooner]);
expect(archivedItems.map((entry) => entry.id)).toEqual(["archived"]);
expect(snoozed.map((entry) => entry.id)).toEqual(["sooner", "later"]);
});
});
+176
View File
@@ -0,0 +1,176 @@
import {
ATTENTION_CLAIMABLE_SOURCE_KINDS,
attentionStaleReasonText,
type AttentionAlreadyClosedDetails,
type AttentionCloseMode,
type AttentionItem,
} from "@paperclipai/shared";
/**
* The Close… menu (PAP-749 plan §2, Rule 6) and the Stale group (§3). Pure
* helpers so both Decisions pages make the same choices. The server still
* checks every close, claim and transfer; nothing here grants authority.
*/
/**
* The Close… verbs the server listed for this card. A feed from a server
* without `closeModes` (an older cached response) offers none.
*/
export function attentionCloseModes(item: AttentionItem): readonly AttentionCloseMode[] {
return item.closeModes ?? [];
}
/** Withdraw closes the request for everyone; only some sources have one. */
export function attentionCanWithdraw(item: AttentionItem): boolean {
return attentionCloseModes(item).includes("withdraw");
}
/** Transfer stores a new addressee, so it needs a source with that field. */
export function attentionCanTransfer(item: AttentionItem): boolean {
return (ATTENTION_CLAIMABLE_SOURCE_KINDS as readonly string[]).includes(item.sourceKind);
}
/**
* The person a card is for, when that is not the viewer. Acting on such a card
* asks "This is for @x. Continue?" first (PAP-749 plan §2 and §6).
*/
export function attentionOtherAudienceUserId(
item: AttentionItem,
currentUserId: string | null | undefined,
): string | null {
const audience = item.audience;
if (!audience || audience.kind !== "user" || !audience.userId) return null;
return audience.userId === currentUserId ? null : audience.userId;
}
export function attentionUserLabel(
userId: string,
userLabelMap: ReadonlyMap<string, string> | null | undefined,
): string {
return userLabelMap?.get(userId) ?? "another person";
}
/** People the card can go to: everyone in the directory but the viewer and the current owner. */
export function attentionTransferTargets(
item: AttentionItem,
currentUserId: string | null | undefined,
userLabelMap: ReadonlyMap<string, string> | null | undefined,
): Array<{ userId: string; label: string }> {
if (!attentionCanTransfer(item) || !userLabelMap) return [];
const owner = item.audience?.kind === "user" ? item.audience.userId : null;
return [...userLabelMap]
.filter(([userId]) => userId !== currentUserId && userId !== owner)
.map(([userId, label]) => ({ userId, label }))
.sort((a, b) => a.label.localeCompare(b.label));
}
/**
* How a card in the Archived view is hidden for you (PAP-749 plan §3):
* archived until it changes, or snoozed until a time. `null` for a card you
* did not hide.
*/
export type AttentionHiddenState =
| { kind: "archived"; since: string }
| { kind: "snoozed"; since: string; until: string };
export function attentionHiddenState(item: AttentionItem): AttentionHiddenState | null {
const dismissal = item.dismissal;
if (!dismissal?.isActive) return null;
if (dismissal.kind === "snooze" && dismissal.snoozedUntil) {
return { kind: "snoozed", since: dismissal.dismissedAt, until: dismissal.snoozedUntil };
}
return { kind: "archived", since: dismissal.dismissedAt };
}
/** "Archived Oct 29" or "Back Fri, Oct 31, 9:00 AM" (local time). */
export function attentionHiddenText(hidden: AttentionHiddenState): string {
if (hidden.kind === "snoozed") {
return `Back ${new Date(hidden.until).toLocaleString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})}`;
}
return `Archived ${new Date(hidden.since).toLocaleDateString(undefined, { month: "short", day: "numeric" })}`;
}
/**
* The Archived view's two parts: archived cards first, then the Snoozed group,
* which comes back soonest first.
*/
export function splitArchivedAttentionItems(items: AttentionItem[]): {
archived: AttentionItem[];
snoozed: AttentionItem[];
} {
const archived: AttentionItem[] = [];
const snoozed: AttentionItem[] = [];
for (const item of items) (attentionHiddenState(item)?.kind === "snoozed" ? snoozed : archived).push(item);
snoozed.sort((a, b) => Date.parse(a.dismissal!.snoozedUntil!) - Date.parse(b.dismissal!.snoozedUntil!));
return { archived, snoozed };
}
/** Current cards first; stale cards go to the collapsed Stale (N) group (Rule 10). */
export function splitStaleAttentionItems(items: AttentionItem[]): {
current: AttentionItem[];
stale: AttentionItem[];
} {
const current: AttentionItem[] = [];
const stale: AttentionItem[] = [];
for (const item of items) (item.stale ? stale : current).push(item);
return { current, stale };
}
/**
* What "Withdraw all stale" does (PAP-749 plan §2, Rule 10). It withdraws only
* cards with a real withdraw that are not required gates. Required approvals
* and governed requests go to a person and never leave in bulk; a person can
* still withdraw one from its own Close… menu. Cards with no request to
* withdraw (failed runs, reviews, budget) also stay. The confirmation lists
* all three, so nothing closes without the person seeing it.
*/
export function planStaleWithdraw(items: AttentionItem[]): {
withdrawable: AttentionItem[];
required: AttentionItem[];
kept: AttentionItem[];
} {
const withdrawable: AttentionItem[] = [];
const required: AttentionItem[] = [];
const kept: AttentionItem[] = [];
for (const item of items) {
if (!attentionCanWithdraw(item)) kept.push(item);
else if (item.required) required.push(item);
else withdrawable.push(item);
}
return { withdrawable, required, kept };
}
export function attentionStaleChipText(item: AttentionItem): string | null {
return item.stale ? attentionStaleReasonText(item.stale.reason) : null;
}
/** The 409 the close, claim and transfer endpoints send when someone else was first. */
export function readAttentionAlreadyClosed(error: unknown): AttentionAlreadyClosedDetails | null {
if (!error || typeof error !== "object") return null;
const body = (error as { body?: unknown }).body;
if (!body || typeof body !== "object") return null;
const details = (body as { details?: unknown }).details;
if (!details || typeof details !== "object") return null;
return (details as { code?: unknown }).code === "attention_already_closed"
? details as AttentionAlreadyClosedDetails
: null;
}
/** "Already answered by @x" (PAP-749 plan §2, "Two persons answer at the same time"). */
export function attentionAlreadyClosedText(
details: AttentionAlreadyClosedDetails,
userLabelMap: ReadonlyMap<string, string> | null | undefined,
): string {
if (details.resolvedByUserId) return `Already answered by @${attentionUserLabel(details.resolvedByUserId, userLabelMap)}`;
return details.resolvedByAgentId ? "Already answered by an agent" : "Already answered";
}
export function attentionForOtherText(name: string, verb: "withdraw" | "continue"): string {
return verb === "withdraw" ? `This is for @${name}. Withdraw anyway?` : `This is for @${name}. Continue?`;
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import type { AttentionItem } from "@paperclipai/shared";
import {
attentionEvidenceLabel,
attentionEvidenceText,
attentionObligationEvidence,
foldAttentionObligations,
isAttentionObligationEvidence,
} from "./attention-obligations";
function item(id: string, overrides: Partial<AttentionItem> = {}): AttentionItem {
return {
id,
sourceKind: "approval",
subject: { kind: "approval", id, companyId: "c1", title: `Card ${id}`, identifier: null, status: null, href: `/PAP/x/${id}` },
whyNow: `Why ${id}`,
detail: null,
obligationKey: null,
obligationLeadId: null,
...overrides,
} as AttentionItem;
}
describe("one card for each obligation (PAP-749 task 2d)", () => {
const approval = item("approval:a1", { obligationKey: "approval:a1", obligationLeadId: "approval:a1" });
const review = item("review:t1", {
sourceKind: "review",
subject: { kind: "issue", id: "t1", companyId: "c1", title: "Ship the page", identifier: "PAP-7", status: "in_review", href: "/PAP/issues/PAP-7" },
whyNow: "Issue is in review with a linked pending approval.",
obligationKey: "approval:a1",
obligationLeadId: "approval:a1",
});
const solo = item("decision:d1", { sourceKind: "decision" });
it("folds evidence into its lead and keeps every other card", () => {
const folded = foldAttentionObligations([review, solo, approval]);
expect(folded.cards.map((card) => card.id)).toEqual(["decision:d1", "approval:a1"]);
expect(attentionObligationEvidence(folded, approval)).toEqual([review]);
expect(attentionObligationEvidence(folded, solo)).toEqual([]);
expect(isAttentionObligationEvidence(review)).toBe(true);
expect(isAttentionObligationEvidence(approval)).toBe(false);
expect(isAttentionObligationEvidence(solo)).toBe(false);
});
it("keeps evidence as its own card when its lead is not in the list", () => {
const folded = foldAttentionObligations([review, solo]);
expect(folded.cards.map((card) => card.id)).toEqual(["review:t1", "decision:d1"]);
expect(folded.evidenceByLeadId.size).toBe(0);
});
it("names what each evidence card is to its lead", () => {
const run = item("failed_run:run:r1", { sourceKind: "failed_run" });
const blocked = item("blocker_attention:blocker:t2", { sourceKind: "blocker_attention" });
const blockedOwner = item("blocker_attention:blocked-owner:t2", { sourceKind: "blocker_attention" });
expect(attentionEvidenceLabel(approval, review)).toBe("Task waiting on this approval");
expect(attentionEvidenceLabel(run, blocked)).toBe("Blocked by this run");
expect(attentionEvidenceLabel(blockedOwner, blocked)).toBe("Blocked work");
expect(attentionEvidenceLabel(run, item("failed_run:run:r2", { sourceKind: "failed_run" }))).toBe("Also failed");
// PAP-768: a hard stop's Raise limit / Keep paused answer its override approval.
const stop = item("budget_alert:budget:p1", { sourceKind: "budget_alert" });
expect(attentionEvidenceLabel(stop, item("approval:approval:b1"))).toBe("Approval this answers");
expect(attentionEvidenceText(review)).toEqual({
title: "PAP-7 Ship the page",
detail: "Issue is in review with a linked pending approval.",
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { AttentionItem } from "@paperclipai/shared";
import { attentionDetailLine, sourceMeta } from "./attention";
/**
* One card for each obligation (PAP-749 plan §2, Rule 11). The server keeps
* every card and marks the ones that are one obligation with a shared
* `obligationKey` and the id of the lead card; the page shows the lead and
* folds the others into it as evidence.
*/
export interface FoldedAttentionObligations {
/** The cards to show: each lead, each card that is its own obligation, and evidence whose lead is not in the list. */
cards: AttentionItem[];
/** The evidence cards folded into each shown lead, keyed by the lead's `id`. */
evidenceByLeadId: ReadonlyMap<string, AttentionItem[]>;
}
const NO_EVIDENCE: readonly AttentionItem[] = [];
export function isAttentionObligationEvidence(item: AttentionItem): boolean {
return item.obligationLeadId != null && item.obligationLeadId !== item.id;
}
/**
* Fold evidence into its lead. An evidence card whose lead is not in `items`
* stays a card of its own, so nothing of the obligation disappears.
*/
export function foldAttentionObligations(items: readonly AttentionItem[]): FoldedAttentionObligations {
const ids = new Set(items.map((item) => item.id));
const cards: AttentionItem[] = [];
const evidenceByLeadId = new Map<string, AttentionItem[]>();
for (const item of items) {
if (isAttentionObligationEvidence(item) && ids.has(item.obligationLeadId!)) {
evidenceByLeadId.set(item.obligationLeadId!, [...(evidenceByLeadId.get(item.obligationLeadId!) ?? []), item]);
} else {
cards.push(item);
}
}
return { cards, evidenceByLeadId };
}
export function attentionObligationEvidence(
folded: FoldedAttentionObligations,
item: AttentionItem,
): readonly AttentionItem[] {
return folded.evidenceByLeadId.get(item.id) ?? NO_EVIDENCE;
}
/**
* What an evidence card is to its lead: the task that waits on the approval,
* the work a failed run blocks, or another cause.
*/
export function attentionEvidenceLabel(lead: AttentionItem, evidence: AttentionItem): string {
if (evidence.sourceKind === "review") return lead.sourceKind === "approval" ? "Task waiting on this approval" : "Task in review";
if (evidence.sourceKind === "blocker_attention") return lead.sourceKind === "failed_run" ? "Blocked by this run" : "Blocked work";
if (evidence.sourceKind === "failed_run") return "Also failed";
// Raise limit approves the override approval and Keep paused rejects it (PAP-768).
if (evidence.sourceKind === "approval" && lead.sourceKind === "budget_alert") return "Approval this answers";
return sourceMeta(evidence.sourceKind).label;
}
/** The one line an evidence card adds: what it is about and what it says. */
export function attentionEvidenceText(evidence: AttentionItem): { title: string; detail: string | null } {
const subject = evidence.subject;
const title = [subject.identifier, subject.title].filter(Boolean).join(" ") || sourceMeta(evidence.sourceKind).label;
const detail = attentionDetailLine(evidence) ?? evidence.whyNow;
return { title, detail: detail && detail !== subject.title ? detail : null };
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { AttentionItem } from "@paperclipai/shared";
import { attentionRecordQueryKeys } from "./attention-record-views";
import { queryKeys } from "./queryKeys";
function item(sourceKind: AttentionItem["sourceKind"], subject: Partial<AttentionItem["subject"]>, overrides: Partial<AttentionItem> = {}) {
return {
id: `${sourceKind}-card`,
sourceKind,
subject: { id: "source-1", metadata: {}, ...subject },
relatedIssue: null,
...overrides,
} as unknown as AttentionItem;
}
describe("attentionRecordQueryKeys (PAP-771: one record, many views)", () => {
it("refreshes the task's interactions under both its UUID and identifier, plus chat", () => {
const keys = attentionRecordQueryKeys("company-1", item(
"issue_thread_interaction",
{ metadata: { issueId: "issue-1" } },
{ relatedIssue: { id: "issue-1", identifier: "NEE-39" } as AttentionItem["relatedIssue"] },
));
expect(keys).toEqual([
queryKeys.attention("company-1"),
queryKeys.issues.interactions("issue-1"),
queryKeys.issues.interactions("NEE-39"),
["issues", "pending-questions"],
["issues", "decision-receipts"],
]);
});
it("refreshes an open approval page and every approval list", () => {
expect(attentionRecordQueryKeys("company-1", item("approval", { id: "approval-1" }))).toEqual([
queryKeys.attention("company-1"),
["approvals", "company-1"],
queryKeys.approvals.detail("approval-1"),
]);
});
it("refreshes an open decision, whose detail key sits outside the company prefix", () => {
expect(attentionRecordQueryKeys("company-1", item("decision", { id: "decision-1" }))).toContainEqual(
queryKeys.decisions.detail("decision-1"),
);
});
it("refreshes the budget page and the hard stop's folded override approval", () => {
expect(attentionRecordQueryKeys("company-1", item("budget_alert", { id: "incident-1", metadata: { approvalId: "approval-9" } }))).toEqual([
queryKeys.attention("company-1"),
queryKeys.budgets.overview("company-1"),
["approvals", "company-1"],
queryKeys.approvals.detail("approval-9"),
]);
});
it("keeps join requests on their own list", () => {
expect(attentionRecordQueryKeys("company-1", item("join_request", { id: "join-1" }))).toEqual([
queryKeys.attention("company-1"),
queryKeys.access.joinRequests("company-1"),
]);
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { QueryClient } from "@tanstack/react-query";
import type { AttentionItem } from "@paperclipai/shared";
import { queryKeys } from "./queryKeys";
/**
* One record, many views (PAP-749 design review, Part 2). A card's source
* record also shows in task detail, chat, and the approval, decision and
* budget pages. After a person acts on a card, each of those reads refetches,
* not only the Needs You feed. Other people's open pages get the same change
* from LiveUpdatesProvider.
*/
export function attentionRecordQueryKeys(companyId: string, item: AttentionItem): ReadonlyArray<readonly unknown[]> {
const keys: Array<readonly unknown[]> = [queryKeys.attention(companyId)];
const metadata = item.subject.metadata;
switch (item.sourceKind) {
case "issue_thread_interaction": {
// Task detail caches interactions under the route's identifier, while
// the card resolver uses the task UUID.
const refs = new Set(
[metadata?.issueId, item.relatedIssue?.id, item.relatedIssue?.identifier]
.filter((ref): ref is string => typeof ref === "string" && ref.length > 0),
);
for (const ref of refs) keys.push(queryKeys.issues.interactions(ref));
keys.push(["issues", "pending-questions"], ["issues", "decision-receipts"]);
break;
}
case "approval":
keys.push(["approvals", companyId], queryKeys.approvals.detail(item.subject.id));
break;
case "decision":
keys.push(["decisions", companyId], queryKeys.decisions.detail(item.subject.id), ["issues", "decision-receipts"]);
break;
case "join_request":
keys.push(queryKeys.access.joinRequests(companyId));
break;
case "budget_alert": {
// A hard stop answers its override approval in the same write (PAP-768).
keys.push(queryKeys.budgets.overview(companyId), ["approvals", companyId]);
const approvalId = metadata?.approvalId;
if (typeof approvalId === "string" && approvalId) keys.push(queryKeys.approvals.detail(approvalId));
break;
}
}
return keys;
}
export function invalidateAttentionRecordViews(queryClient: QueryClient, companyId: string, item: AttentionItem) {
for (const queryKey of attentionRecordQueryKeys(companyId, item)) {
void queryClient.invalidateQueries({ queryKey });
}
}
/**
* One record, many views, in every direction (PAP-775, UX2). After a person
* answers, accepts, rejects or withdraws an interaction in Needs You, the
* agent's chat or task detail, every open view of it refetches in this tab:
* the Needs You feed and badge, the chat's pending question and receipts, and
* the task's interaction list under both its UUID and its identifier key.
* Other people's tabs get the same change from LiveUpdatesProvider.
*/
export function invalidateInteractionRecordViews(queryClient: QueryClient, companyId: string) {
void queryClient.invalidateQueries({ queryKey: ["issues", "interactions"] });
void queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) });
void queryClient.invalidateQueries({ queryKey: ["issues", "pending-questions"] });
void queryClient.invalidateQueries({ queryKey: ["issues", "decision-receipts"] });
}
+167
View File
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import type { AttentionAudience, AttentionItem, AttentionReceipt, AttentionSourceKind } from "@paperclipai/shared";
import {
ATTENTION_COMPANY_AUDIENCE_TEXT,
ATTENTION_POOL_AUDIENCE_TEXT,
attentionAudienceLineNamesResponders,
attentionClaimAlternative,
attentionEveryoneViewAllowed,
attentionItemClaimable,
attentionReceiptLine,
attentionResponderText,
attentionUnownedAudienceText,
attentionViewsFor,
describeAttentionAudience,
parseAttentionView,
unassignedBannerText,
} from "./attention-views";
import { coordinatorAttentionQueryKey } from "./coordinator-attention";
function item(audience: AttentionAudience, sourceKind: AttentionSourceKind = "issue_thread_interaction") {
return { id: "card", sourceKind, audience, subject: { id: "card" } } as unknown as AttentionItem;
}
describe("attention views (PAP-749 task 1b)", () => {
it("defaults to For you and keeps only the three known views", () => {
expect(parseAttentionView(null)).toBe("mine");
expect(parseAttentionView("all")).toBe("mine");
expect(parseAttentionView("bogus")).toBe("mine");
expect(parseAttentionView("unassigned")).toBe("unassigned");
expect(parseAttentionView("company")).toBe("company");
});
it("opens Everyone only for a board member (PAP-768)", () => {
expect(parseAttentionView("all", { everyone: true })).toBe("all");
expect(parseAttentionView("all", { everyone: false })).toBe("mine");
expect(attentionViewsFor(false)).toEqual(["mine", "unassigned", "company", "archived"]);
expect(attentionViewsFor(true)).toEqual(["mine", "unassigned", "company", "archived", "all"]);
});
it("treats owners, admins, operators and legacy members as board members, not viewers (PAP-768)", () => {
const member = (membershipRole: "owner" | "admin" | "operator" | "viewer" | "member" | null, status: "active" | "suspended" = "active") =>
({ source: "session", isInstanceAdmin: false, memberships: [{ companyId: "c1", membershipRole, status }] });
for (const role of ["owner", "admin", "operator", "member"] as const) {
expect(attentionEveryoneViewAllowed("c1", member(role))).toBe(true);
}
expect(attentionEveryoneViewAllowed("c1", member("viewer"))).toBe(false);
expect(attentionEveryoneViewAllowed("c1", member(null))).toBe(false);
expect(attentionEveryoneViewAllowed("c1", member("owner", "suspended"))).toBe(false);
expect(attentionEveryoneViewAllowed("c2", member("owner"))).toBe(false);
expect(attentionEveryoneViewAllowed("c1", { source: "local_implicit", isInstanceAdmin: false, memberships: [] })).toBe(true);
expect(attentionEveryoneViewAllowed("c1", { source: "session", isInstanceAdmin: true, memberships: [] })).toBe(true);
expect(attentionEveryoneViewAllowed("c1", undefined)).toBe(false);
expect(attentionEveryoneViewAllowed(null, member("owner"))).toBe(false);
});
it("keys each list by company, person and view", () => {
expect(coordinatorAttentionQueryKey("c1", "scott", "mine")).not.toEqual(coordinatorAttentionQueryKey("c1", "dotta", "mine"));
expect(coordinatorAttentionQueryKey("c1", "scott", "mine")).not.toEqual(coordinatorAttentionQueryKey("c1", "scott", "unassigned"));
expect(coordinatorAttentionQueryKey("c1", "scott", "mine").slice(0, 4)).toEqual(["attention", "c1", "scott", "mine"]);
});
it("answers Why me? for your own card from the audience reason", () => {
expect(describeAttentionAudience(item({ kind: "user", userId: "scott", reason: "addressee" }), "scott"))
.toEqual({ kind: "mine", text: "Sent to you" });
expect(describeAttentionAudience(item({ kind: "user", userId: "scott", reason: "reviewer" }), "scott"))
.toEqual({ kind: "mine", text: "You are the reviewer" });
expect(describeAttentionAudience(item({ kind: "user", userId: "scott", reason: "instigator" }), "scott"))
.toEqual({ kind: "mine", text: "You created the original task" });
expect(describeAttentionAudience(item({ kind: "user", userId: "scott", reason: "escalation_owner" }), "scott"))
.toEqual({ kind: "mine", text: "You are the default decision owner" });
});
it("names the project and budget escalation owners (PAP-749 task 1d)", () => {
expect(describeAttentionAudience(item({ kind: "user", userId: "riley", reason: "project_owner" }), "riley"))
.toEqual({ kind: "mine", text: "You own this project's decisions" });
const budgetStop = item({ kind: "user", userId: "scott", reason: "budget_owner", alsoInCompany: true }, "budget_alert");
expect(describeAttentionAudience(budgetStop, "scott")).toEqual({ kind: "mine", text: "You own budget escalation" });
// Company still shows the hard stop to everyone else, as "For @scott".
expect(describeAttentionAudience(budgetStop, "dotta")).toEqual({ kind: "other", userId: "scott" });
expect(attentionItemClaimable(budgetStop)).toBe(false);
});
it("names the other person on a card that is not yours", () => {
expect(describeAttentionAudience(item({ kind: "user", userId: "dotta", reason: "addressee" }), "scott"))
.toEqual({ kind: "other", userId: "dotta" });
});
it("says why an ownerless card is answerable and offers Claim only where an addressee can be stored", () => {
expect(describeAttentionAudience(item({ kind: "pool", userId: null, reason: "none" }), "scott"))
.toEqual({ kind: "pool", text: ATTENTION_POOL_AUDIENCE_TEXT, claimable: true });
expect(describeAttentionAudience(item({ kind: "pool", userId: null, reason: "none" }, "decision"), "scott"))
.toEqual({ kind: "pool", text: ATTENTION_POOL_AUDIENCE_TEXT, claimable: false });
// An ownerless interaction that moved to Company after 7 days can still be claimed.
expect(describeAttentionAudience(item({ kind: "company", userId: null, reason: "none" }), "scott"))
.toEqual({ kind: "company", text: ATTENTION_COMPANY_AUDIENCE_TEXT, claimable: true });
expect(attentionItemClaimable(item({ kind: "company", userId: null, reason: "none" }, "failed_run"))).toBe(false);
expect(attentionItemClaimable(item({ kind: "user", userId: "dotta", reason: "addressee" }))).toBe(false);
});
it("says \"Transferred by\" on a card routed to you (PAP-749 plan §6)", () => {
expect(describeAttentionAudience(item({ kind: "user", userId: "riley", reason: "addressee", transferredByUserId: "scott" }), "riley"))
.toEqual({ kind: "mine", text: "Transferred by", transferredByUserId: "scott" });
});
it("phrases receipts with display names, never IDs", () => {
const labels = new Map([["scott", "Scott"], ["riley", "Riley"]]);
const receipt = (outcome: AttentionReceipt["outcome"], transferredToUserId: string | null = null) =>
({ outcome, actorUserId: "scott", transferredToUserId }) as AttentionReceipt;
expect(attentionReceiptLine(receipt("answered"), labels)).toBe("Answered by @Scott");
expect(attentionReceiptLine(receipt("withdrawn"), labels)).toBe("Withdrawn by @Scott");
expect(attentionReceiptLine(receipt("transferred", "riley"), labels)).toBe("Transferred to @Riley by @Scott");
expect(attentionReceiptLine(receipt("approved"), null)).toBe("Approved by @someone");
});
it("says who may answer in the same statement as why the card is here (PAP-767)", () => {
const resolver = (overrides: Partial<NonNullable<AttentionItem["resolverAudience"]>> = {}) => ({
requestedResolverPolicy: "anyone", effectiveResolverPolicy: "anyone", effectiveResolverPolicySource: "requested",
resolverPolicyProvenance: "inherited", addresseeAgentId: null, addresseeUserId: null, addresseeName: null,
createdByAgentId: "agent-1", createdByAgentName: "Watchdog", ...overrides,
}) as NonNullable<AttentionItem["resolverAudience"]>;
const card = (audience: AttentionAudience, resolverAudience: AttentionItem["resolverAudience"]) =>
({ ...item(audience), resolverAudience }) as AttentionItem;
const describe_ = (entry: AttentionItem, viewer: string) => describeAttentionAudience(entry, viewer)!;
// NEE-15: the project owner's card is open to anyone, so others may answer it too.
const owned = card({ kind: "user", userId: "dotta", reason: "project_owner" }, resolver());
expect(attentionResponderText(owned, describe_(owned, "dotta"))).toBe("Anyone else can answer it too.");
const sent = card({ kind: "user", userId: "dotta", reason: "addressee" }, resolver({ addresseeUserId: "dotta" }));
expect(attentionResponderText(sent, describe_(sent, "dotta"))).toBe("Only you can answer it.");
expect(attentionResponderText(sent, describe_(sent, "scott"), new Map([["dotta", "Dotta"]]))).toBe("Only @Dotta can answer it.");
// An ownerless card addressed to a napping agent names the agent and the board, once.
const napping = card({ kind: "pool", userId: null, reason: "none" }, resolver({ addresseeAgentId: "agent-2", addresseeName: "Napping Agent" }));
const pool = describe_(napping, "scott") as Extract<ReturnType<typeof describeAttentionAudience>, { kind: "pool" }>;
expect(attentionUnownedAudienceText(napping, pool)).toBe("No owner yet. Napping Agent or any board member can answer it.");
const boardOnly = card({ kind: "pool", userId: null, reason: "none" }, resolver({ effectiveResolverPolicy: "human_only" }));
expect(attentionUnownedAudienceText(boardOnly, pool)).toBe("No owner yet. Any board member can answer it.");
const notCreator = card({ kind: "pool", userId: null, reason: "none" }, resolver({ effectiveResolverPolicy: "not_creator" }));
expect(attentionUnownedAudienceText(notCreator, pool)).toBe("No owner yet. Anyone except Watchdog can answer it.");
// A card with no resolver facts (an approval, a recovery) keeps the plain line.
const approval = { ...item({ kind: "pool", userId: null, reason: "none" }, "approval"), resolverAudience: null } as AttentionItem;
expect(attentionUnownedAudienceText(approval, pool)).toBe(ATTENTION_POOL_AUDIENCE_TEXT);
expect(attentionAudienceLineNamesResponders(napping, "scott")).toBe(true);
expect(attentionAudienceLineNamesResponders(napping, null)).toBe(false);
expect(attentionAudienceLineNamesResponders(approval, "scott")).toBe(false);
});
it("offers the task instead of Claim on an ownerless card with no owner field (PAP-767)", () => {
const pool: AttentionAudience = { kind: "pool", userId: null, reason: "none" };
const withTask = { ...item(pool, "blocker_attention"), subject: { id: "i", kind: "issue", href: "/NEE/issues/NEE-16" } } as unknown as AttentionItem;
expect(attentionClaimAlternative(withTask)).toEqual({ kind: "assign", href: "/NEE/issues/NEE-16" });
const noTask = { ...item(pool, "approval"), relatedIssue: null } as AttentionItem;
expect(attentionClaimAlternative(noTask)).toEqual({ kind: "none" });
// Claim itself, a card for a person and a company alert need no alternative.
expect(attentionClaimAlternative(item(pool))).toBeNull();
expect(attentionClaimAlternative(item({ kind: "user", userId: "scott", reason: "addressee" }, "approval"))).toBeNull();
expect(attentionClaimAlternative({ ...item({ kind: "company", userId: null, reason: "none" }, "failed_run"), stale: null } as AttentionItem)).toBeNull();
const unowned = { ...item({ kind: "company", userId: null, reason: "none" }, "blocker_attention"), stale: { reason: "unowned" },
subject: { id: "i", kind: "issue", href: "/NEE/issues/NEE-9" } } as unknown as AttentionItem;
expect(attentionClaimAlternative(unowned)).toEqual({ kind: "assign", href: "/NEE/issues/NEE-9" });
});
it("phrases the Unassigned banner", () => {
expect(unassignedBannerText(1)).toBe("1 card needs an owner");
expect(unassignedBannerText(3)).toBe("3 cards need an owner");
});
});
+202
View File
@@ -0,0 +1,202 @@
import {
ATTENTION_CLAIMABLE_SOURCE_KINDS,
ATTENTION_PERSONAL_VIEWS,
attentionAudienceReasonText,
attentionReceiptText,
type AttentionItem,
type AttentionPersonalView,
type AttentionReceipt,
type AttentionViewCounts,
} from "@paperclipai/shared";
import type { CurrentBoardAccess } from "@/api/access";
/**
* The view switch on the classic Decisions page and Coordinator Needs You
* (PAP-749 plan §1, Rule 3; §3). "For you" is the default; the URL keeps the
* others so a link (and the Unassigned banner) can open them directly.
* Archived holds the cards you archived or snoozed for yourself. Everyone
* holds every person's cards, for board members only (PAP-768).
*/
export const ATTENTION_VIEW_LABELS: Record<AttentionPersonalView, string> = {
mine: "For you",
unassigned: "Unassigned",
company: "Company",
archived: "Archived",
all: "Everyone",
};
export const ATTENTION_VIEW_PARAM = "view";
/** The views a person can switch to: Everyone only for a board member. */
export function attentionViewsFor(everyone: boolean): readonly AttentionPersonalView[] {
return everyone ? ATTENTION_PERSONAL_VIEWS : ATTENTION_PERSONAL_VIEWS.filter((view) => view !== "all");
}
/** The view a URL asks for; "For you" when it names a view this person cannot open. */
export function parseAttentionView(value: string | null | undefined, options: { everyone?: boolean } = {}): AttentionPersonalView {
return (attentionViewsFor(options.everyone === true) as readonly string[]).includes(value ?? "")
? value as AttentionPersonalView
: "mine";
}
export const EMPTY_ATTENTION_VIEW_COUNTS: AttentionViewCounts = { mine: 0, unassigned: 0, company: 0, archived: 0, all: 0 };
/**
* Whether the signed-in person is a board member of `companyId`, who may see
* Everyone (PAP-768): a local board, an instance admin, or an active member
* who can act (owner, admin, operator, or a legacy member). A viewer cannot
* act on another person's card, so the tab stays hidden. False while the
* access snapshot is unknown. The server still checks every action.
*/
export function attentionEveryoneViewAllowed(
companyId: string | null | undefined,
boardAccess: Pick<CurrentBoardAccess, "source" | "isInstanceAdmin" | "memberships"> | null | undefined,
): boolean {
if (!boardAccess || !companyId) return false;
if (boardAccess.source === "local_implicit" || boardAccess.isInstanceAdmin) return true;
const membership = boardAccess.memberships?.find((entry) => entry.companyId === companyId && entry.status === "active");
return Boolean(membership?.membershipRole && membership.membershipRole !== "viewer");
}
/** "N cards need an owner" (recommendation e). Never part of the badge. */
export function unassignedBannerText(count: number) {
return count === 1 ? "1 card needs an owner" : `${count} cards need an owner`;
}
/**
* What a card says about its audience (PAP-749 plan §1, Rule 4). A card for
* you says why ("Sent to you"); a card for a different person shows "For
* @name"; an ownerless card says why you can still answer it and whether you
* can claim it; a company alert says it is for operators. Placement only: the
* resolver policy, not this line, decides who may act.
*/
export type AttentionAudienceDescription =
| { kind: "mine"; text: string; transferredByUserId?: string }
| { kind: "other"; userId: string }
| { kind: "pool"; text: string; claimable: boolean }
| { kind: "company"; text: string; claimable: boolean };
export const ATTENTION_POOL_AUDIENCE_TEXT = "No owner yet. Any board member can answer it.";
export const ATTENTION_COMPANY_AUDIENCE_TEXT = "Company alert for operators";
/**
* Whether Claim can store an owner for this card. Only sources with an
* addressee field qualify, and only while nobody owns the card: in the pool,
* or moved to Company after a week without an owner.
*/
export function attentionItemClaimable(item: AttentionItem): boolean {
const audience = item.audience;
if (!audience || audience.userId) return false;
if (audience.kind !== "pool" && audience.kind !== "company") return false;
return (ATTENTION_CLAIMABLE_SOURCE_KINDS as readonly string[]).includes(item.sourceKind);
}
/**
* What an ownerless card offers when Claim cannot store an owner on it
* (PAP-767): its task, where a person can be assigned, which gives the card
* that owner. A card with no task (an approval no task waits on) says it
* cannot be claimed instead of showing nothing. `null` for a claimable card
* and for a company alert, which nobody owns by design.
*/
export type AttentionClaimAlternative = { kind: "assign"; href: string } | { kind: "none" };
export function attentionClaimAlternative(item: AttentionItem): AttentionClaimAlternative | null {
const audience = item.audience;
if (!audience || audience.userId || attentionItemClaimable(item)) return null;
const unowned = audience.kind === "pool" || (audience.kind === "company" && item.stale?.reason === "unowned");
if (!unowned) return null;
const task = item.relatedIssue ?? (item.subject.kind === "issue" ? item.subject : null);
return task?.href ? { kind: "assign", href: task.href } : { kind: "none" };
}
export const ATTENTION_NO_CLAIM_TEXT = "It has no task to assign, so it can't be claimed.";
export function describeAttentionAudience(
item: AttentionItem,
currentUserId: string | null | undefined,
): AttentionAudienceDescription | null {
const audience = item.audience;
if (!audience) return null;
if (audience.kind === "user" && audience.userId) {
if (audience.userId !== currentUserId) return { kind: "other", userId: audience.userId };
// "Transferred by @x" replaces "Sent to you" (PAP-749 plan §6, Rule 12).
if (audience.transferredByUserId) {
return { kind: "mine", text: "Transferred by", transferredByUserId: audience.transferredByUserId };
}
const text = attentionAudienceReasonText(audience.reason);
return text ? { kind: "mine", text } : null;
}
if (audience.kind === "pool") {
return { kind: "pool", text: ATTENTION_POOL_AUDIENCE_TEXT, claimable: attentionItemClaimable(item) };
}
return { kind: "company", text: ATTENTION_COMPANY_AUDIENCE_TEXT, claimable: attentionItemClaimable(item) };
}
/**
* Who may answer an interaction card, worded to agree with its audience line
* so the two read as one statement (PAP-767): "Why me? You own this project's
* decisions. Anyone else can answer it too." and "No owner yet. Napping Agent
* or any board member can answer it." It reads the server-evaluated resolver
* facts, in the precedence of `describeResolverAudience`. `null` when the
* card carries no resolver facts: then the plain audience line stands alone.
*/
export function attentionResponderText(
item: Pick<AttentionItem, "sourceKind" | "resolverAudience">,
description: AttentionAudienceDescription,
userLabelMap?: ReadonlyMap<string, string> | null,
): string | null {
const facts = item.sourceKind === "issue_thread_interaction" ? item.resolverAudience : null;
if (!facts) return null;
const mine = description.kind === "mine";
const too = mine ? " too" : "";
if (facts.addresseeUserId) {
if (mine) return "Only you can answer it.";
return `Only @${userLabelMap?.get(facts.addresseeUserId) ?? "the addressed person"} can answer it.`;
}
if (facts.effectiveResolverPolicy === "human_only") return `Any board member can answer it${too}.`;
if (facts.addresseeAgentId) return `${facts.addresseeName?.trim() || "The addressed agent"} or any board member can answer it${too}.`;
if (facts.effectiveResolverPolicy === "not_creator") {
return `Anyone except ${facts.createdByAgentName?.trim() || "the agent that asked"} can answer it${too}.`;
}
return mine ? "Anyone else can answer it too." : "Anyone can answer it.";
}
/**
* The text of an ownerless or company card's audience line, with who may
* answer it when the card says (PAP-767).
*/
export function attentionUnownedAudienceText(
item: Pick<AttentionItem, "sourceKind" | "resolverAudience">,
description: Extract<AttentionAudienceDescription, { kind: "pool" | "company" }>,
userLabelMap?: ReadonlyMap<string, string> | null,
) {
const responders = attentionResponderText(item, description, userLabelMap);
if (!responders) return description.text;
return description.kind === "pool" ? `No owner yet. ${responders}` : `${description.text}. ${responders}`;
}
/**
* Whether the card's audience line already says who may answer it, so a row
* leaves out the separate "Anyone can respond" clause (PAP-767).
*/
export function attentionAudienceLineNamesResponders(item: AttentionItem, currentUserId: string | null | undefined) {
const description = currentUserId ? describeAttentionAudience(item, currentUserId) : null;
return Boolean(description && attentionResponderText(item, description));
}
/** Display name for a person on a receipt; never an ID. */
function receiptUserLabel(userId: string | null | undefined, userLabelMap?: ReadonlyMap<string, string> | null) {
return (userId ? userLabelMap?.get(userId) : null) ?? "someone";
}
/**
* The first line of a receipt (PAP-749 plan §6, Rule 12): "Answered by
* @scott", "Withdrawn by @scott", "Transferred to @riley by @scott".
*/
export function attentionReceiptLine(receipt: AttentionReceipt, userLabelMap?: ReadonlyMap<string, string> | null) {
return attentionReceiptText({
outcome: receipt.outcome,
actorLabel: receiptUserLabel(receipt.actorUserId, userLabelMap),
transferredToLabel: receipt.transferredToUserId ? receiptUserLabel(receipt.transferredToUserId, userLabelMap) : null,
});
}
+19 -2
View File
@@ -108,11 +108,18 @@ describe("isInlineResolvable", () => {
expect(isInlineResolvable(buildItem({ sourceKind: "review", inlineResolvable: false }))).toBe(false);
});
it("deep-links recovery/failure/budget rows rather than inlining", () => {
for (const kind of ["recovery_action", "failed_run", "budget_alert", "blocker_attention"] as AttentionSourceKind[]) {
it("deep-links recovery/blocker/agent-error rows rather than inlining", () => {
for (const kind of ["recovery_action", "blocker_attention", "agent_error_alert"] as AttentionSourceKind[]) {
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(false);
}
});
it("inlines failed-run Retry and budget verbs only when the server flags them (PAP-749 Rule 7)", () => {
for (const kind of ["failed_run", "budget_alert"] as AttentionSourceKind[]) {
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: true }))).toBe(true);
expect(isInlineResolvable(buildItem({ sourceKind: kind, inlineResolvable: false }))).toBe(false);
}
});
});
describe("attentionBadgeCount", () => {
@@ -407,6 +414,16 @@ describe("attentionDetailLine (§7)", () => {
expect(line).toContain("exit code 1");
});
it("shows budget amounts as dollars, not cents (PAP-767)", () => {
const line = attentionDetailLine(
buildItem({
sourceKind: "budget_alert",
detail: { kind: "budget", observedPercent: 100, amountObserved: 10000, amountLimit: 10000, images: [] },
}),
);
expect(line).toBe("100% of budget used ($100.00 / $100.00)");
});
it("returns null when there is no detail block", () => {
expect(attentionDetailLine(buildItem({ detail: null }))).toBeNull();
});
+18 -13
View File
@@ -9,14 +9,14 @@ import type {
AttentionSourceKind,
AttentionWorkspaceRef,
} from "@paperclipai/shared";
import { formatCents } from "./utils";
export type AttentionListOptions = AttentionFeedQuery;
/**
* Source kinds the queue can fully resolve in-row. Everything else deep-links
* to its native surface — the state-derived sources (recovery, failures,
* budget) expose verbs too rich to safely inline here, so they open their
* surface.
* to its native surface — recovery, blockers and agent errors keep their
* verbs on their own pages, so they open that surface.
*
* `review` is inline *only when stalled* (PAP-16080 §4.4): a stalled review has
* no interaction/approval/monitor to open, so the three review verbs
@@ -24,6 +24,10 @@ export type AttentionListOptions = AttentionFeedQuery;
* `inlineResolvable` on for exactly those rows (`isInlineResolvable` still ANDs
* that flag). A *covered* review keeps deep-linking, since its real action
* lives on the issue (the pending card, a monitor, a live run).
*
* `failed_run` (Retry, when the run has a task) and `budget_alert` (Raise
* budget, Keep paused) are inline since PAP-749 Rule 7: the server offers only
* verbs that work, and the server flag still decides each row.
*/
export const INLINE_RESOLVABLE_SOURCE_KINDS: ReadonlySet<AttentionSourceKind> = new Set<AttentionSourceKind>([
"approval",
@@ -31,6 +35,8 @@ export const INLINE_RESOLVABLE_SOURCE_KINDS: ReadonlySet<AttentionSourceKind> =
"issue_thread_interaction",
"join_request",
"review",
"failed_run",
"budget_alert",
]);
export function isInlineResolvable(item: AttentionItem): boolean {
@@ -240,7 +246,8 @@ export function attentionDetailLine(item: AttentionItem): string | null {
return b.title ? `Blocked by ${id}${b.title}` : b.identifier ? `Blocked by ${b.identifier}` : null;
}
case "budget":
return `${Math.round(detail.observedPercent)}% of budget used ($${detail.amountObserved} / $${detail.amountLimit})`;
// Budget amounts are cents, like the Spend line of the budget card.
return `${Math.round(detail.observedPercent)}% of budget used (${formatCents(detail.amountObserved)} / ${formatCents(detail.amountLimit)})`;
case "generic":
return quote(detail.summaryExcerpt);
default:
@@ -264,10 +271,9 @@ export function attentionImageUrl(assetId: string): string {
}
/**
* The sidebar badge: distinct items that either surfaced today or carry an
* explicit decide-by deadline that is due today/past. The server computes this
* before pagination (`deskBadgeCount`), so badge polling can fetch a small
* first page without losing the company-wide signal.
* The shared badge: current "For you" cards (PAP-749 plan §1, Rule 5). The
* server counts it before pagination (`deskBadgeCount`), so a small first page
* still carries the full count. It has no day boundary.
*/
export function attentionBadgeCount(feed: AttentionFeed | null | undefined): number {
return feed?.deskBadgeCount ?? 0;
@@ -282,11 +288,10 @@ export function attentionBadgeCount(feed: AttentionFeed | null | undefined): num
// is due today/past. No deadline set anywhere → no shelf, no claim.
// • "New today" — decisions that surfaced today (arrival grouping).
// • "Earlier" — everything else, older arrivals.
// The server owns the authoritative decide-by ranking (`sort=decide`) and the
// badge (`deskBadgeCount`); these client helpers mirror that logic *exactly*
// (same UTC day boundaries) so the on-page split and the badge never disagree.
// Keep in lockstep with `server/src/services/attention.ts`
// (`decideOrder`/`isDecideNow`/`isNewToday`).
// The server owns the authoritative decide-by ranking (`sort=decide`); these
// client helpers mirror its UTC day boundaries exactly. Keep in lockstep with
// `decideOrder` in `server/src/services/attention.ts`. The shelves only group
// the desk; the badge counts "For you" cards and ignores the day (PAP-749).
// ---------------------------------------------------------------------------
const MS_PER_DAY_DECIDE = 24 * 60 * 60 * 1_000;
+103 -2
View File
@@ -1,6 +1,18 @@
import { describe, expect, it } from "vitest";
import type { AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
import { COORDINATOR_RECENT_DECISION_MS, pendingDecisionTaskIds, recentDecisionReceipts } from "./coordinator-attention";
import { attentionReceiptText, type AttentionItem, type DecisionHistoryItem } from "@paperclipai/shared";
import {
COORDINATOR_RECENT_DECISION_MS,
DECISION_HISTORY_OUTCOME_OPTIONS,
coordinatorAudienceKey,
coordinatorAssignee,
coordinatorAssigneeTarget,
coordinatorCardDetail,
coordinatorCardTitle,
decisionHistoryContext,
decisionHistoryHeadline,
pendingDecisionTaskIds,
recentDecisionReceipts,
} from "./coordinator-attention";
describe("Coordinator attention projections", () => {
it("lists one task for two pending decisions and removes it only after the last decision leaves", () => {
@@ -23,3 +35,92 @@ describe("Coordinator attention projections", () => {
expect([...pendingDecisionTaskIds([])]).toEqual([]);
});
});
describe("Decision history presentation (PAP-749 task 2e)", () => {
const entry = (overrides: Partial<DecisionHistoryItem>) => ({
id: "issue_thread_interaction:a", sourceKind: "issue_thread_interaction", sourceId: "a", issueId: "task-1", issueIdentifier: "PAP-1",
issueTitle: "Launch copy", title: "Copy review", label: "Withdrawn by user", outcome: "cancelled", resultOutcome: "withdrawn",
resolvedAt: "2026-10-30T12:00:00.000Z", resolvedByUserId: "scott", resolvedByAgentId: null, agentId: null, agentName: null,
projectId: null, projectName: null, href: "/PAP/issues/PAP-1#interaction-a", target: null, ...overrides,
}) as DecisionHistoryItem;
it("offers Withdrawn, Expired and Closed with task as separate outcome filters", () => {
expect(DECISION_HISTORY_OUTCOME_OPTIONS.map((option) => option.label)).toEqual(expect.arrayContaining(["Withdrawn", "Expired", "Closed with task"]));
expect(DECISION_HISTORY_OUTCOME_OPTIONS.find((option) => option.label === "Closed with task")?.value).toBe("issue_closed");
});
it("names who withdrew a card and keeps every other label", () => {
const labels = new Map([["scott", "Scott"]]);
expect(decisionHistoryHeadline(entry({}), labels)).toBe("Withdrawn by @Scott");
expect(decisionHistoryHeadline(entry({ resolvedByUserId: null, label: "Withdrawn" }), labels)).toBe("Withdrawn");
expect(decisionHistoryHeadline(entry({ resultOutcome: null, label: "Approved", outcome: "accepted" }), labels)).toBe("Approved");
});
it("names who acted with the receipt's verb for the same action (PAP-767)", () => {
const labels = new Map([["scott", "Scott"], ["dotta", "Dotta"]]);
const approved = entry({ resultOutcome: null, label: "Approved", outcome: "accepted", action: { outcome: "approved", detail: null } });
expect(decisionHistoryHeadline(approved, labels)).toBe("Approved by @Scott");
expect(decisionHistoryHeadline(approved, labels)).toBe(attentionReceiptText({ outcome: "approved", actorLabel: "Scott" }));
const keptPaused = entry({ sourceKind: "approval", resultOutcome: null, label: "Kept paused", outcome: "rejected",
resolvedByUserId: "dotta", action: { outcome: "kept_paused", detail: null } });
expect(decisionHistoryHeadline(keptPaused, labels)).toBe("Kept paused by @Dotta");
const decided = entry({ sourceKind: "decision", resultOutcome: null, label: "Ship it", outcome: "decided", action: { outcome: "decided", detail: "Ship it" } });
expect(decisionHistoryHeadline(decided, labels)).toBe("Decided by @Scott: Ship it");
// An expired card has no actor: the label stays.
expect(decisionHistoryHeadline(entry({ resultOutcome: null, label: "Expired", outcome: "expired", resolvedByUserId: null, action: null }), labels)).toBe("Expired");
});
it("titles a card the way Needs You shows it, for the Withdraw all stale list (PAP-767)", () => {
const base = {
id: "c", sourceKind: "issue_thread_interaction", inlineResolvable: true,
subject: { id: "i", kind: "interaction", title: "Lingering on a done task", metadata: {} },
relatedIssue: { id: "t", identifier: "PAP-19", title: "Confirmation on a done task" }, whyNow: "Why",
} as unknown as AttentionItem;
// An inline interaction card is named by its task link.
expect(coordinatorCardTitle(base)).toBe("Confirmation on a done task");
// Every other card leads with its heading.
expect(coordinatorCardTitle({ ...base, sourceKind: "approval", subject: { ...base.subject, kind: "approval", title: "Approve the plan" } } as AttentionItem))
.toBe("Approve the plan");
expect(coordinatorCardTitle({ ...base, inlineResolvable: false } as AttentionItem)).toBe("Lingering on a done task");
});
it("gives a card titled with its bare task or agent name an ask, and explains a stuck task (PAP-770)", () => {
const blocker = {
id: "b", sourceKind: "blocker_attention", inlineResolvable: false, whyNow: "Blocks 0 tasks and needs human attention.",
subject: { id: "t", kind: "issue", title: "Today's weather forecast", metadata: {} }, relatedIssue: null,
detail: { kind: "blocker", blockingIssue: null, blockedTaskCount: 0, images: [] },
} as unknown as AttentionItem;
expect(coordinatorCardTitle(blocker)).toBe("Get “Today's weather forecast” moving again");
expect(coordinatorCardDetail(blocker, null)).toBe("No one is working on it. Unblock it, reassign it, or nudge its owner.");
const holding = { ...blocker, detail: { kind: "blocker", blockingIssue: null, blockedTaskCount: 2, images: [] } } as unknown as AttentionItem;
expect(coordinatorCardDetail(holding, null)).toBe("It's holding up 2 tasks, and no one is working on it. Unblock it, reassign it, or nudge its owner.");
const agentError = { ...blocker, sourceKind: "agent_error_alert", subject: { id: "a", kind: "agent", title: "Chief of Staff" },
detail: { kind: "agent_error", agentName: "Chief of Staff", failureReasonExcerpt: null, images: [] } } as unknown as AttentionItem;
expect(coordinatorCardTitle(agentError)).toBe("Chief of Staff hit an error and needs help");
});
it("shows the task, or names an approval that has no task", () => {
expect(decisionHistoryContext(entry({}))).toBe("PAP-1 · Launch copy");
expect(decisionHistoryContext(entry({ sourceKind: "approval", issueId: null, issueIdentifier: null, issueTitle: null, title: "Hire a designer" })))
.toBe("Approval · Hire a designer");
});
});
describe("who a card is for (PAP-775)", () => {
const card = (audience: unknown) => ({ audience }) as Pick<AttentionItem, "audience">;
it("keys a card by its person, No owner yet or Company", () => {
expect(coordinatorAudienceKey(card({ kind: "user", userId: "dotta", reason: "addressee" }))).toBe("user:dotta");
expect(coordinatorAudienceKey(card({ kind: "pool", userId: null, reason: "none" }))).toBe("unassigned");
expect(coordinatorAudienceKey(card({ kind: "company", userId: null, reason: "none" }))).toBe("company");
});
it("maps the assignee filter to a view and back", () => {
expect(coordinatorAssignee("mine", null)).toBe("me");
expect(coordinatorAssignee("all", null)).toBe("all");
expect(coordinatorAssignee("all", "dotta")).toBe("user:dotta");
expect(coordinatorAssignee("archived", null)).toBeNull();
expect(coordinatorAssigneeTarget("user:dotta", "scott")).toEqual({ view: "all", forUserId: "dotta" });
expect(coordinatorAssigneeTarget("user:scott", "scott")).toEqual({ view: "mine", forUserId: null });
expect(coordinatorAssigneeTarget("unassigned", "scott")).toEqual({ view: "unassigned", forUserId: null });
});
});
+139 -2
View File
@@ -1,7 +1,61 @@
import type { AttentionItem, DecisionHistoryItem } from "@paperclipai/shared";
import { attentionReceiptText, type AttentionItem, type AttentionPersonalView, type DecisionHistoryItem, type DecisionHistoryOutcomeFilter, type DecisionHistoryScope } from "@paperclipai/shared";
import { queryKeys } from "./queryKeys";
import { isInlineResolvable } from "./attention";
export const coordinatorAttentionQueryKey = (companyId: string) => [...queryKeys.attention(companyId), "coordinator", "pending"] as const;
/**
* The pending feed for one person and one view (PAP-749 plan §1). The key
* holds the user, so switching accounts never shows the previous person's
* cached list. The "For you" key is shared by the page, the sidebar, the mobile
* navigation and the Tasks filter, so the badge always agrees with the list.
*/
export const coordinatorAttentionQueryKey = (companyId: string, userId?: string | null, view?: AttentionPersonalView) =>
userId === undefined && view === undefined
? [...queryKeys.attention(companyId), "coordinator", "pending"] as const
: [...queryKeys.attention(companyId), userId, view, "coordinator", "pending"] as const;
/**
* The title a Needs You card leads with, so a list of cards (the Withdraw all
* stale confirmation) names each one the way the card does (PAP-767). An
* inline interaction or stalled review card renders its own resolver, and its
* task link names it; every other card has a heading.
*/
export function coordinatorCardTitle(item: AttentionItem) {
const task = item.relatedIssue ?? (item.subject.kind === "issue" ? item.subject : null);
const inline = isInlineResolvable(item)
&& (item.sourceKind === "issue_thread_interaction"
|| (item.sourceKind === "review" && item.subject.kind === "issue" && item.subject.metadata?.reviewAttentionState === "stalled"));
if (inline && task?.title) return task.title;
return coordinatorCardAsk(item, task?.title ?? null) ?? item.subject.title ?? item.whyNow;
}
/**
* Focus (PAP-770): a card's heading says what to do. Some sources are titled
* with the bare task or agent name ("Today's weather forecast", "Chief of
* Staff"), which repeats the task link and asks nothing; those get an ask.
*/
function coordinatorCardAsk(item: AttentionItem, taskTitle: string | null): string | null {
const title = item.subject.title?.trim() || null;
if (item.sourceKind === "blocker_attention" && title) return `Get “${title}” moving again`;
if (item.sourceKind === "agent_error_alert") {
const agent = item.detail?.kind === "agent_error" ? item.detail.agentName ?? title : title;
return agent ? `${agent} hit an error and needs help` : null;
}
if (item.sourceKind === "review" && title && title === taskTitle) return `Review “${title}”`;
// PAP-775 (UI1): approvals and join requests are answered on the card now.
if (item.sourceKind === "approval" && title && !/^approve\b/i.test(title)) return `Approve “${title}”?`;
if (item.sourceKind === "join_request" && title) return `Let ${title} join the company?`;
return null;
}
/** The context under a card's heading: what happened and what you can do about it. */
export function coordinatorCardDetail(item: AttentionItem, detailLine: string | null): string | null {
if (item.sourceKind === "blocker_attention" && item.detail?.kind === "blocker" && !item.detail.blockingIssue) {
const count = item.detail.blockedTaskCount ?? 0;
const holding = count > 0 ? `It's holding up ${count} ${count === 1 ? "task" : "tasks"}, and no` : "No";
return `${holding} one is working on it. Unblock it, reassign it, or nudge its owner.`;
}
return detailLine ?? item.whyNow;
}
export const coordinatorAttentionSummaryQueryKey = (companyId: string) => [...queryKeys.attention(companyId), "coordinator", "summary"] as const;
export const coordinatorAttentionTasksQueryKey = (companyId: string) => [...queryKeys.attention(companyId), "coordinator", "tasks"] as const;
@@ -15,3 +69,86 @@ export function recentDecisionReceipts(items: DecisionHistoryItem[], now: number
export function pendingDecisionTaskIds(items: AttentionItem[]) {
return new Set(items.flatMap((item) => item.relatedIssue ? [item.relatedIssue.id] : item.subject.kind === "issue" ? [item.subject.id] : []));
}
/**
* The Decision history outcome filter (PAP-749 plan §3). Withdrawn, Expired and
* Closed with task are separate: a card that closed with its task is not in
* Expired. "Handled for me" is a different filter (who the card was for), so
* it is not an outcome here.
*/
export const DECISION_HISTORY_OUTCOME_OPTIONS: ReadonlyArray<{ value: DecisionHistoryOutcomeFilter; label: string }> = [
{ value: "accepted", label: "Approved" },
{ value: "answered", label: "Answered" },
{ value: "rejected", label: "Changes requested" },
{ value: "decided", label: "Decided" },
{ value: "withdrawn", label: "Withdrawn" },
{ value: "expired", label: "Expired" },
{ value: "issue_closed", label: "Closed with task" },
{ value: "cancelled", label: "Cancelled" },
];
/**
* Whose decisions Decision history and Recent Decisions list (PAP-768). Your
* own is the default: what you answered, withdrew or transferred, what a
* different person handled for you, and the cards that were for you.
*/
export const DECISION_HISTORY_SCOPE_OPTIONS: ReadonlyArray<{ value: DecisionHistoryScope; label: string }> = [
{ value: "mine", label: "Handled by me or for me" },
{ value: "all", label: "Everyone" },
];
export const DEFAULT_DECISION_HISTORY_SCOPE: DecisionHistoryScope = "mine";
/**
* A history entry's headline. When a person acted, it names them with the
* receipt's verb for the same action ("Approved by @scott", "Kept paused by
* @dotta"; PAP-749 plan §3, PAP-767). A decision adds the option chosen and an
* item review its verdict counts. Everything else keeps the server's label.
*/
export function decisionHistoryHeadline(item: DecisionHistoryItem, userLabelMap?: ReadonlyMap<string, string> | null) {
const actorLabel = item.resolvedByUserId ? userLabelMap?.get(item.resolvedByUserId) ?? "someone" : null;
if (item.action && actorLabel) {
const line = attentionReceiptText({ outcome: item.action.outcome, actorLabel });
return item.action.detail ? `${line}: ${item.action.detail}` : line;
}
if (item.resultOutcome === "withdrawn" && actorLabel) return `Withdrawn by @${actorLabel}`;
return item.label;
}
/** The task line of a history entry; an approval with no task names itself instead. */
export function decisionHistoryContext(item: DecisionHistoryItem) {
if (!item.issueId) return item.sourceKind === "approval" ? `Approval · ${item.title}` : item.title;
return [item.issueIdentifier, item.issueTitle].filter(Boolean).join(" · ");
}
/**
* The toolbar's assignee filter (PAP-775): "Assigned to you" by default, every
* card the viewer may see ("all", a board member's Everyone), one person, the
* cards with no owner yet, or company alerts. Each value is one personal view
* of the feed, plus a person for "user:<id>"; the server routes every card and
* re-checks every action, so this only picks which cards to list.
*/
export type CoordinatorAssignee = "me" | "all" | "unassigned" | "company" | `user:${string}`;
/** The URL parameter that names the person in a `user:<id>` filter. */
export const COORDINATOR_ASSIGNEE_PARAM = "for";
export function coordinatorAudienceKey(item: Pick<AttentionItem, "audience">): Exclude<CoordinatorAssignee, "me" | "all"> {
const audience = item.audience;
if (audience?.kind === "user" && audience.userId) return `user:${audience.userId}`;
if (audience?.kind === "pool") return "unassigned";
return "company";
}
/** The filter a URL shows: its view, narrowed to one person in Everyone. */
export function coordinatorAssignee(view: AttentionPersonalView, forUserId: string | null | undefined): CoordinatorAssignee | null {
if (view === "archived") return null;
if (view === "all") return forUserId ? `user:${forUserId}` : "all";
return view === "mine" ? "me" : view;
}
/** The view (and person) to load for a filter value; your own name is "Assigned to you". */
export function coordinatorAssigneeTarget(assignee: CoordinatorAssignee, currentUserId: string | null | undefined): { view: AttentionPersonalView; forUserId: string | null } {
if (assignee === "me" || assignee === `user:${currentUserId}`) return { view: "mine", forUserId: null };
if (assignee.startsWith("user:")) return { view: "all", forUserId: assignee.slice(5) };
return { view: assignee as "all" | "unassigned" | "company", forUserId: null };
}
+55
View File
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import {
claimReviseDraftWork,
clearReviseDraft,
getReviseDraft,
releaseReviseDraftWork,
resetReviseDraftsForTests,
reviseDraftKey,
updateReviseDraft,
} from "./revise-draft-store";
afterEach(() => {
sessionStorage.clear();
resetReviseDraftsForTests();
});
describe("revise draft store", () => {
it("keys a draft by interaction and plan revision", () => {
expect(reviseDraftKey("i1", "r1")).toBe("i1:r1");
expect(reviseDraftKey("i1", null)).toBe("i1");
updateReviseDraft(reviseDraftKey("i1", "r1"), { revising: true, reason: "abc" });
expect(getReviseDraft("i1:r1")).toMatchObject({ revising: true, reason: "abc" });
// A newer revision of the same plan starts clean.
expect(getReviseDraft("i1:r2")).toMatchObject({ revising: false, reason: "" });
});
it("clears a draft", () => {
updateReviseDraft("k", { revising: true, reason: "abc" });
clearReviseDraft("k");
expect(getReviseDraft("k")).toMatchObject({ revising: false, reason: "" });
expect(sessionStorage.getItem("paperclip:revise-drafts")).toBeNull();
});
it("lets only one view send at a time", () => {
expect(claimReviseDraftWork("k", "reject")).toBe(true);
expect(claimReviseDraftWork("k", "reject")).toBe(false);
expect(claimReviseDraftWork("k", "accept")).toBe(false);
releaseReviseDraftWork("k");
expect(claimReviseDraftWork("k", "accept")).toBe(true);
});
it("survives a reload through sessionStorage, without the in-flight send", () => {
updateReviseDraft("k", { revising: true, reason: "keep me", working: "reject" });
resetReviseDraftsForTests();
expect(getReviseDraft("k")).toEqual({ revising: true, reason: "keep me", working: null });
});
it("ignores a corrupt stored value", () => {
sessionStorage.setItem("paperclip:revise-drafts", "{not json");
resetReviseDraftsForTests();
expect(getReviseDraft("k")).toMatchObject({ revising: false, reason: "" });
});
});
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useSyncExternalStore } from "react";
/**
* One "Revise…" draft per decision (PAP-773). On Needs You a plan card and its
* Review side panel are two views of the same decision; both read and write
* this store so opening Revise…, typing, cancelling and sending mirror live.
* The side panel renders through the layout's panel slot, outside the page's
* React tree, so this is a module store rather than a context.
*
* Key by interaction id + target revision id: a newer plan revision gets a
* fresh, empty draft. Drafts survive closing the panel or switching cards for
* the session (sessionStorage), and are cleared when the decision resolves.
*/
export type ReviseDraftWork = "accept" | "reject" | "revise";
export interface ReviseDraft {
revising: boolean;
reason: string;
/** Which action one of the views is sending right now; blocks a second send. */
working: ReviseDraftWork | null;
}
const STORAGE_KEY = "paperclip:revise-drafts";
const EMPTY_DRAFT: ReviseDraft = Object.freeze({ revising: false, reason: "", working: null }) as ReviseDraft;
const drafts = new Map<string, ReviseDraft>();
const listeners = new Set<() => void>();
let hydrated = false;
function hydrate() {
if (hydrated) return;
hydrated = true;
try {
const raw = globalThis.sessionStorage?.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, { revising?: unknown; reason?: unknown }>;
for (const [key, value] of Object.entries(parsed)) {
if (!value || typeof value !== "object") continue;
const reason = typeof value.reason === "string" ? value.reason : "";
const revising = value.revising === true;
if (revising || reason) drafts.set(key, { revising, reason, working: null });
}
} catch {
// A corrupt or unavailable store only costs the reload persistence.
}
}
function persist() {
try {
const out: Record<string, { revising: boolean; reason: string }> = {};
for (const [key, draft] of drafts) {
if (draft.revising || draft.reason) out[key] = { revising: draft.revising, reason: draft.reason };
}
if (Object.keys(out).length === 0) globalThis.sessionStorage?.removeItem(STORAGE_KEY);
else globalThis.sessionStorage?.setItem(STORAGE_KEY, JSON.stringify(out));
} catch {
// Storage full or blocked: the in-memory draft still syncs both views.
}
}
function emit() {
for (const listener of listeners) listener();
}
export function reviseDraftKey(interactionId: string, revisionId?: string | null) {
return revisionId ? `${interactionId}:${revisionId}` : interactionId;
}
export function getReviseDraft(key: string): ReviseDraft {
hydrate();
return drafts.get(key) ?? EMPTY_DRAFT;
}
export function updateReviseDraft(key: string, patch: Partial<ReviseDraft>) {
hydrate();
const current = drafts.get(key) ?? EMPTY_DRAFT;
const next = { ...current, ...patch };
if (next.revising === current.revising && next.reason === current.reason && next.working === current.working) return;
if (!next.revising && !next.reason && !next.working) drafts.delete(key);
else drafts.set(key, next);
persist();
emit();
}
export function clearReviseDraft(key: string) {
hydrate();
if (!drafts.has(key)) return;
drafts.delete(key);
persist();
emit();
}
/** Start sending from one view. Returns false when another view already is,
* so one decision can't be resolved twice by two buttons. */
export function claimReviseDraftWork(key: string, working: ReviseDraftWork): boolean {
if (getReviseDraft(key).working) return false;
updateReviseDraft(key, { working });
return true;
}
export function releaseReviseDraftWork(key: string) {
updateReviseDraft(key, { working: null });
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
/** Read and write the shared draft for `key`. A null key means "not shared";
* callers keep their own local state then. */
export function useReviseDraft(key: string | null) {
const draft = useSyncExternalStore(
subscribe,
() => (key ? getReviseDraft(key) : EMPTY_DRAFT),
() => EMPTY_DRAFT,
);
const update = useCallback((patch: Partial<ReviseDraft>) => {
if (key) updateReviseDraft(key, patch);
}, [key]);
return [draft, update] as const;
}
/** Test-only: forget every draft and the hydration flag. */
export function resetReviseDraftsForTests() {
drafts.clear();
hydrated = false;
emit();
}
+182 -16
View File
@@ -1,7 +1,8 @@
import { ChangeEvent, useEffect, useState } from "react";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
import { AgentObservabilitySettingsSection } from "../components/observability/AgentObservabilitySettings";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type InteractionResolverGovernance,
type IssueThreadInteractionKind,
@@ -14,10 +15,12 @@ import { resolveCompanyArchiveDeparture } from "../lib/company-selection";
import { cloudPortfolioManageUrl } from "../lib/cloudLinks";
import { navigateTopLevel } from "@/lib/browserNavigation";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
import { describeError } from "@/api/errors";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { NativeSelect } from "@/components/ui/select";
import { SlidersHorizontal } from "lucide-react";
import {
InteractionGovernancePanel,
@@ -28,6 +31,7 @@ import {
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import {
Field,
HintIcon,
ToggleField,
} from "../components/agent-config-primitives";
import { InstanceGeneralSettings } from "./InstanceGeneralSettings";
@@ -54,14 +58,24 @@ export function CompanySettings() {
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [governance, setGovernance] = useState<InteractionResolverGovernance>({});
// Sync local state from selected company
// Sync local state from the selected company. Keyed on the saved values, not
// the company object: the sections that save on change (Hiring, Decisions,
// governance, logo) refetch the company list, and that fresh object must not
// wipe an unsaved name or description draft.
const savedName = selectedCompany?.name ?? null;
const savedDescription = selectedCompany?.description ?? "";
const savedLogoUrl = selectedCompany?.logoUrl ?? "";
const savedGovernance = selectedCompany?.interactionResolverGovernance;
useEffect(() => {
if (!selectedCompany) return;
setCompanyName(selectedCompany.name);
setDescription(selectedCompany.description ?? "");
setLogoUrl(selectedCompany.logoUrl ?? "");
setGovernance(selectedCompany.interactionResolverGovernance ?? {});
}, [selectedCompany]);
if (savedName === null) return;
setCompanyName(savedName);
setDescription(savedDescription);
}, [selectedCompanyId, savedName, savedDescription]);
useEffect(() => {
if (savedName === null) return;
setLogoUrl(savedLogoUrl);
setGovernance(savedGovernance ?? {});
}, [selectedCompanyId, savedName, savedLogoUrl, savedGovernance]);
const generalDirty =
!!selectedCompany &&
@@ -88,6 +102,41 @@ export function CompanySettings() {
}
});
// Members populate the default decision owner and budget owner pickers.
// Ownerless "Needs You" cards route to the default decision owner's "For
// you" list; clearing it sends them to the claimable Unassigned pool
// (PAP-749 task 1d-lite). A budget hard stop also goes to the budget owner,
// and Company keeps showing it (task 1d).
const membersQuery = useQuery({
queryKey: queryKeys.access.companyMembers(selectedCompanyId ?? ""),
queryFn: () => accessApi.listMembers(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
// Each owner saves on change with only its own field, so neither belongs to
// the General draft or its "Save changes" button. Waiting for the refetch
// keeps the picker on the new owner instead of flashing back to the old one.
async function saveDefaultDecisionOwner(defaultDecisionOwnerUserId: string | null) {
const company = await companiesApi.update(selectedCompanyId!, { defaultDecisionOwnerUserId });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
return company;
}
async function saveBudgetOwner(budgetOwnerUserId: string | null) {
const company = await companiesApi.update(selectedCompanyId!, { budgetOwnerUserId });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
return company;
}
const membersView = useQueryView(membersQuery);
const memberOptions = (membersQuery.data?.members ?? [])
.filter((member) => member.user?.id)
.map((member) => (
<option key={member.user!.id} value={member.user!.id}>
{member.user!.name || member.user!.email || member.user!.id}
</option>
));
const governanceMutation = useMutation({
mutationFn: (next: InteractionResolverGovernance) =>
companiesApi.update(selectedCompanyId!, { interactionResolverGovernance: next }),
@@ -351,14 +400,57 @@ export function CompanySettings() {
</div>
</div>
{/* Interaction governance */}
<InteractionGovernancePanel
governance={governance}
onChange={handleGovernanceChange}
isPending={governanceMutation.isPending}
errorMessage={governanceMutation.error ? describeError(governanceMutation.error).body : null}
/>
{/* Decisions */}
<div className="max-w-2xl space-y-4" data-testid="company-settings-decisions-section">
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Decisions
</div>
<p className="text-xs text-muted-foreground">
Each owner saves as soon as you choose it.
</p>
</div>
{membersView.kind === "error" ? <QueryErrorState error={membersView.error} onRetry={membersView.retry} /> : null}
<DecisionOwnerField
label="Default decision owner"
hint="When a card needs a person but has no explicit owner, it goes to this user's For you list. Leave as Unassigned to keep ownerless cards in the claimable pool."
testId="company-settings-default-decision-owner"
unsetLabel="Unassigned (no default owner)"
savedUserId={selectedCompany.defaultDecisionOwnerUserId ?? null}
memberOptions={memberOptions}
membersLoading={membersQuery.isLoading}
fallbackError="Couldn't save the default decision owner."
save={saveDefaultDecisionOwner}
/>
<DecisionOwnerField
label="Budget owner"
hint="A budget hard stop also goes to this user's For you list, with Raise limit and Keep paused. The Company view still shows it to every operator."
testId="company-settings-budget-owner"
unsetLabel="No budget owner (Company view only)"
savedUserId={selectedCompany.budgetOwnerUserId ?? null}
memberOptions={memberOptions}
membersLoading={membersQuery.isLoading}
fallbackError="Couldn't save the budget owner."
save={saveBudgetOwner}
/>
</div>
{/* Interaction governance. Held to the form column like the sections
above; the wrapper scrolls rather than letting the policy grid push
past the column when the content area is narrow. The padding keeps
the selects' focus rings inside the scroll box, and the negative
inline margin keeps the panel aligned with the other sections. */}
<div
className="-mx-1 min-w-0 max-w-2xl overflow-x-auto p-1"
data-testid="company-settings-interaction-governance-frame"
>
<InteractionGovernancePanel
governance={governance}
onChange={handleGovernanceChange}
isPending={governanceMutation.isPending}
errorMessage={governanceMutation.error ? describeError(governanceMutation.error).body : null}
/>
</div>
<AgentObservabilitySettingsSection companyId={selectedCompanyId!} />
<InstanceGeneralSettings embedded />
@@ -407,3 +499,77 @@ export function CompanySettings() {
</div>
);
}
/**
* One Decisions owner picker. It saves on change, on its own, so the page's
* General "Save changes" never carries it. A real `<label htmlFor>` names the
* select, and the line under it reports saving, saved, or the server's error
* (for example the 422 for an owner who is not an active member).
*/
function DecisionOwnerField({
label,
hint,
testId,
unsetLabel,
savedUserId,
memberOptions,
membersLoading,
fallbackError,
save,
}: {
label: string;
hint: string;
testId: string;
unsetLabel: string;
savedUserId: string | null;
memberOptions: ReactNode;
membersLoading: boolean;
fallbackError: string;
save: (userId: string | null) => Promise<unknown>;
}) {
const controlId = useId();
const errorId = `${controlId}-error`;
const mutation = useMutation({
mutationFn: save,
// Quick successive picks save in order, so the last choice is what sticks.
scope: { id: `company-settings:${testId}` },
});
// Show the choice while it saves. Otherwise show what the server holds, so
// a rejected owner snaps back beside its error.
const value = mutation.isPending ? (mutation.variables ?? "") : (savedUserId ?? "");
const errorMessage = mutation.error ? describeError(mutation.error).body ?? fallbackError : null;
return (
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<label htmlFor={controlId} className="text-xs text-muted-foreground">
{label}
</label>
<HintIcon text={hint} />
</div>
<NativeSelect
id={controlId}
data-testid={testId}
value={value}
// Not disabled while saving: disabling would drop keyboard focus.
disabled={membersLoading}
aria-busy={mutation.isPending || undefined}
aria-invalid={errorMessage ? true : undefined}
aria-describedby={errorMessage ? errorId : undefined}
onChange={(event) => mutation.mutate(event.target.value === "" ? null : event.target.value)}
>
<option value="">{unsetLabel}</option>
{memberOptions}
</NativeSelect>
{errorMessage ? (
<p id={errorId} role="alert" className="text-xs text-destructive">
{errorMessage}
</p>
) : (
<p role="status" className="min-h-4 text-xs text-muted-foreground">
{mutation.isPending ? "Saving…" : mutation.isSuccess ? "Saved" : null}
</p>
)}
</div>
);
}
@@ -0,0 +1,320 @@
// @vitest-environment jsdom
import { act } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import { CompanySettings } from "./CompanySettings";
const mockCompaniesApi = vi.hoisted(() => ({
update: vi.fn(),
archive: vi.fn(),
}));
const mockAccessApi = vi.hoisted(() => ({
listMembers: vi.fn(),
}));
const mockAssetsApi = vi.hoisted(() => ({
uploadCompanyLogo: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
// A tiny external store stands in for the company list query, so a save can
// hand the page a fresh company object the way a real refetch does.
const companyStore = vi.hoisted(() => {
type Company = Record<string, unknown>;
const initial: Company = {
id: "company-1",
name: "Acme Robotics",
description: null,
status: "active",
issuePrefix: "ACM",
brandColor: null,
logoUrl: null,
attachmentMaxBytes: null,
requireBoardApprovalForNewAgents: false,
interactionResolverGovernance: {},
defaultDecisionOwnerUserId: null,
budgetOwnerUserId: "user-1",
};
let company: Company = initial;
const listeners = new Set<() => void>();
return {
initial,
get: () => company,
set(next: Company) {
company = next;
for (const listener of listeners) listener();
},
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
});
vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi }));
vi.mock("../api/access", () => ({ accessApi: mockAccessApi }));
vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi }));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
vi.mock("../context/CompanyContext", async () => {
const { useSyncExternalStore } = await import("react");
return {
useCompany: () => {
const company = useSyncExternalStore(companyStore.subscribe, companyStore.get);
return {
companies: [company],
selectedCompany: company,
selectedCompanyId: company.id,
setSelectedCompanyId: mockSetSelectedCompanyId,
};
},
};
});
// The governance panel has its own tests; here only its frame matters.
vi.mock("../components/InteractionGovernancePanel", () => ({
InteractionGovernancePanel: () => <div data-testid="interaction-governance-panel" />,
applyGovernanceChange: (governance: unknown) => governance,
}));
// These sections own their own queries (and their own "Save changes").
vi.mock("../components/observability/AgentObservabilitySettings", () => ({
AgentObservabilitySettingsSection: () => null,
}));
vi.mock("./InstanceGeneralSettings", () => ({
InstanceGeneralSettings: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const MEMBERS = {
members: [
{ id: "membership-1", user: { id: "user-1", name: "Ada Lovelace", email: "ada@example.test" } },
{ id: "membership-2", user: { id: "user-2", name: "Grace Hopper", email: "grace@example.test" } },
],
};
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void) {
let lastError: unknown;
for (let i = 0; i < 20; i += 1) {
await flushReact();
try {
assertion();
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
describe("CompanySettings Decisions section", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
companyStore.set({ ...companyStore.initial });
mockAccessApi.listMembers.mockResolvedValue(MEMBERS);
// Mirrors the server: the PATCH answers with the updated company, and the
// refetch then carries it to the page.
mockCompaniesApi.update.mockImplementation(
async (_companyId: string, patch: Record<string, unknown>) => {
await Promise.resolve();
const next = { ...companyStore.get(), ...patch };
companyStore.set(next);
return next;
},
);
});
afterEach(() => {
if (root) flushSync(() => root!.unmount());
root = null;
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function render() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
// CloudAccessGate owns the health fetch in the app.
queryClient.setQueryData(queryKeys.health, { status: "ok", cloud: null });
root = createRoot(container);
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<MemoryRouter>
<CompanySettings />
</MemoryRouter>
</TooltipProvider>
</QueryClientProvider>,
);
});
// Members fill the pickers before anyone can choose.
await waitForAssertion(() => {
expect(getByLabelText("Budget owner").querySelectorAll("option")).toHaveLength(3);
});
}
/** The control a `<label>` with exactly this text names via `htmlFor`. */
function getByLabelText(text: string): HTMLSelectElement {
const label = Array.from(container.querySelectorAll("label")).find(
(element) => element.textContent?.trim() === text,
);
expect(label, `label "${text}"`).toBeDefined();
expect(label!.htmlFor).not.toBe("");
const control = document.getElementById(label!.htmlFor);
expect(control).toBeInstanceOf(HTMLSelectElement);
return control as HTMLSelectElement;
}
function changeSelect(select: HTMLSelectElement, value: string) {
// Go through the native setter so React sees a real change.
Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value")!.set!.call(select, value);
act(() => {
select.dispatchEvent(new Event("change", { bubbles: true }));
});
}
function typeInto(input: HTMLInputElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value);
act(() => {
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
function statusFor(select: HTMLSelectElement) {
return select.parentElement!.querySelector("[role=status]")?.textContent?.trim() ?? "";
}
function alertFor(select: HTMLSelectElement) {
return select.parentElement!.querySelector("[role=alert]")?.textContent?.trim() ?? null;
}
function saveChangesButtons() {
return Array.from(container.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Save changes",
);
}
it("names both owner selects with an associated label", async () => {
await render();
const decisionOwner = getByLabelText("Default decision owner");
const budgetOwner = getByLabelText("Budget owner");
expect(decisionOwner.dataset.testid).toBe("company-settings-default-decision-owner");
expect(budgetOwner.dataset.testid).toBe("company-settings-budget-owner");
expect(Array.from(decisionOwner.labels ?? []).map((label) => label.textContent)).toEqual([
"Default decision owner",
]);
expect(budgetOwner.value).toBe("user-1");
});
it("saves the default decision owner on change with only that field", async () => {
await render();
const decisionOwner = getByLabelText("Default decision owner");
// Nothing is waiting on the page-level save before or after the change.
expect(saveChangesButtons()).toHaveLength(0);
changeSelect(decisionOwner, "user-2");
await waitForAssertion(() => {
expect(statusFor(getByLabelText("Default decision owner"))).toBe("Saved");
});
expect(mockCompaniesApi.update).toHaveBeenCalledTimes(1);
expect(mockCompaniesApi.update).toHaveBeenCalledWith("company-1", {
defaultDecisionOwnerUserId: "user-2",
});
expect(getByLabelText("Default decision owner").value).toBe("user-2");
expect(saveChangesButtons()).toHaveLength(0);
});
it("clears the budget owner with an explicit null", async () => {
await render();
changeSelect(getByLabelText("Budget owner"), "");
await waitForAssertion(() => {
expect(statusFor(getByLabelText("Budget owner"))).toBe("Saved");
});
expect(mockCompaniesApi.update).toHaveBeenCalledWith("company-1", { budgetOwnerUserId: null });
expect(getByLabelText("Budget owner").value).toBe("");
});
it("shows the server's rejection and keeps the saved owner", async () => {
const message = "Budget owner must be an active member of this company";
mockCompaniesApi.update.mockRejectedValueOnce(
new ApiError(message, 422, { error: message }),
);
await render();
changeSelect(getByLabelText("Budget owner"), "user-2");
await waitForAssertion(() => {
expect(alertFor(getByLabelText("Budget owner"))).toBe(message);
});
const budgetOwner = getByLabelText("Budget owner");
expect(budgetOwner.value).toBe("user-1");
expect(budgetOwner.getAttribute("aria-invalid")).toBe("true");
expect(document.getElementById(budgetOwner.getAttribute("aria-describedby")!)?.textContent).toBe(
message,
);
});
it("keeps an unsaved organization name draft when an owner saves", async () => {
await render();
const nameInput = container.querySelector<HTMLInputElement>("input[type=text]")!;
typeInto(nameInput, "Acme Labs");
expect(saveChangesButtons()).toHaveLength(1);
changeSelect(getByLabelText("Default decision owner"), "user-2");
await waitForAssertion(() => {
expect(statusFor(getByLabelText("Default decision owner"))).toBe("Saved");
});
// The owner PATCH did not carry the draft, and the refetch did not wipe it.
expect(mockCompaniesApi.update).toHaveBeenCalledWith("company-1", {
defaultDecisionOwnerUserId: "user-2",
});
expect(container.querySelector<HTMLInputElement>("input[type=text]")!.value).toBe("Acme Labs");
expect(saveChangesButtons()).toHaveLength(1);
});
it("frames the interaction governance table to the form column", async () => {
await render();
const frame = container.querySelector("[data-testid=interaction-governance-panel]")!.parentElement!;
expect(frame.dataset.testid).toBe("company-settings-interaction-governance-frame");
expect(frame.className.split(" ")).toEqual(
expect.arrayContaining(["min-w-0", "max-w-2xl", "overflow-x-auto"]),
);
});
});
+14 -6
View File
@@ -12,6 +12,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { useInboxDismissals } from "../hooks/useInboxBadge";
import { queryKeys } from "../lib/queryKeys";
import { attentionObligationEvidence, foldAttentionObligations } from "../lib/attention-obligations";
import {
ATTENTION_AGING_DAYS,
attentionIsAging,
@@ -144,9 +145,11 @@ export function DecisionQueuePage() {
[feed?.generatedAt],
);
// One card for each obligation (PAP-749 Rule 11): evidence folds into its lead.
const obligations = useMemo(() => foldAttentionObligations(feed?.items ?? []), [feed]);
const activeItems = useMemo(
() => (feed?.items ?? []).filter((item) => !(item.dismissal?.isActive ?? false)),
[feed],
() => obligations.cards.filter((item) => !(item.dismissal?.isActive ?? false)),
[obligations],
);
// Aging shelf (§4.4): items the server flags as idle past retention leave the
@@ -321,9 +324,10 @@ export function DecisionQueuePage() {
currentUserId={currentUserId}
expanded={expandedId === item.id}
onToggleExpand={handleToggleExpand}
onDismiss={(next) => dismiss(next.dismissalKey)}
onSnooze={(next, until) => snooze(next.dismissalKey, until)}
onDismiss={(next) => dismiss(next.dismissalKey, next.changeFingerprint)}
onSnooze={(next, until) => snooze(next.dismissalKey, until, next.changeFingerprint)}
onExcluded={invalidate}
evidence={attentionObligationEvidence(obligations, item)}
/>
))}
</div>
@@ -354,8 +358,9 @@ export function DecisionQueuePage() {
currentUserId={currentUserId}
expanded={expandedId === item.id}
onToggleExpand={handleToggleExpand}
onDismiss={(next) => dismiss(next.dismissalKey)}
onSnooze={(next, until) => snooze(next.dismissalKey, until)}
onDismiss={(next) => dismiss(next.dismissalKey, next.changeFingerprint)}
onSnooze={(next, until) => snooze(next.dismissalKey, until, next.changeFingerprint)}
evidence={attentionObligationEvidence(obligations, item)}
/>
))}
</Curtain>
@@ -431,6 +436,7 @@ function QueueItemRow({
onDismiss,
onSnooze,
onExcluded,
evidence,
}: {
item: AttentionItem;
companyId: string;
@@ -443,6 +449,7 @@ function QueueItemRow({
onDismiss: (item: AttentionItem) => void;
onSnooze: (item: AttentionItem, snoozedUntil: string) => void;
onExcluded: () => void;
evidence: readonly AttentionItem[];
}) {
const { pushToast } = useToastActions();
const [open, setOpen] = useState(false);
@@ -518,6 +525,7 @@ function QueueItemRow({
agents={agents}
showTriage
currentUserId={currentUserId}
evidence={evidence}
/>
</div>
);
+266 -140
View File
@@ -2,17 +2,25 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type React
import { useQuery } from "@tanstack/react-query";
import { useCoordinatorWorkspaceEnabled } from "../hooks/useCoordinatorWorkspaceEnabled";
import { CoordinatorNeedsYou } from "../components/coordinator/CoordinatorNeedsYou";
import { CheckCircle2, Inbox } from "lucide-react";
import type { Agent, AttentionItem, AttentionSubject } from "@paperclipai/shared";
import { CheckCircle2, History, Inbox } from "lucide-react";
import type { Agent, AttentionItem, AttentionPersonalView, AttentionSubject } from "@paperclipai/shared";
import { useNavigate, useSearchParams } from "@/lib/router";
import { accessApi } from "../api/access";
import { attentionApi } from "../api/attention";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { decisionsApi } from "../api/decisions";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { useInboxDismissals } from "../hooks/useInboxBadge";
import { useAttentionBadge, useAttentionCloseActions, useAttentionEveryoneView, useClaimAttentionItem, useDismissAttentionReceipt } from "../hooks/useAttentionViews";
import { ATTENTION_VIEW_PARAM, parseAttentionView } from "../lib/attention-views";
import { buildCompanyUserLabelMap } from "../lib/company-members";
import { AttentionReceiptList, AttentionUnassignedBanner, AttentionViewSwitch } from "../components/AttentionViews";
import { AttentionSnoozedGroup, AttentionStaleGroup } from "../components/AttentionCloseMenu";
import { DecisionHistoryPanel } from "../components/DecisionHistoryPanel";
import { Button } from "../components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "../components/ui/sheet";
import { splitArchivedAttentionItems } from "../lib/attention-close";
import { attentionObligationEvidence, foldAttentionObligations } from "../lib/attention-obligations";
import { queryKeys } from "../lib/queryKeys";
import {
ATTENTION_AGING_DAYS,
@@ -51,9 +59,6 @@ import { DecisionDateChips, type AttentionCustomRange } from "../components/Deci
import { DecisionResolver } from "../components/DecisionResolver";
import { IssueGroupHeader } from "../components/IssueGroupHeader";
/** Curtain rows never expand; module-level so memoized rows see one identity. */
const noopToggleExpand = () => {};
// Incremental rendering (PAP-13784, same pattern as IssuesList): the feed is
// uncapped, so mounting every row up front makes the page slow to paint and
// scroll. Render a bounded window and grow it as the scroll position nears the
@@ -94,6 +99,9 @@ export function WhatNeedsMe() {
function ClassicWhatNeedsMe() {
const { selectedCompanyId } = useCompany();
// The signed-in person; it keys every list so an account switch never shows
// the previous person's cards (PAP-749 plan §1).
const { userId: currentUserId } = useCoordinatorWorkspaceEnabled();
const { setBreadcrumbs } = useBreadcrumbs();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [selectedAttentionId, setSelectedAttentionId] = useState<string | null>(null);
@@ -113,9 +121,12 @@ function ClassicWhatNeedsMe() {
const [sortOrder, setSortOrder] = useState<AttentionSortOrder>(() => loadAttentionSortOrder());
const [filters, setFilters] = useState<AttentionFilterState>(() => defaultAttentionFilterState);
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => new Set());
const [snoozedOpen, setSnoozedOpen] = useState(false);
const [dismissedOpen, setDismissedOpen] = useState(false);
// The Archived view's Snoozed group starts open (PAP-749 plan §3).
const [snoozedOpen, setSnoozedOpen] = useState(true);
const [agingOpen, setAgingOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
// The Stale (N) group starts collapsed (PAP-749 plan §3).
const [staleOpen, setStaleOpen] = useState(false);
const [decidedOpen, setDecidedOpen] = useState(false);
const [expiredOpen, setExpiredOpen] = useState(false);
@@ -127,13 +138,28 @@ function ClassicWhatNeedsMe() {
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkDecisionId = searchParams.get("decisionId");
const [deepLinkConsumed, setDeepLinkConsumed] = useState(false);
// For you is the default; the other views live in the URL (PAP-749 Rule 3).
// Everyone opens for a board member only (PAP-768).
const everyone = useAttentionEveryoneView(selectedCompanyId);
const view = parseAttentionView(searchParams.get(ATTENTION_VIEW_PARAM), { everyone });
const setView = useCallback((next: AttentionPersonalView) => {
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
if (next === "mine") params.delete(ATTENTION_VIEW_PARAM);
else params.set(ATTENTION_VIEW_PARAM, next);
return params;
}, { replace: true });
}, [setSearchParams]);
// Archived: the cards you archived or snoozed for yourself (PAP-749 plan §3).
const archivedView = view === "archived";
const { viewCounts } = useAttentionBadge(selectedCompanyId);
const claim = useClaimAttentionItem(selectedCompanyId);
const claimingId = claim.isPending ? claim.variables?.id ?? null : null;
const handleClaim = useCallback((item: AttentionItem) => claim.mutate(item), [claim.mutate]);
// Optimistic hide/restore. Reset whenever a fresh feed lands (server truth).
// Optimistic hide. Reset whenever a fresh feed lands (server truth).
const [pendingHide, setPendingHide] = useState<Set<string>>(() => new Set());
const [pendingRestore, setPendingRestore] = useState<Set<string>>(() => new Set());
const { dismiss, snooze, restore } = useInboxDismissals(selectedCompanyId);
const { pushToast } = useToastActions();
const navigate = useNavigate();
// Date chips resolve to server-side activity bounds. Anchored to start-of-day,
@@ -160,16 +186,18 @@ function ClassicWhatNeedsMe() {
// `["attention", companyId]` prefix still cascades to this query.
queryKey: [
...queryKeys.attention(selectedCompanyId!),
"with-dismissed",
currentUserId,
view,
"classic",
activityBounds.activitySince ?? null,
activityBounds.activityUntil ?? null,
],
queryFn: () => attentionApi.list(selectedCompanyId!, {
includeDismissed: true,
all: true,
view,
...activityBounds,
}),
enabled: !!selectedCompanyId,
enabled: !!selectedCompanyId && !!currentUserId,
refetchOnWindowFocus: true,
});
const { data: feed, isLoading } = feedQuery;
@@ -194,11 +222,27 @@ function ClassicWhatNeedsMe() {
enabled: decisionHistoryQueryEnabled(selectedCompanyId, expiredOpen),
});
const { data: session } = useQuery({
queryKey: queryKeys.auth.session,
queryFn: () => authApi.getSession(),
const { data: userDirectory } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const userLabelMap = useMemo(() => buildCompanyUserLabelMap(userDirectory?.users), [userDirectory?.users]);
// Every Close… verb goes through the one close API (PAP-749 plan §2). The row
// hides at once; it comes back if the server refuses.
const unhide = useCallback((item: AttentionItem) => {
setPendingHide((prev) => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
}, []);
const closeActions = useAttentionCloseActions(selectedCompanyId, { userLabelMap, onCloseFailed: unhide });
const receiptActions = useDismissAttentionReceipt(selectedCompanyId);
const closeCard = closeActions.close;
const transferCard = closeActions.transfer;
const closingId = closeActions.closingItemId ?? closeActions.transferringItemId;
const agentMap = useMemo(() => {
const map = new Map<string, Agent>();
@@ -209,19 +253,16 @@ function ClassicWhatNeedsMe() {
// Reset optimistic state once the server sends a fresh snapshot.
useEffect(() => {
setPendingHide(new Set());
setPendingRestore(new Set());
}, [feed?.generatedAt]);
const allItems = useMemo(() => feed?.items ?? [], [feed]);
const isServerHidden = (item: AttentionItem) => item.dismissal != null && item.dismissal.isActive;
// One card for each obligation (PAP-749 Rule 11): evidence folds into its
// lead before any split, so a lead and its evidence always move together.
const obligations = useMemo(() => foldAttentionObligations(feed?.items ?? []), [feed]);
const allItems = obligations.cards;
const activeItems = useMemo(
() =>
allItems.filter(
(item) => (!isServerHidden(item) || pendingRestore.has(item.id)) && !pendingHide.has(item.id),
),
[allItems, pendingHide, pendingRestore],
() => allItems.filter((item) => !pendingHide.has(item.id)),
[allItems, pendingHide],
);
// The server's clock at feed time — used for the arrival/decide-by shelves and
@@ -232,26 +273,23 @@ function ClassicWhatNeedsMe() {
[feed?.generatedAt],
);
// Aging shelf (§4.4): items the server flags as idle past retention leave the
// live desk for their own curtain, so today's desk shows only fresh decisions.
const agingItems = useMemo(() => activeItems.filter(attentionIsAging), [activeItems]);
const deskItems = useMemo(() => activeItems.filter((item) => !attentionIsAging(item)), [activeItems]);
const snoozedItems = useMemo(
() =>
allItems.filter(
(item) =>
item.dismissal?.kind === "snooze" && item.dismissal.isActive && !pendingRestore.has(item.id),
),
[allItems, pendingRestore],
);
const dismissedItems = useMemo(
() =>
allItems.filter(
(item) =>
item.dismissal?.kind === "dismiss" && item.dismissal.isActive && !pendingRestore.has(item.id),
),
[allItems, pendingRestore],
);
// Stale cards (PAP-749 Rule 10) go to the collapsed Stale group at the bottom.
// Aging shelf (§4.4): other items the server flags as idle past retention
// leave the live desk for their own curtain, so the desk shows fresh decisions.
// The Archived view (plan §3) keeps stale cards in place with their chip and
// puts snoozed cards in their own group.
const { staleItems, agingItems, deskItems, snoozedItems } = useMemo(() => {
if (archivedView) {
const { archived, snoozed } = splitArchivedAttentionItems(activeItems);
return { staleItems: [], agingItems: [], deskItems: archived, snoozedItems: snoozed };
}
return {
staleItems: activeItems.filter((item) => item.stale),
agingItems: activeItems.filter((item) => !item.stale && attentionIsAging(item)),
deskItems: activeItems.filter((item) => !item.stale && !attentionIsAging(item)),
snoozedItems: [] as AttentionItem[],
};
}, [activeItems, archivedView]);
const filterOptions = useMemo(() => buildAttentionFilterOptions(deskItems), [deskItems]);
@@ -269,6 +307,11 @@ function ClassicWhatNeedsMe() {
return groupAttentionItems(sorted, groupBy);
}, [deskItems, filters, searchQuery, sortOrder, groupBy, now]);
const visibleStaleItems = useMemo(
() => sortAttentionItems(filterAttentionItems(staleItems, filters, searchQuery), sortOrder),
[staleItems, filters, searchQuery, sortOrder],
);
const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]);
const keyboardItems = useMemo(
() => groups.filter((group) => group.label === null || !collapsedGroupKeys.has(group.key)).flatMap((group) => group.items),
@@ -295,14 +338,12 @@ function ClassicWhatNeedsMe() {
collapsedGroupKeys,
snoozedItems,
snoozedOpen,
dismissedItems,
dismissedOpen,
dismissedItems: [],
dismissedOpen: false,
limit: Math.max(renderedRowLimit, selectedIndex + 1),
});
}, [
collapsedGroupKeys,
dismissedItems,
dismissedOpen,
groups,
keyboardItems,
renderedRowLimit,
@@ -365,10 +406,14 @@ function ClassicWhatNeedsMe() {
// card once the feed lands, then drop the param so a later manual collapse is
// not re-forced on the next refetch. Wins over the generic auto-expand below.
useEffect(() => {
if (deepLinkConsumed || !deepLinkDecisionId || allItems.length === 0) return;
if (deepLinkConsumed || !deepLinkDecisionId || isLoading) return;
const target = allItems.find(
(item) => item.sourceKind === "decision" && item.subject.id === deepLinkDecisionId,
);
if (!target) {
if (everyone && view !== "all") setView("all");
return;
}
setDeepLinkConsumed(true);
setAutoExpandDone(true);
if (target) {
@@ -384,7 +429,7 @@ function ClassicWhatNeedsMe() {
{ replace: true },
);
}
}, [allItems, deepLinkConsumed, deepLinkDecisionId, setSearchParams]);
}, [allItems, deepLinkConsumed, deepLinkDecisionId, setSearchParams, isLoading, everyone, view, setView]);
// Auto-expand the topmost inline-capable decision, once.
useEffect(() => {
@@ -418,51 +463,66 @@ function ClassicWhatNeedsMe() {
};
// All row callbacks are stable (deps are setState functions, stable hook
// callbacks, and the stable `pushToast`) so the memoized rows only re-render
// callbacks, and the stable mutation `mutate`s) so the memoized rows only re-render
// when their own item/expanded/selected props change (PAP-13784).
const handleUndoDismiss = useCallback(
(item: AttentionItem) => {
setPendingHide((prev) => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
restore(item.dismissalKey);
},
[restore],
);
const handleDismiss = useCallback(
(item: AttentionItem) => {
setPendingHide((prev) => new Set(prev).add(item.id));
dismiss(item.dismissalKey);
// "Archive for me": personal, with an Undo toast from the close hook.
closeCard({ item, mode: "archive" });
setExpandedId((previous) => (previous === item.id ? null : previous));
// ~8s undo window; restores the row in place via T1's DELETE endpoint.
pushToast({
id: `attention-dismiss-${item.id}`,
dedupeKey: `attention-dismiss-${item.dismissalKey}`,
title: "Dismissed",
body: item.subject.title ?? undefined,
tone: "info",
ttlMs: 8000,
action: { label: "Undo", onClick: () => handleUndoDismiss(item) },
});
},
[dismiss, handleUndoDismiss, pushToast],
[closeCard],
);
const handleSnooze = useCallback(
(item: AttentionItem, snoozedUntil: string) => {
// In the Archived view a new snooze time keeps the card; it only moves
// to (or within) the Snoozed group after the refetch.
if (!archivedView) {
setPendingHide((prev) => new Set(prev).add(item.id));
setExpandedId((previous) => (previous === item.id ? null : previous));
}
closeCard({ item, mode: "snooze", until: snoozedUntil });
},
[archivedView, closeCard],
);
const handleWithdraw = useCallback(
(item: AttentionItem, reason: string) => {
setPendingHide((prev) => new Set(prev).add(item.id));
snooze(item.dismissalKey, snoozedUntil);
closeCard({ item, mode: "withdraw", reason });
setExpandedId((previous) => (previous === item.id ? null : previous));
},
[snooze],
[closeCard],
);
const handleTransfer = useCallback(
(item: AttentionItem, userId: string) => transferCard({ item, userId }),
[transferCard],
);
const handleWithdrawAll = useCallback(
(items: AttentionItem[], reason: string) => {
setPendingHide((prev) => {
const next = new Set(prev);
for (const item of items) next.add(item.id);
return next;
});
return closeActions.withdrawAll(items, reason).then((result) => {
for (const item of result.failed) unhide(item);
return result;
});
},
[closeActions.withdrawAll, unhide],
);
// Restore deletes your dismissal: the card leaves Archived at once and is
// back in its view after the refetch. It comes back here if the server refuses.
const restoreCard = closeActions.restore;
const handleRestore = useCallback(
(item: AttentionItem) => {
setPendingRestore((prev) => new Set(prev).add(item.id));
restore(item.dismissalKey);
setPendingHide((prev) => new Set(prev).add(item.id));
void restoreCard(item).then((restored) => {
if (!restored) unhide(item);
});
},
[restore],
[restoreCard, unhide],
);
const handleToggleExpand = useCallback((item: AttentionItem) => {
setSelectedAttentionId(item.id);
@@ -535,12 +595,33 @@ function ClassicWhatNeedsMe() {
);
}
const hasAnything = activeItems.length > 0 || snoozedItems.length > 0 || dismissedItems.length > 0;
const receipts = view === "mine" ? feed?.receipts ?? [] : [];
const hasAnything = activeItems.length > 0 || receipts.length > 0;
return (
<div ref={rootRef} className="max-w-3xl space-y-4">
{/* Closed cards — answered, withdrawn, expired, closed with their task,
approvals too — with outcome filters (PAP-749 plan §3). */}
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent side="right" className="w-full gap-0 overflow-y-auto sm:max-w-md" data-classic-decision-history>
<SheetHeader>
<SheetTitle>Decision history</SheetTitle>
</SheetHeader>
<div className="p-4">
{historyOpen && <DecisionHistoryPanel companyId={selectedCompanyId} agents={agents ?? []} userLabelMap={userLabelMap} />}
</div>
</SheetContent>
</Sheet>
<div className="space-y-4">
<h1 className="text-xl font-bold">Decisions</h1>
<div className="flex items-center justify-between gap-3">
<h1 className="text-xl font-bold">Decisions</h1>
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
<History className="h-4 w-4" />
Decision history
</Button>
</div>
<AttentionViewSwitch value={view} counts={viewCounts} onChange={setView} everyone={everyone} />
{view === "mine" ? <AttentionUnassignedBanner count={viewCounts.unassigned} onOpen={() => setView("unassigned")} /> : null}
<DecisionsToolbar
query={searchQuery}
onQueryChange={setSearchQuery}
@@ -555,6 +636,9 @@ function ClassicWhatNeedsMe() {
/>
</div>
<AttentionReceiptList receipts={receipts} userLabelMap={userLabelMap}
onDismiss={receiptActions.dismiss} dismissingId={receiptActions.dismissingId} onOpen={navigate} />
{/* Queue quicklinks + date-range chips (§4.1–§4.2). The rail self-hides
when the company has no queues; the chips filter the desk server-side. */}
<div className="space-y-2">
@@ -570,11 +654,11 @@ function ClassicWhatNeedsMe() {
</div>
{!hasAnything ? (
<ZeroState />
<ZeroState view={view} />
) : (
<div className="space-y-4">
{visibleCount === 0 ? (
<CaughtUpNote filtered={deskItems.length > 0} />
<CaughtUpNote filtered={deskItems.length > 0} view={view} snoozedBelow={snoozedItems.length > 0} />
) : (
<>
{groups.map((group) => {
@@ -632,11 +716,19 @@ function ClassicWhatNeedsMe() {
onToggleExpand={handleToggleExpand}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
onWithdraw={handleWithdraw}
onTransfer={handleTransfer}
onRestore={archivedView ? handleRestore : undefined}
closing={closingId === item.id}
agentMap={agentMap}
agents={agents}
showTriage
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onClaim={handleClaim}
claiming={claimingId === item.id}
selected={selectionFromKeyboard && selectedAttentionId === item.id}
evidence={attentionObligationEvidence(obligations, item)}
/>
</div>
</Fragment>
@@ -651,53 +743,68 @@ function ClassicWhatNeedsMe() {
</>
)}
{snoozedItems.length > 0 && (
<Curtain
label="Snoozed"
count={snoozedItems.length}
open={snoozedOpen}
onToggle={() => setSnoozedOpen((prev) => !prev)}
>
{renderPlan.snoozedRows.map((item) => (
<AttentionQueueRow
key={item.id}
item={item}
companyId={selectedCompanyId}
variant="hidden"
expanded={false}
onToggleExpand={noopToggleExpand}
onDismiss={handleDismiss}
onRestore={handleRestore}
agentMap={agentMap}
currentUserId={currentUserId}
/>
))}
</Curtain>
)}
<AttentionStaleGroup
items={visibleStaleItems}
open={staleOpen}
onToggle={() => setStaleOpen((prev) => !prev)}
onWithdrawAll={handleWithdrawAll}
busy={closeActions.bulkPending}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
>
{visibleStaleItems.map((item) => (
<AttentionQueueRow
key={item.id}
item={item}
companyId={selectedCompanyId}
expanded={expandedId === item.id}
onToggleExpand={handleToggleExpand}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
onWithdraw={handleWithdraw}
onTransfer={handleTransfer}
closing={closingId === item.id}
agentMap={agentMap}
agents={agents}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onClaim={handleClaim}
claiming={claimingId === item.id}
evidence={attentionObligationEvidence(obligations, item)}
/>
))}
</AttentionStaleGroup>
{dismissedItems.length > 0 && (
<Curtain
label="Dismissed"
count={dismissedItems.length}
open={dismissedOpen}
onToggle={() => setDismissedOpen((prev) => !prev)}
>
{renderPlan.dismissedRows.map((item) => (
<AttentionQueueRow
key={item.id}
item={item}
companyId={selectedCompanyId}
variant="hidden"
expanded={false}
onToggleExpand={noopToggleExpand}
onDismiss={handleDismiss}
onRestore={handleRestore}
agentMap={agentMap}
currentUserId={currentUserId}
/>
))}
</Curtain>
)}
{/* Snoozed by you, in the Archived view: each card says when it
comes back; Restore now or change the time (PAP-749 plan §3). */}
<AttentionSnoozedGroup
count={snoozedItems.length}
open={snoozedOpen}
onToggle={() => setSnoozedOpen((prev) => !prev)}
>
{renderPlan.snoozedRows.map((item) => (
<AttentionQueueRow
key={item.id}
item={item}
companyId={selectedCompanyId}
expanded={expandedId === item.id}
onToggleExpand={handleToggleExpand}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
onWithdraw={handleWithdraw}
onTransfer={handleTransfer}
onRestore={handleRestore}
closing={closingId === item.id}
agentMap={agentMap}
agents={agents}
currentUserId={currentUserId}
userLabelMap={userLabelMap}
onClaim={handleClaim}
claiming={claimingId === item.id}
evidence={attentionObligationEvidence(obligations, item)}
/>
))}
</AttentionSnoozedGroup>
{agingItems.length > 0 && (
<Curtain
@@ -722,6 +829,7 @@ function ClassicWhatNeedsMe() {
onToggleExpand={handleToggleExpand}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
evidence={attentionObligationEvidence(obligations, item)}
/>
))}
</Curtain>
@@ -821,11 +929,21 @@ export function DecisionBundleHeader({
);
}
function CaughtUpNote({ filtered }: { filtered: boolean }) {
const CAUGHT_UP_TEXT: Record<AttentionPersonalView, string> = {
mine: "You're all caught up.",
unassigned: "No cards need an owner.",
company: "No company alerts right now.",
archived: "Nothing archived.",
all: "No open cards for anyone.",
};
function CaughtUpNote({ filtered, view, snoozedBelow = false }: { filtered: boolean; view: AttentionPersonalView; snoozedBelow?: boolean }) {
return (
<div className="rounded-xl border border-dashed border-border py-10 text-center">
<p className="text-sm font-medium text-foreground">
{filtered ? "No decisions match your search or filters." : "You're all caught up."}
{filtered
? "No decisions match your search or filters."
: snoozedBelow ? "No archived cards. Snoozed cards are below." : CAUGHT_UP_TEXT[view]}
</p>
{filtered && (
<p className="mt-1 text-xs text-muted-foreground">Adjust or clear your search and filters to see the rest.</p>
@@ -834,16 +952,24 @@ function CaughtUpNote({ filtered }: { filtered: boolean }) {
);
}
function ZeroState() {
const ZERO_STATE_DETAIL: Record<AttentionPersonalView, string> = {
mine: "Nothing needs a decision from you right now.",
unassigned: "Every open card has an owner.",
company: "No failed runs, agent errors, budget stops or join requests.",
archived: "Cards you archive or snooze for yourself show here. Restore one to see it again.",
all: "Nobody has a card waiting right now.",
};
function ZeroState({ view }: { view: AttentionPersonalView }) {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
<div className="mb-4 rounded-full bg-green-500/10 p-4">
<CheckCircle2 className="h-10 w-10 text-green-500" />
</div>
<p className="text-lg font-semibold text-foreground">You're all caught up</p>
<p className="text-lg font-semibold text-foreground">{view === "mine" ? "You're all caught up" : CAUGHT_UP_TEXT[view]}</p>
<p className="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
<Inbox className="h-4 w-4" />
Nothing needs a decision from you right now.
{ZERO_STATE_DETAIL[view]}
</p>
</div>
);
+267
View File
@@ -0,0 +1,267 @@
// @vitest-environment jsdom
import { act } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AttentionItem, AttentionReceipt } from "@paperclipai/shared";
import { WhatNeedsMe } from "./WhatNeedsMe";
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// PAP-749 tasks 1b and 2e on the classic Decisions page: the view switch, per
// person and view query keys, the Unassigned banner, Claim, and the Archived
// view with Restore and its Snoozed group.
const fixture = vi.hoisted(() => ({
userId: "scott",
byView: {} as Record<string, AttentionItem[]>,
calls: [] as Array<Record<string, unknown>>,
claim: vi.fn(async () => ({})),
close: vi.fn(async (..._args: unknown[]) => ({ until: null })),
restore: vi.fn(async (..._args: unknown[]) => ({})),
boardAccess: null as null | Record<string, unknown>,
receipts: [] as AttentionReceipt[],
}));
vi.mock("../hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: false, userId: fixture.userId }) }));
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company" }) }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) }));
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }), useOptionalToastActions: () => ({ pushToast: vi.fn() }) }));
vi.mock("../hooks/useInboxBadge", () => ({ useInboxDismissals: () => ({ dismiss: vi.fn(), snooze: vi.fn(), restore: vi.fn() }) }));
vi.mock("../api/agents", () => ({ agentsApi: { list: async () => [] } }));
vi.mock("../api/decisions", () => ({ decisionsApi: { list: async () => [] } }));
vi.mock("../api/access", () => ({ accessApi: {
listUserDirectory: async () => ({ users: [] }),
getCurrentBoardAccess: async () => { if (!fixture.boardAccess) throw new Error("No board access"); return fixture.boardAccess; },
} }));
vi.mock("../api/inboxDismissals", () => ({ inboxDismissalsApi: { restore: fixture.restore, dismiss: vi.fn() } }));
vi.mock("../components/DecisionHistoryPanel", () => ({ DecisionHistoryPanel: () => <div data-history-panel>History</div> }));
vi.mock("../api/attention", () => ({ attentionApi: {
claim: fixture.claim,
close: fixture.close,
list: async (_companyId: string, options: Record<string, unknown>) => {
fixture.calls.push(options);
const items = fixture.byView[String(options.view)] ?? [];
return { companyId: "company", generatedAt: "2026-10-30T12:00:00Z", totalCount: items.length, deskBadgeCount: 2,
viewCounts: { mine: 2, unassigned: 1, company: 0, archived: 3, all: 3 }, nextCursor: null, countsBySourceKind: {}, items,
receipts: options.view === "mine" ? fixture.receipts : [] };
},
} }));
vi.mock("../components/DecisionsToolbar", () => ({ DecisionsToolbar: () => null }));
vi.mock("../components/DecisionQueueRail", () => ({ DecisionQueueRail: () => null }));
vi.mock("../components/DecisionDateChips", () => ({ DecisionDateChips: () => null }));
vi.mock("../components/DecisionResolver", () => ({ DecisionResolver: () => null }));
vi.mock("../components/AttentionQueueRow", () => ({
AttentionQueueRow: ({ item, onClaim, onDismiss, onRestore, onSnooze, currentUserId }: { item: AttentionItem; onClaim?: (item: AttentionItem) => void; onDismiss?: (item: AttentionItem) => void; onRestore?: (item: AttentionItem) => void; onSnooze?: (item: AttentionItem, until: string) => void; currentUserId?: string | null }) => (
<div data-row={item.id} data-viewer={currentUserId ?? ""}>
{item.subject.title}
{onClaim ? <button type="button" onClick={() => onClaim(item)}>Claim {item.id}</button> : null}
{onDismiss && !onRestore ? <button type="button" onClick={() => onDismiss(item)}>Archive {item.id}</button> : null}
{onRestore ? <button type="button" onClick={() => onRestore(item)}>Restore {item.id}</button> : null}
{onRestore && onSnooze ? <button type="button" onClick={() => onSnooze(item, "2026-11-02T09:00:00.000Z")}>Change time {item.id}</button> : null}
</div>
),
}));
function card(id: string, audience: AttentionItem["audience"]) {
const at = new Date().toISOString();
return { id, sourceKind: "issue_thread_interaction", subject: { id: `${id}-interaction`, kind: "interaction", title: `Card ${id}`, metadata: {} },
audience, dismissal: null, dismissalKey: `attention:${id}`, changeFingerprint: `${id}.me`, closeModes: ["archive", "snooze"], obligationKey: null, obligationLeadId: null, activityAt: at, createdAt: at, updatedAt: at, inlineResolvable: false, decisionVerbs: [], queues: [], shelf: false,
severity: "medium", relatedIssue: null, project: null } as unknown as AttentionItem;
}
function Location() { return <output data-location>{useLocation().search}</output>; }
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
beforeEach(() => {
fixture.userId = "scott";
fixture.calls = [];
fixture.boardAccess = null;
fixture.receipts = [];
fixture.claim.mockClear();
fixture.close.mockClear();
fixture.restore.mockClear();
fixture.byView = {
mine: [card("mine-1", { kind: "user", userId: "scott", reason: "addressee" })],
unassigned: [card("pool-1", { kind: "pool", userId: null, reason: "none" })],
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
client.clear();
});
async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); }
async function render(url = "/decisions") {
await act(async () => root.render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[url]}><WhatNeedsMe /><Location /></MemoryRouter></QueryClientProvider>));
await flush(); await flush();
}
describe("classic Decisions views", () => {
it("finds a linked decision outside Mine before consuming its URL", async () => {
fixture.boardAccess = { userId: "scott", source: "session", memberships: [{ companyId: "company", status: "active", membershipRole: "admin" }] };
fixture.byView.all = [{ ...card("decision-target", { kind: "pool", userId: null, reason: "none" }), sourceKind: "decision", subject: { id: "target", kind: "decision", title: "Linked decision" } } as AttentionItem];
await render("/decisions?decisionId=target");
for (let tick = 0; tick < 5; tick++) await flush();
expect(fixture.calls).toContainEqual(expect.objectContaining({ view: "all" }));
expect(container.querySelector("[data-location]")?.textContent).not.toContain("decisionId");
});
it("shows personal receipts even when there are no pending cards", async () => {
fixture.byView = { mine: [] };
fixture.receipts = [{
id: "receipt-1", dismissalKey: "attention:receipt:receipt-1", outcome: "withdrawn",
sourceKind: "issue_thread_interaction", sourceId: "interaction-1", title: "Review the plan",
reason: "Plan changed", actorUserId: "dotta", transferredToUserId: null,
issue: null, href: null, createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
}];
await render();
expect(container.querySelector('[data-attention-receipt="withdrawn"]')?.textContent).toContain("Plan changed");
await act(async () => {
const company = container.querySelector<HTMLButtonElement>('[data-attention-view="company"]')!;
company.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
company.focus();
});
await flush();
expect(container.querySelector("[data-attention-receipts]")).toBeNull();
});
it("opens For you by default with the shared badge count and the Unassigned banner", async () => {
await render();
expect(fixture.calls).toContainEqual(expect.objectContaining({ all: true, view: "mine" }));
// The views never ask for hidden cards; those live in the Archived view (PAP-749 plan §3).
expect(fixture.calls.some((call) => "includeDismissed" in call)).toBe(false);
expect(fixture.calls.every((call) => call.view === "mine")).toBe(true);
const tabs = [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent);
expect(tabs).toEqual(["For you2", "Unassigned1", "Company0", "Archived3"]);
expect(container.querySelector('[data-row="mine-1"]')?.getAttribute("data-viewer")).toBe("scott");
expect(container.querySelector("[data-attention-unassigned-banner]")?.textContent).toContain("1 card needs an owner");
expect(client.getQueryCache().find({ queryKey: ["attention", "company", "scott", "mine", "classic"], exact: false })).toBeTruthy();
});
it("opens Everyone for a board member with its own count (PAP-768)", async () => {
fixture.boardAccess = { userId: "scott", source: "session", isInstanceAdmin: false, companyIds: ["company"],
memberships: [{ companyId: "company", membershipRole: "owner", status: "active" }] };
fixture.byView.all = [card("mine-1", { kind: "user", userId: "scott", reason: "addressee" }), card("dotta-1", { kind: "user", userId: "dotta", reason: "instigator" })];
await render("/decisions?view=all");
await vi.waitFor(() => expect(container.querySelector('[data-attention-view="all"]')?.getAttribute("aria-selected")).toBe("true"));
const tabs = [...container.querySelectorAll('[role="tab"]')].map((tab) => tab.textContent);
expect(tabs).toEqual(["For you2", "Unassigned1", "Company0", "Archived3", "Everyone3"]);
expect(fixture.calls).toContainEqual(expect.objectContaining({ all: true, view: "all" }));
expect([...container.querySelectorAll("[data-row]")].map((row) => row.getAttribute("data-row"))).toEqual(["mine-1", "dotta-1"]);
expect(container.querySelector("[data-attention-unassigned-banner]")).toBeNull();
});
it("hides Everyone when the board access is unknown (PAP-768)", async () => {
await render("/decisions?view=all");
expect(container.querySelector('[data-attention-view="all"]')).toBeNull();
expect(container.querySelector('[data-attention-view="mine"]')?.getAttribute("aria-selected")).toBe("true");
expect(fixture.calls.some((call) => call.view === "all")).toBe(false);
});
it("switches to Unassigned from the banner and claims a card", async () => {
await render();
const open = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Open Unassigned"))!;
await act(async () => open.click());
await flush(); await flush();
expect(container.querySelector("[data-location]")?.textContent).toBe("?view=unassigned");
expect(fixture.calls).toContainEqual(expect.objectContaining({ view: "unassigned" }));
expect(container.querySelector("[data-attention-unassigned-banner]")).toBeNull();
const claim = [...container.querySelectorAll("button")].find((button) => button.textContent === "Claim pool-1")!;
await act(async () => claim.click());
await flush();
expect(fixture.claim).toHaveBeenCalledWith("company", "issue_thread_interaction", "pool-1-interaction");
});
it("keeps each person's list under their own key", async () => {
await render();
fixture.userId = "dotta";
await render();
expect(client.getQueryCache().find({ queryKey: ["attention", "company", "dotta", "mine", "classic"], exact: false })).toBeTruthy();
expect(client.getQueryCache().find({ queryKey: ["attention", "company", "scott", "mine", "classic"], exact: false })).toBeTruthy();
});
it("moves stale cards to a collapsed Stale group and withdraws them after a confirmation (PAP-749 task 2c)", async () => {
const stale = (id: string) => ({ ...card(id, { kind: "user", userId: "scott", reason: "addressee" }), stale: { reason: "aged" }, closeModes: ["withdraw", "archive", "snooze"] }) as unknown as AttentionItem;
fixture.byView = { mine: [card("fresh", { kind: "user", userId: "scott", reason: "addressee" }), stale("old-1"), stale("old-2")] };
await render();
const group = container.querySelector<HTMLElement>("[data-attention-stale-group]")!;
expect(group.textContent).toContain("Stale (2)");
expect(container.querySelector('[data-row="fresh"]')).not.toBeNull();
expect(container.querySelector('[data-row="old-1"]')).toBeNull();
await act(async () => group.querySelector<HTMLElement>("[aria-expanded]")!.click());
expect(group.querySelector('[data-row="old-1"]')).not.toBeNull();
const withdrawAll = [...group.querySelectorAll("button")].find((button) => button.textContent?.includes("Withdraw all stale"))!;
await act(async () => withdrawAll.click());
const confirm = [...document.querySelectorAll<HTMLButtonElement>("[data-attention-stale-withdraw-dialog] button")].find((button) => button.textContent === "Withdraw 2 requests")!;
await act(async () => confirm.click());
await flush();
expect(fixture.close.mock.calls.map((call) => [call[2], (call[3] as { mode: string }).mode])).toEqual([["old-1-interaction", "withdraw"], ["old-2-interaction", "withdraw"]]);
});
it("archives for me through the one close API (PAP-749 task 2c)", async () => {
fixture.byView = { mine: [{ ...card("mine-1", { kind: "user", userId: "scott", reason: "addressee" }), changeFingerprint: "fp.me", closeModes: ["archive", "snooze"] } as unknown as AttentionItem] };
await render();
const archive = [...container.querySelectorAll("button")].find((button) => button.textContent === "Archive mine-1")!;
await act(async () => archive.click());
await flush();
expect(fixture.close).toHaveBeenCalledWith("company", "issue_thread_interaction", "mine-1-interaction", { mode: "archive", reason: undefined, until: undefined, fingerprint: "fp.me" });
expect(container.querySelector('[data-row="mine-1"]')).toBeNull();
});
it("lists your archived cards, then a Snoozed group, with Restore and Change time (PAP-749 task 2e)", async () => {
const hidden = (id: string, kind: "dismiss" | "snooze", snoozedUntil: string | null = null) => ({
...card(id, { kind: "user", userId: "scott", reason: "addressee" }),
dismissal: { kind, dismissedAt: "2026-10-29T12:00:00Z", snoozedUntil, isActive: true },
}) as unknown as AttentionItem;
fixture.byView = { archived: [hidden("arch-1", "dismiss"), hidden("snz-1", "snooze", "2026-10-31T09:00:00Z")] };
await render("/decisions?view=archived");
expect(fixture.calls).toContainEqual(expect.objectContaining({ view: "archived", all: true }));
expect(container.querySelector('[role="tab"][aria-selected="true"]')?.textContent).toBe("Archived3");
// The badge stays the For you count; the Archived count is only on its tab.
expect(container.querySelector('[data-attention-view-count="mine"]')?.textContent).toBe("2");
const snoozedGroup = container.querySelector<HTMLElement>("[data-attention-snoozed-group]")!;
expect(snoozedGroup.textContent).toContain("Snoozed (1)");
expect(snoozedGroup.querySelector('[data-row="snz-1"]')).not.toBeNull();
expect(snoozedGroup.querySelector('[data-row="arch-1"]')).toBeNull();
expect(container.querySelector('[data-row="arch-1"]')).not.toBeNull();
// No Archive verb here: the card is already archived.
expect([...container.querySelectorAll("button")].some((button) => button.textContent === "Archive arch-1")).toBe(false);
const changeTime = [...container.querySelectorAll("button")].find((button) => button.textContent === "Change time snz-1")!;
await act(async () => changeTime.click());
await flush();
expect(fixture.close).toHaveBeenCalledWith("company", "issue_thread_interaction", "snz-1-interaction", expect.objectContaining({ mode: "snooze", until: "2026-11-02T09:00:00.000Z" }));
// A new time keeps the card in the Archived view.
expect(container.querySelector('[data-row="snz-1"]')).not.toBeNull();
const restore = [...container.querySelectorAll("button")].find((button) => button.textContent === "Restore arch-1")!;
await act(async () => restore.click());
await flush();
expect(fixture.restore).toHaveBeenCalledWith("company", "attention:arch-1");
expect(container.querySelector('[data-row="arch-1"]')).toBeNull();
});
it("shows the Archived empty state and opens Decision history from the header", async () => {
await render("/decisions?view=archived");
expect(container.textContent).toContain("Nothing archived.");
expect(document.querySelector("[data-history-panel]")).toBeNull();
const history = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Decision history"))!;
await act(async () => history.click());
await flush();
expect(document.querySelector("[data-classic-decision-history] [data-history-panel]")).not.toBeNull();
});
it("shows a Company empty state from the URL", async () => {
await render("/decisions?view=company");
expect(fixture.calls).toContainEqual(expect.objectContaining({ view: "company" }));
expect(container.textContent).toContain("No company alerts right now.");
});
});
@@ -37,7 +37,7 @@ const meta = {
} },
{ path: /\/api\/issues\/[^/]+$/, method: "GET", read: ({ url }) => initialItems.find(item => item.issue.id === url.pathname.split("/").at(-1))?.issue },
{ path: /\/api\/companies\/[^/]+\/attention$/, method: "GET", read: () => ({
companyId: "company-storybook", generatedAt: "2026-10-27T12:00:00Z", totalCount: 2, deskBadgeCount: 2, nextCursor: null, countsBySourceKind: { issue_thread_interaction: 2 },
companyId: "company-storybook", generatedAt: "2026-10-27T12:00:00Z", totalCount: 2, deskBadgeCount: 2, viewCounts: { mine: 2, unassigned: 0, company: 0 }, nextCursor: null, countsBySourceKind: { issue_thread_interaction: 2 },
items: initialItems.slice(1, 3).map(({ issue }, index) => ({ id: `fixture-decision-${index}`, sourceKind: "issue_thread_interaction", subject: { kind: "interaction", id: `fixture-question-${index}` }, relatedIssue: { id: issue.id, title: issue.title, identifier: issue.identifier } })),
}) },
{ path: /\/api\/companies\/[^/]+\/chats$/, method: "GET", read: () => [] },
@@ -370,12 +370,12 @@ function PrimeDeskFixtures({
queryClient.setQueryData(queryKeys.agents.list(companyId), AGENTS);
queryClient.setQueryData(queryKeys.auth.session, storybookAuthSession);
if (items) {
// Keep this fixture aligned with the current page query.
// Keyed by person and view, like the current page query.
queryClient.setQueryData(
[...queryKeys.attention(companyId), "with-dismissed", null, null],
[...queryKeys.attention(companyId), STORYBOOK_USER_ID, "mine", "with-dismissed", null, null],
feed(items),
);
queryClient.setQueryData(coordinatorAttentionQueryKey(companyId), feed(items));
queryClient.setQueryData(coordinatorAttentionQueryKey(companyId, STORYBOOK_USER_ID, "mine"), feed(items));
}
if (queueItems) {
// The queue feed key carries the resolved activity bounds (null/null for
@@ -0,0 +1,430 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import type {
AttentionAudience,
AttentionItem,
AttentionPersonalView,
AttentionSourceKind,
AttentionViewCounts,
} from "@paperclipai/shared";
import { AttentionSnoozedGroup, AttentionStaleGroup } from "@/components/AttentionCloseMenu";
import { AttentionQueueRow } from "@/components/AttentionQueueRow";
import { AttentionUnassignedBanner, AttentionViewSwitch } from "@/components/AttentionViews";
import { splitArchivedAttentionItems, splitStaleAttentionItems } from "@/lib/attention-close";
import { attentionObligationEvidence, foldAttentionObligations } from "@/lib/attention-obligations";
// PAP-749 tasks 1b, 2c, 2e and 2d, and PAP-768's Everyone tab: the view switch, the "Why me?" line, the
// "For @name" chip, Claim and the Unassigned banner, plus the Close… menu, the
// Stale chip and the collapsed Stale (N) group with "Withdraw all stale", the
// Archived view with Restore and its Snoozed group, and one card for each
// obligation, as the classic Decisions page and Coordinator Needs You render
// them. Scott is the viewer.
const companyId = "company-storybook";
const SCOTT = "user-scott";
const DOTTA = "user-dotta";
const RILEY = "user-riley";
const USER_LABELS: ReadonlyMap<string, string> = new Map([[SCOTT, "Scott"], [DOTTA, "Dotta"], [RILEY, "Riley"]]);
const NOW = Date.parse("2026-10-30T12:00:00Z");
const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;
const WITHDRAWABLE = { closeModes: ["withdraw", "archive", "snooze"] } satisfies Partial<AttentionItem>;
const noop = () => {};
function card(
id: string,
sourceKind: AttentionSourceKind,
title: string,
detail: string,
audience: AttentionAudience,
overrides: Partial<AttentionItem> = {},
): AttentionItem {
const at = new Date(NOW - HOUR).toISOString();
return {
id,
companyId,
sourceKind,
subject: {
kind: sourceKind === "issue_thread_interaction" ? "interaction" : "issue",
id: `${id}-subject`,
companyId,
title,
identifier: null,
status: "pending",
href: "/PAP/issues/PAP-1000",
metadata: {},
},
whyNow: detail,
decisionVerbs: [],
inlineResolvable: false,
entryRule: "",
exitRule: "",
dedupKey: `${id}-dedup`,
dismissalKey: `attention:${id}-dedup`,
dismissal: null,
severity: "medium",
rank: 0,
activityAt: at,
createdAt: at,
updatedAt: at,
relatedIssue: {
kind: "issue",
id: "issue-1000",
companyId,
title: "Ship the Needs You views",
identifier: "PAP-1000",
status: "in_progress",
href: "/PAP/issues/PAP-1000",
metadata: {},
},
audience,
stale: null,
required: false,
changeFingerprint: "fixture.me", closeModes: ["archive", "snooze"], obligationKey: null, obligationLeadId: null,
project: null,
workspace: null,
expiresAt: null,
ruleKey: null,
originAgentName: null,
queues: [],
shelf: false,
retentionDays: 30,
keep: false,
archivedAt: null,
retentionVersion: 1,
decideBy: null,
decideByAttribution: null,
snoozedUntil: null,
detail: null,
trainingExampleId: null,
...overrides,
};
}
const forScott = (reason: AttentionAudience["reason"]): AttentionAudience => ({ kind: "user", userId: SCOTT, reason });
const POOL: AttentionAudience = { kind: "pool", userId: null, reason: "none" };
const COMPANY: AttentionAudience = { kind: "company", userId: null, reason: "none" };
const daysAgo = (days: number) => new Date(NOW - days * DAY).toISOString();
const STALE: AttentionItem[] = [
card("stale-31", "issue_thread_interaction", "Confirm the Q3 roadmap order", "No important change for 31 days.", forScott("addressee"),
{ ...WITHDRAWABLE, stale: { reason: "aged" }, activityAt: daysAgo(31), createdAt: daysAgo(31) }),
card("stale-done", "issue_thread_interaction", "Which screenshot goes in the release note?", "Its task is done.", forScott("instigator"),
{ ...WITHDRAWABLE, stale: { reason: "issue_closed" }, activityAt: daysAgo(4) }),
card("stale-decision", "decision", "Pick the archive window", "85% of its time limit is gone.", forScott("escalation_owner"),
{ ...WITHDRAWABLE, stale: { reason: "decision_expiring" }, activityAt: daysAgo(6) }),
card("stale-review", "review", "Review the old onboarding copy", "Nobody touched it for 60 days.", forScott("reviewer"),
{ stale: { reason: "aged" }, activityAt: daysAgo(60), createdAt: daysAgo(60) }),
];
const FOR_YOU: AttentionItem[] = [
card("q-scott", "issue_thread_interaction", "Which rollout order should we use?", "An agent asked you two questions.", forScott("addressee"), WITHDRAWABLE),
card("review-scott", "review", "Review the pricing page copy", "Waiting on your review.", forScott("reviewer")),
card("confirm-scott", "issue_thread_interaction", "Approve the migration plan", "A child task of your task needs a confirmation.", forScott("instigator"), WITHDRAWABLE),
card("approval-scott", "approval", "Hire agent: Research Analyst", "You asked for this hire.", forScott("requester")),
card("owner-scott", "decision", "Pick a vendor for transcripts", "No other owner; you are the company's default decision owner.", forScott("escalation_owner"), WITHDRAWABLE),
...STALE,
];
const UNASSIGNED: AttentionItem[] = [
card("pool-routine", "issue_thread_interaction", "Confirm the weekly digest recipients", "A routine-created task asks for a confirmation.", POOL, WITHDRAWABLE),
card("pool-agent", "issue_thread_interaction", "Answer a question for a paused agent", "The agent it was sent to cannot run.", POOL, WITHDRAWABLE),
card("pool-decision", "decision", "Choose a retry budget", "An agent-created task needs a decision.", POOL, WITHDRAWABLE),
];
const COMPANY_CARDS: AttentionItem[] = [
card("failed-run", "failed_run", "Nightly sync failed after all retries", "The last of three retries failed.", COMPANY, {
severity: "high",
inlineResolvable: true,
decisionVerbs: [{ id: "retry", label: "Retry", description: null }, { id: "inspect", label: "Open run", description: null }],
}),
card("budget", "budget_alert", "Budget hard stop: Research team", "Spend reached 100% of the monthly limit.", COMPANY, {
severity: "critical",
inlineResolvable: true,
subject: {
kind: "budget_incident", id: "budget-incident", companyId, title: "Budget hard stop: Research team", identifier: null, status: "open", href: "/PAP/costs",
metadata: { thresholdType: "hard", amountObserved: 50_000, amountLimit: 50_000 },
},
}),
card("stale-pool", "issue_thread_interaction", "Confirm the release checklist", "No owner for 7+ days.", COMPANY,
{ ...WITHDRAWABLE, stale: { reason: "unowned" }, activityAt: daysAgo(9) }),
];
const FOR_OTHERS: AttentionItem[] = [
card("q-dotta", "issue_thread_interaction", "Which launch date works for marketing?", "Sent to Dotta.", { kind: "user", userId: DOTTA, reason: "addressee" },
{ ...WITHDRAWABLE, inlineResolvable: true, subject: { kind: "interaction", id: "q-dotta-subject", companyId, title: "Which launch date works for marketing?", identifier: null, status: "pending", href: "/PAP/issues/PAP-1000", metadata: { issueId: "issue-1000" } } }),
card("review-dotta", "review", "Review the onboarding email", "Dotta is the reviewer.", { kind: "user", userId: DOTTA, reason: "reviewer" }),
card("stale-dotta", "issue_thread_interaction", "Confirm the old pricing test", "Dotta's request, 45 days without a change.", { kind: "user", userId: DOTTA, reason: "addressee" },
{ ...WITHDRAWABLE, stale: { reason: "aged" }, activityAt: daysAgo(45) }),
];
function hiddenBy(kind: "dismiss" | "snooze", dismissedAt: number, snoozedUntil: number | null = null): Partial<AttentionItem> {
return {
dismissal: {
kind,
dismissedAt: new Date(dismissedAt).toISOString(),
snoozedUntil: snoozedUntil === null ? null : new Date(snoozedUntil).toISOString(),
isActive: true,
},
};
}
// Scott's own hidden cards: two archived (one also stale) and one snoozed until tomorrow.
const ARCHIVED: AttentionItem[] = [
card("archived-copy", "issue_thread_interaction", "Approve the launch blog copy", "Archived yesterday; still open for Dotta.", { kind: "user", userId: SCOTT, reason: "addressee" },
{ ...WITHDRAWABLE, ...hiddenBy("dismiss", NOW - DAY) }),
card("archived-stale", "issue_thread_interaction", "Confirm the old pricing test", "45 days without a change.", { kind: "user", userId: SCOTT, reason: "instigator" },
{ ...WITHDRAWABLE, ...hiddenBy("dismiss", NOW - 3 * DAY), stale: { reason: "aged" }, activityAt: daysAgo(45) }),
card("snoozed-review", "issue_thread_interaction", "Pick the onboarding email subject", "Snoozed until tomorrow morning.", { kind: "user", userId: SCOTT, reason: "addressee" },
{ ...WITHDRAWABLE, ...hiddenBy("snooze", NOW - 2 * HOUR, NOW + 21 * HOUR) }),
];
// One card for each obligation (Rule 11): an approval with the in-review task
// that waits on it, and a failed run with the task it blocks. The feed returns
// all four cards; the page shows two.
const OBLIGATIONS: AttentionItem[] = [
card("ob-approval", "approval", "Approve the launch budget", "You asked for this approval.", forScott("requester"), {
...WITHDRAWABLE,
obligationKey: "approval:launch", obligationLeadId: "ob-approval", relatedIssue: null,
subject: { kind: "approval", id: "approval-launch", companyId, title: "Approve the launch budget", identifier: null, status: "pending", href: "/PAP/approvals/approval-launch", metadata: { issueId: "issue-1001" } },
}),
card("ob-review", "review", "Launch page", "Issue is in review with a linked pending approval.", forScott("requester"), {
obligationKey: "approval:launch", obligationLeadId: "ob-approval", relatedIssue: null,
subject: { kind: "issue", id: "issue-1001", companyId, title: "Launch page", identifier: "PAP-1001", status: "in_review", href: "/PAP/issues/PAP-1001", metadata: {} },
}),
card("ob-run", "failed_run", "Flaky Runner run failed", "Run failed after automatic retries were exhausted.", forScott("instigator"), {
severity: "high",
inlineResolvable: true,
decisionVerbs: [{ id: "retry", label: "Retry", description: null }, { id: "inspect", label: "Open run", description: null }],
obligationKey: "blocked:nightly", obligationLeadId: "ob-run",
subject: { kind: "run", id: "run-1002", companyId, title: "Flaky Runner run failed", identifier: null, status: "failed", href: "/PAP/agents/flaky/runs/run-1002", metadata: { issueId: "issue-1002" } },
relatedIssue: { kind: "issue", id: "issue-1002", companyId, title: "Nightly sync", identifier: "PAP-1002", status: "blocked", href: "/PAP/issues/PAP-1002", metadata: {} },
}),
card("ob-blocked", "blocker_attention", "Nightly sync", "Automatic retries exhausted. Retry or reassign the task.", forScott("instigator"), {
severity: "high",
obligationKey: "blocked:nightly", obligationLeadId: "ob-run", relatedIssue: null,
subject: { kind: "issue", id: "issue-1002", companyId, title: "Nightly sync", identifier: "PAP-1002", status: "blocked", href: "/PAP/issues/PAP-1002", metadata: {} },
}),
];
// Escalation owners (PAP-749 task 1d): the budget owner's hard stop, which
// Company still shows to everyone, and a card that reached Scott as the
// project's decision owner. The stop's override approval is folded into it
// (PAP-768): Raise limit approves it and Keep paused rejects it.
const BUDGET_OWNER = { kind: "user", userId: SCOTT, reason: "budget_owner", alsoInCompany: true } satisfies AttentionAudience;
const BUDGET_OWNER_STOP = card("budget-owned", "budget_alert", "Research team budget hard stop", "Budget hard stop was reached.",
BUDGET_OWNER, {
severity: "high",
inlineResolvable: true,
relatedIssue: null,
obligationKey: "approval:budget-override", obligationLeadId: "budget-owned",
decisionVerbs: [{ id: "raise_budget_and_resume", label: "Raise limit", description: null }, { id: "keep_paused", label: "Keep paused", description: null }],
subject: {
kind: "budget_incident", id: "budget-incident-owned", companyId, title: "Research team budget hard stop", identifier: null, status: "open", href: "/PAP/costs",
metadata: { thresholdType: "hard", amountObserved: 50_000, amountLimit: 50_000 },
},
});
const BUDGET_OVERRIDE_APPROVAL = card("budget-override", "approval", "budget override required", "Approval is pending a board decision.", BUDGET_OWNER, {
relatedIssue: null,
obligationKey: "approval:budget-override", obligationLeadId: "budget-owned",
subject: {
kind: "approval", id: "budget-override", companyId, title: "budget override required", identifier: null, status: "pending", href: "/PAP/approvals/budget-override",
metadata: { type: "budget_override_required" },
},
});
const ESCALATION_OWNER_CARDS: AttentionItem[] = [
BUDGET_OWNER_STOP,
BUDGET_OVERRIDE_APPROVAL,
card("project-owned", "issue_thread_interaction", "Confirm the Platform migration plan", "No person on the task; you own the Platform project's decisions.", forScott("project_owner"), WITHDRAWABLE),
];
// Everyone (PAP-768): every person's open cards, for a board member.
const EVERYONE: AttentionItem[] = [...FOR_YOU, ...FOR_OTHERS, ...UNASSIGNED, ...COMPANY_CARDS];
const COUNTS: AttentionViewCounts = {
mine: FOR_YOU.length, unassigned: UNASSIGNED.length, company: COMPANY_CARDS.length, archived: ARCHIVED.length, all: EVERYONE.length,
};
const ITEMS: Record<AttentionPersonalView, AttentionItem[]> = { mine: FOR_YOU, unassigned: UNASSIGNED, company: COMPANY_CARDS, archived: ARCHIVED, all: EVERYONE };
function NeedsYouViews({
initialView = "mine",
items = ITEMS,
counts = COUNTS,
staleOpen: initialStaleOpen = false,
expandedId: initialExpandedId = null,
viewerId = SCOTT,
everyone = true,
}: {
initialView?: AttentionPersonalView;
items?: Record<AttentionPersonalView, AttentionItem[]>;
counts?: AttentionViewCounts;
staleOpen?: boolean;
expandedId?: string | null;
viewerId?: string;
/** The viewer is a board member, so the switch shows Everyone. */
everyone?: boolean;
}) {
const [view, setView] = useState<AttentionPersonalView>(initialView);
// Claim, Close… verbs and Withdraw all stale only hide rows locally here.
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const [staleOpen, setStaleOpen] = useState(initialStaleOpen);
const [expandedId, setExpandedId] = useState<string | null>(initialExpandedId);
const [snoozedOpen, setSnoozedOpen] = useState(true);
const hide = (entry: AttentionItem) => setHidden((current) => new Set(current).add(entry.id));
// Evidence folds into its lead before anything is hidden or split (Rule 11).
const obligations = foldAttentionObligations(items[view]);
const visible = obligations.cards.filter((item) => !hidden.has(item.id));
// The Archived view keeps stale cards in place and puts snoozed cards in their own group.
const archivedSplit = splitArchivedAttentionItems(visible);
const { current, stale, snoozed } = view === "archived"
? { current: archivedSplit.archived, stale: [] as AttentionItem[], snoozed: archivedSplit.snoozed }
: { ...splitStaleAttentionItems(visible), snoozed: [] as AttentionItem[] };
const row = (item: AttentionItem) => (
<AttentionQueueRow
key={item.id}
item={item}
companyId={companyId}
expanded={expandedId === item.id}
onToggleExpand={(entry) => setExpandedId((open) => (open === entry.id ? null : entry.id))}
onDismiss={hide}
onSnooze={hide}
onWithdraw={hide}
onTransfer={hide}
onRestore={view === "archived" ? hide : undefined}
currentUserId={viewerId}
userLabelMap={USER_LABELS}
onClaim={hide}
evidence={attentionObligationEvidence(obligations, item)}
/>
);
return (
<div className="mx-auto max-w-3xl space-y-4 p-6">
<h1 className="text-xl font-bold">Decisions</h1>
<AttentionViewSwitch value={view} counts={counts} onChange={setView} everyone={everyone} />
{view === "mine" && <AttentionUnassignedBanner count={counts.unassigned} onOpen={() => setView("unassigned")} />}
<div className="space-y-4">{current.map(row)}</div>
<AttentionStaleGroup
items={stale}
open={staleOpen}
onToggle={() => setStaleOpen((open) => !open)}
onWithdrawAll={(entries) => setHidden((currentHidden) => new Set([...currentHidden, ...entries.map((entry) => entry.id)]))}
currentUserId={viewerId}
userLabelMap={USER_LABELS}
>
{stale.map(row)}
</AttentionStaleGroup>
<AttentionSnoozedGroup count={snoozed.length} open={snoozedOpen} onToggle={() => setSnoozedOpen((open) => !open)}>
{snoozed.map(row)}
</AttentionSnoozedGroup>
</div>
);
}
const meta: Meta<typeof NeedsYouViews> = {
id: "pages-decisions-views",
title: "Tasks and conversations/Inbox, decisions, and search/Pages/Decisions views",
component: NeedsYouViews,
parameters: {
layout: "fullscreen",
paperclip: {
"capabilities": [
"inbox-search/decisions"
],
"implementation": "production",
"boundary": "fixtures",
"notes": "Mounts the shipped view switch, Unassigned banner, AttentionQueueRow (audience line, Stale chip, Close… menu, For-other gate, inline Retry and budget verbs, obligation evidence) and the Stale (N) group with fixture cards; Scott is the viewer. Claim, the Close… verbs and Withdraw all stale only hide rows locally here; the real close/claim/transfer endpoints, live updates and per-user server views need the running app."
},
},
};
export default meta;
type Story = StoryObj<typeof NeedsYouViews>;
/** Default view with the "Why me?" line for each audience reason, the banner and a collapsed Stale (4) group. */
export const ForYou: Story = {};
/**
* The Stale group open: each card carries its Stale chip and Close… menu.
* "Withdraw all stale" confirms first and lists the three requests it will
* withdraw and the review that stays.
*/
export const StaleGroupOpen: Story = { args: { staleOpen: true } };
/** Ownerless cards: why you can answer, and Claim. */
export const Unassigned: Story = { args: { initialView: "unassigned" } };
/** Operator alerts, plus an ownerless card that moved here after 7 days. */
export const Company: Story = { args: { initialView: "company" } };
/**
* Cards for a different person show "For @name" instead of "Why me?". Expanded,
* the answer controls wait behind "This is for @Dotta. Continue?"; the Close…
* menu asks the same before Withdraw or Transfer.
*/
export const ForAnotherPerson: Story = {
args: { initialView: "company", items: { ...ITEMS, company: FOR_OTHERS }, expandedId: "q-dotta", staleOpen: true },
};
/**
* Everyone (PAP-768): a board member sees every person's open cards with
* their own count, never in the badge. Each card for a different person shows
* "For @name"; Withdraw, Transfer and the answer controls ask "This is for
* @Dotta. Continue?" first.
*/
export const Everyone: Story = { args: { initialView: "all", expandedId: "q-dotta" } };
/** A viewer is not a board member, so the switch has no Everyone tab. */
export const NoEveryoneForViewer: Story = { args: { everyone: false } };
/** Company alerts expanded: the failed run retries inline, the budget stop raises or keeps paused. */
export const CompanyInlineVerbs: Story = {
args: { initialView: "company", expandedId: "budget" },
};
/**
* Your Archived view (PAP-749 plan §3): the cards you archived, each with its
* archive date (and Stale chip when it is also stale), then the Snoozed group
* with the time each card comes back. The Close… menu offers Restore (or
* "Restore now" and "Change snooze time") instead of Archive. The tab count
* is never in the For you badge.
*/
export const Archived: Story = { args: { initialView: "archived" } };
/**
* One card for each obligation (PAP-749 plan §2, Rule 11): the approval card
* names the task in review that waits on it, and the failed run names the task
* it blocks and keeps the one action (Retry). Two obligations, two cards, and
* the badge counts two.
*/
export const OneObligation: Story = {
args: { items: { ...ITEMS, mine: OBLIGATIONS }, counts: { ...COUNTS, mine: 2 }, expandedId: "ob-run" },
};
/**
* Escalation owners (PAP-749 task 1d): Scott owns budget escalation, so the
* budget hard stop is in his For you ("You own budget escalation") with Raise
* limit and Keep paused, and its override approval is one line under it
* ("Approval this answers"), not a card in Unassigned (PAP-768); a card on the
* Platform project reached him as the project's decision owner.
*/
export const EscalationOwners: Story = {
args: { items: { ...ITEMS, mine: ESCALATION_OWNER_CARDS }, counts: { ...COUNTS, mine: 2 }, expandedId: "budget-owned" },
};
/**
* The same hard stop in Dotta's Company view: Company still shows it to every
* operator, as "For @Scott", and Raise limit / Keep paused wait behind "This is
* for @Scott. Continue?".
*/
export const BudgetOwnerCardInCompany: Story = {
args: {
viewerId: DOTTA,
initialView: "company",
items: { ...ITEMS, company: [BUDGET_OWNER_STOP, BUDGET_OVERRIDE_APPROVAL, ...COMPANY_CARDS] },
counts: { ...COUNTS, company: COMPANY_CARDS.length + 1 },
expandedId: "budget-owned",
},
};
/** Nothing waits in the pool, so For you shows no banner. */
export const ForYouNoBanner: Story = {
args: { counts: { ...COUNTS, unassigned: 0 } },
};