diff --git a/DESIGN.md b/DESIGN.md index 901784c7c4e..4aaba5a24a1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 diff --git a/doc/PRODUCT.md b/doc/PRODUCT.md index 74186bf151d..7cba4ba80f7 100644 --- a/doc/PRODUCT.md +++ b/doc/PRODUCT.md @@ -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 diff --git a/maps/agent-access-map/inbox-search.md b/maps/agent-access-map/inbox-search.md index 526a7a54736..294b440631f 100644 --- a/maps/agent-access-map/inbox-search.md +++ b/maps/agent-access-map/inbox-search.md @@ -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 diff --git a/maps/agent-access-map/projects.md b/maps/agent-access-map/projects.md index d24acd44f69..f468e3b6187 100644 --- a/maps/agent-access-map/projects.md +++ b/maps/agent-access-map/projects.md @@ -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. diff --git a/maps/agent-access-map/questions-and-approvals.md b/maps/agent-access-map/questions-and-approvals.md index b6874e75f8d..99c27579438 100644 --- a/maps/agent-access-map/questions-and-approvals.md +++ b/maps/agent-access-map/questions-and-approvals.md @@ -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. diff --git a/maps/feature-map/inbox-search.md b/maps/feature-map/inbox-search.md index 988f700ae83..7568c7ac505 100644 --- a/maps/feature-map/inbox-search.md +++ b/maps/feature-map/inbox-search.md @@ -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. diff --git a/tests/e2e/announcement-placement.spec.ts b/tests/e2e/announcement-placement.spec.ts index 3cb0a41ca85..89f57dc6856 100644 --- a/tests/e2e/announcement-placement.spec.ts +++ b/tests/e2e/announcement-placement.spec.ts @@ -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" }); diff --git a/tests/e2e/coordinator-inbox-views.spec.ts b/tests/e2e/coordinator-inbox-views.spec.ts index 575ce660482..f07082e37fb 100644 --- a/tests/e2e/coordinator-inbox-views.spec.ts +++ b/tests/e2e/coordinator-inbox-views.spec.ts @@ -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 } }); diff --git a/ui/src/api/approvals.ts b/ui/src/api/approvals.ts index bda58c1cf7b..1526f8f2896 100644 --- a/ui/src/api/approvals.ts +++ b/ui/src/api/approvals.ts @@ -16,6 +16,9 @@ export const approvalsApi = { api.post(`/approvals/${id}/reject`, { decisionNote }), requestRevision: (id: string, decisionNote?: string) => api.post(`/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(`/approvals/${id}/cancel`, { reason }), resubmit: (id: string, payload?: Record) => api.post(`/approvals/${id}/resubmit`, { payload }), listComments: (id: string) => api.get(`/approvals/${id}/comments`), diff --git a/ui/src/api/attention.ts b/ui/src/api/attention.ts index da35a511a38..e0986442652 100644 --- a/ui/src/api/attention.ts +++ b/ui/src/api/attention.ts @@ -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(`/companies/${companyId}/attention/summary`), taskIndex: (companyId: string) => api.get(`/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(`/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(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(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(cardPath(companyId, sourceKind, sourceId, "transfer"), input), }; diff --git a/ui/src/api/companies.ts b/ui/src/api/companies.ts index f46d8be0129..840b2a1ad75 100644 --- a/ui/src/api/companies.ts +++ b/ui/src/api/companies.ts @@ -106,6 +106,8 @@ export const companiesApi = { | "description" | "status" | "budgetMonthlyCents" + | "defaultDecisionOwnerUserId" + | "budgetOwnerUserId" | "requireBoardApprovalForNewAgents" | "interactionResolverGovernance" | "feedbackDataSharingEnabled" diff --git a/ui/src/api/inboxDismissals.ts b/ui/src/api/inboxDismissals.ts index e8de87dfd1c..beeb243f0c4 100644 --- a/ui/src/api/inboxDismissals.ts +++ b/ui/src/api/inboxDismissals.ts @@ -3,13 +3,19 @@ import { api } from "./client"; export const inboxDismissalsApi = { list: (companyId: string) => api.get(`/companies/${companyId}/inbox-dismissals`), - dismiss: (companyId: string, itemKey: string) => - api.post(`/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(`/companies/${companyId}/inbox-dismissals`, { + itemKey, + ...(fingerprint ? { fingerprint } : {}), + }), + snooze: (companyId: string, itemKey: string, snoozedUntil: string, fingerprint?: string) => api.post(`/companies/${companyId}/inbox-dismissals`, { itemKey, kind: "snooze", snoozedUntil, + ...(fingerprint ? { fingerprint } : {}), }), restore: (companyId: string, itemKey: string) => api.delete(`/companies/${companyId}/inbox-dismissals/${encodeURIComponent(itemKey)}`), diff --git a/ui/src/components/ApprovalCardPanel.tsx b/ui/src/components/ApprovalCardPanel.tsx new file mode 100644 index 00000000000..577730def19 --- /dev/null +++ b/ui/src/components/ApprovalCardPanel.tsx @@ -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 | undefined, key: string): string | null { + const value = payload?.[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +const APPROVAL_KIND_LABELS: Record = { + 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 {artifactRow}; + 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 + } title={budget ? `${name} · ${wholeDollars(budget)} / month` : name} meta={meta || undefined} onOpen={onViewDetails} label="View configuration" /> + {budget ? "Approval creates the agent with this monthly spending limit." : "Approval creates the agent."} + ; + } + // 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 + } title={recommended ?? kind} meta={recommended ? kind : undefined} onOpen={onViewDetails} label="View details" /> + {next ? {`On approval: ${next}`} : null} + ; +} + diff --git a/ui/src/components/AttentionCloseMenu.test.tsx b/ui/src/components/AttentionCloseMenu.test.tsx new file mode 100644 index 00000000000..025d981840a --- /dev/null +++ b/ui/src/components/AttentionCloseMenu.test.tsx @@ -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 & { to: string }>(({ children, to, ...props }, ref) => ( + {children} + )), +})); + +// 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 = {}) { + 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("[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(`[data-attention-close-mode="${mode}"]`); +} + +function buttonWithText(text: string, scope: ParentNode = document.body) { + return [...scope.querySelectorAll("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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await openMenu(); + await click(menuItem("withdraw")); + const dialog = document.body.querySelector("[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(); + 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(); + 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('[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( +

Ship the launch plan

+
); + // 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(); + expect(container.querySelector("[data-attention-for-other-gate]")).toBeNull(); + expect(buttonWithText("Approve", container)).toBeTruthy(); + }); +}); + +describe("AttentionStaleChip", () => { + it("shows the stale reason", async () => { + await render(); + expect(container.querySelector('[data-attention-stale="aged"]')?.textContent).toBe("Stale · Waiting 30+ days"); + }); + + it("renders nothing for a current card", async () => { + await render(); + 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(

cards

); + expect(container.textContent).toContain("Stale (4)"); + expect(container.textContent).not.toContain("cards"); + const header = container.querySelector("[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(

cards

); + await click(buttonWithText("Withdraw all stale", container)); + expect(onWithdrawAll).not.toHaveBeenCalled(); + const dialog = document.body.querySelector("[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(

cards

); + await click(buttonWithText("Withdraw all stale", container)); + let dialog = document.body.querySelector("[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( `Task for ${item.id}`}>

cards

); + await click(buttonWithText("Withdraw all stale", container)); + dialog = document.body.querySelector("[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(

cards

); + expect(container.textContent).toContain("Stale (1)"); + expect(buttonWithText("Withdraw all stale", container)).toBeUndefined(); + }); + + it("renders nothing without stale cards", async () => { + await render(

cards

); + 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(<> + + + + ); + 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(

cards

); + const group = container.querySelector("[data-attention-snoozed-group]")!; + expect(group.textContent).toContain("Snoozed (2)"); + expect(group.querySelector("[data-child]")).not.toBeNull(); + await click(group.querySelector("[aria-expanded]")); + expect(onToggle).toHaveBeenCalled(); + await render(

cards

); + expect(container.querySelector("[data-attention-snoozed-group]")).toBeNull(); + }); +}); diff --git a/ui/src/components/AttentionCloseMenu.tsx b/ui/src/components/AttentionCloseMenu.tsx new file mode 100644 index 00000000000..babb3752944 --- /dev/null +++ b/ui/src/components/AttentionCloseMenu.tsx @@ -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 | 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 ( + <> + + + + + + {canWithdraw && ( + setWithdrawOpen(true)} data-attention-close-mode="withdraw"> + + Withdraw request… + + )} + {canRestore && ( + onRestore?.(item)} data-attention-close-mode="restore"> + + {hidden?.kind === "snoozed" ? "Restore now" : "Restore"} + + )} + {canArchive && ( + onArchive?.(item)} data-attention-close-mode="archive"> + + Archive for me + + )} + {canSnooze && ( + onSnooze?.(item, until)} + label={hidden?.kind === "snoozed" ? "Change snooze time" : undefined} + /> + )} + {targets.length > 0 && ( + + + + Transfer to… + + + {targets.map((target) => ( + transfer(target)}> + + @{target.label} + + ))} + + + )} + {href && ( + <> + {(canWithdraw || canRestore || canArchive || canSnooze || targets.length > 0) && } + + + + Open source + + + + )} + + + + {canWithdraw && ( + { + setWithdrawOpen(false); + onWithdraw?.(item, reason); + }} + /> + )} + + {transferTo && otherName && ( + { if (!open) setTransferTo(null); }}> + event.stopPropagation()}> + + {attentionForOtherText(otherName, "continue")} + + The card leaves the list of @{otherName} and goes to @{transferTo.label}. The history records the transfer. + + + + + + + + + )} + + ); +} + +/** + * 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 ( + { if (!next) setReason(""); onOpenChange(next); }}> + event.stopPropagation()} data-attention-withdraw-dialog> + + {otherName ? attentionForOtherText(otherName, "withdraw") : "Withdraw this request?"} + + {title ? <>{title}{" — "} : 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. + + +