From 8e7f1c03ebf564c3b2f7ac55e1d729cd22bc1d76 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:31:45 -0500 Subject: [PATCH] feat(decisions): improve desk triage and queue parity (#10785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The decisions desk and queue help operators find work that needs a human decision. > - The current views use different grouping, sorting, and labels. > - Repeated confirmation requests can also leave stale pending actions in the queue. > - Blocked-work attention can point at an intermediate issue instead of the terminal blocker. > - This pull request aligns the server contract and both user interfaces. > - The benefit is a smaller, clearer queue that ranks the decisions with the largest impact. ## Linked Issues or Issue Description Related PR: #10774 **What existing behavior does this improve?** The decisions desk and queue currently use different triage rules. They can show stale repeated confirmations and can rank blocked work by an intermediate issue. **Subsystem affected** This change affects attention aggregation, issue thread interactions, shared attention contracts, and the decisions user interface. **Current behavior** The desk uses a can-wait group that has no clear arrival meaning. The queue has fewer controls than the desk. Repeated pending confirmations remain actionable. Blocked-work rows do not always identify the terminal actionable blocker. **Proposed behavior** Group desk items by arrival date, and reserve Decide now for explicit due dates. Use one toolbar and shelf model on both pages. Supersede older repeated pending confirmations. Aggregate blocked work under the terminal actionable blocker and rank it by impact. **Reason and benefit** Operators get one consistent triage model. The badge reflects new and overdue work. High-impact blockers move to the top. Duplicate confirmation work no longer consumes attention. **Breaking changes** The attention summary field `decideNowCount` changes to `deskBadgeCount`. Consumers must use the new field. Older repeated confirmation interactions can now finish with the `superseded_by_newer_request` outcome. ## What Changed - Supersede older pending confirmation requests for the same issue and record the mutation in activity history. - Resolve blocked-work attention to actionable terminal blockers, suppress live blocker trees, and rank rows by blocked-work impact. - Group the decisions desk into New today and Earlier, and count new plus overdue work in the desk badge. - Share the decision toolbar and shelf components across the desk and queue. - Add queue grouping, sorting, filtering, aging, visible training controls, and clearer recommendation copy. - Add server, shared-contract, and user-interface tests for the new behavior. ## Verification - `pnpm check:token-gates` - `pnpm -r typecheck` - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm test:run` (all server, UI, CLI, shared, and catalog tests passed; one fixed five-second DB timeout flaked under full-suite load) - `NODE_ENV=test pnpm --filter @paperclipai/db exec vitest run src/status-card-migrations.test.ts` (passed in isolation) - `pnpm build` ## Risks - The attention summary field rename requires synchronized consumers. - Terminal-blocker traversal uses cycle and depth guards. A malformed dependency graph can stop at the last safe node. - The new arrival grouping changes which items contribute to the decisions badge. - Superseding repeated confirmations changes the terminal state of older pending interactions. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex with GPT-5. The runtime did not expose a dated model snapshot or context-window size. The model used reasoning, repository tools, code execution, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/issue-thread-interactions.test.ts | 14 + .../generated/paperclip-telemetry.ts | 3 +- packages/shared/src/types/attention.ts | 9 +- packages/shared/src/types/issue.ts | 14 +- packages/shared/src/validators/issue.ts | 11 +- .../src/__tests__/attention-service.test.ts | 267 ++++++++++- .../issue-thread-interactions-service.test.ts | 166 +++++++ .../server-startup-feedback-export.test.ts | 9 + server/src/index.ts | 6 + server/src/routes/attention.ts | 1 + server/src/services/attention.ts | 295 ++++++++++-- .../src/services/issue-thread-interactions.ts | 140 +++++- server/src/services/issues.ts | 90 +++- ui/src/components/AttentionQueueRow.test.tsx | 32 +- ui/src/components/AttentionQueueRow.tsx | 18 + ui/src/components/DecisionShelf.tsx | 126 +++++ ui/src/components/DecisionTriageStrip.tsx | 18 +- ui/src/components/DecisionsToolbar.tsx | 295 ++++++++++++ ui/src/lib/attention.test.ts | 59 ++- ui/src/lib/attention.ts | 97 +++- ui/src/pages/DecisionQueuePage.tsx | 299 ++++++++++-- ui/src/pages/WhatNeedsMe.tsx | 444 ++---------------- .../stories/decisions-desk.stories.tsx | 79 +++- 23 files changed, 1909 insertions(+), 583 deletions(-) create mode 100644 ui/src/components/DecisionShelf.tsx create mode 100644 ui/src/components/DecisionsToolbar.tsx diff --git a/packages/shared/src/issue-thread-interactions.test.ts b/packages/shared/src/issue-thread-interactions.test.ts index 8b99c694d4..5856335181 100644 --- a/packages/shared/src/issue-thread-interactions.test.ts +++ b/packages/shared/src/issue-thread-interactions.test.ts @@ -84,6 +84,20 @@ describe("issue thread interaction schemas", () => { .toBeUndefined(); }); + it("parses superseded confirmation results with a replacement pointer", () => { + const result = requestConfirmationResultSchema.parse({ + version: 1, + outcome: "superseded_by_newer_request", + supersededByInteractionId: "11111111-1111-4111-8111-111111111111", + }); + + expect(result).toEqual({ + version: 1, + outcome: "superseded_by_newer_request", + supersededByInteractionId: "11111111-1111-4111-8111-111111111111", + }); + }); + it("accepts issue document targets for request_confirmation interactions", () => { const parsed = createIssueThreadInteractionSchema.parse({ kind: "request_confirmation", diff --git a/packages/shared/src/telemetry/generated/paperclip-telemetry.ts b/packages/shared/src/telemetry/generated/paperclip-telemetry.ts index 57fe14cab8..4b5288259a 100644 --- a/packages/shared/src/telemetry/generated/paperclip-telemetry.ts +++ b/packages/shared/src/telemetry/generated/paperclip-telemetry.ts @@ -42,7 +42,7 @@ export interface PaperclipInstallStartedDimensions { export interface PaperclipInteractionResolvedDimensions { interaction_kind: ("suggest_tasks" | "ask_user_questions" | "request_confirmation" | "request_checkbox_confirmation" | "other") status: ("accepted" | "rejected" | "answered" | "cancelled" | "expired" | "failed" | "other") -resolution_reason?: ("accepted" | "rejected" | "stale_target" | "superseded_by_comment" | "expired" | "cancelled" | "other") +resolution_reason?: ("accepted" | "rejected" | "stale_target" | "superseded_by_comment" | "superseded_by_newer_request" | "expired" | "cancelled" | "other") resolved_by_kind: ("user" | "agent" | "system" | "other") created_by_kind?: ("agent" | "user" | "other") creator_agent_role?: ("ceo" | "cto" | "cmo" | "cfo" | "security" | "engineer" | "designer" | "pm" | "qa" | "devops" | "researcher" | "general" | "other") @@ -255,6 +255,7 @@ export const PAPERCLIP_ENUM_DESCRIPTIONS = { "rejected": "Stored result outcome says the interaction was rejected.", "stale_target": "Bound target, such as an issue document revision, was no longer current.", "superseded_by_comment": "A later user or board comment superseded the pending confirmation.", + "superseded_by_newer_request": "A newer confirmation from the same agent superseded the pending confirmation.", "expired": "Interaction expired for a generic expiration reason.", "cancelled": "Interaction was explicitly cancelled.", "other": "Fallback when the resolution reason is unknown or not represented by the tracked enum." diff --git a/packages/shared/src/types/attention.ts b/packages/shared/src/types/attention.ts index 38edb8d291..47a657a17d 100644 --- a/packages/shared/src/types/attention.ts +++ b/packages/shared/src/types/attention.ts @@ -158,6 +158,7 @@ export type AttentionItemDetail = identifier: string | null; title: string | null; } | null; + blockedTaskCount?: number; images: AttentionDetailImage[]; } | { @@ -220,7 +221,13 @@ export interface AttentionFeed { companyId: string; generatedAt: string; totalCount: number; - decideNowCount: number; + /** + * The sidebar badge: distinct items that either surfaced today ("new today") + * or carry an explicit decide-by deadline that is due today/past ("overdue"). + * Computed before pagination so a small first page still reflects the + * company-wide load. The desk no longer editorializes about what "can wait". + */ + deskBadgeCount: number; nextCursor: string | null; countsBySourceKind: Record; items: AttentionItem[]; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 1fdab004f7..bdeed97cde 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -403,6 +403,10 @@ export interface IssueBlockerAttention { pendingFinalizeBlockerIssueIds?: string[]; sampleBlockerIdentifier: string | null; sampleStalledBlockerIdentifier: string | null; + /** True when a blocker or one of its open descendants is actively progressing. */ + blockingTreeLive?: boolean; + /** The sampled leaf blocker that requires action, rather than the blocked root. */ + terminalBlockerIssueId?: string | null; } export type IssueInboxAttentionKind = "blocked"; @@ -1189,9 +1193,17 @@ export interface RequestItemVerdictsPayload { export interface RequestConfirmationResult { version: 1; - outcome: "accepted" | "rejected" | "superseded_by_comment" | "stale_target" | "withdrawn" | "issue_closed"; + outcome: + | "accepted" + | "rejected" + | "superseded_by_comment" + | "superseded_by_newer_request" + | "stale_target" + | "withdrawn" + | "issue_closed"; reason?: string | null; commentId?: string | null; + supersededByInteractionId?: string | null; staleTarget?: RequestConfirmationTarget | null; resumeFailure?: { status: "retrying" | "needs_attention"; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index a3183ce3a2..3d912ec03a 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -985,9 +985,18 @@ export const requestConfirmationToolActionResultSchema = z.object({ export const requestConfirmationResultSchema = z.object({ version: z.literal(1), - outcome: z.enum(["accepted", "rejected", "superseded_by_comment", "stale_target", "withdrawn", "issue_closed"]), + outcome: z.enum([ + "accepted", + "rejected", + "superseded_by_comment", + "superseded_by_newer_request", + "stale_target", + "withdrawn", + "issue_closed", + ]), reason: z.string().trim().max(4000).nullable().optional(), commentId: z.string().uuid().nullable().optional(), + supersededByInteractionId: z.string().uuid().nullable().optional(), staleTarget: requestConfirmationTargetSchema.nullable().optional(), resumeFailure: requestConfirmationResumeFailureSchema.nullable().optional(), toolAction: requestConfirmationToolActionResultSchema.optional(), diff --git a/server/src/__tests__/attention-service.test.ts b/server/src/__tests__/attention-service.test.ts index 62ac6aa2a1..286f4e6bc4 100644 --- a/server/src/__tests__/attention-service.test.ts +++ b/server/src/__tests__/attention-service.test.ts @@ -611,8 +611,10 @@ describeEmbeddedPostgres("attention service", () => { }); expect(feed.items.find((item) => item.sourceKind === "blocker_attention")?.detail).toMatchObject({ kind: "blocker", - blockingIssue: { identifier: "ATN-5", title: "Stalled review blocker" }, + blockingIssue: null, + blockedTaskCount: 1, }); + expect(feed.items.find((item) => item.sourceKind === "blocker_attention")?.subject.id).toBe(blockerLeafId); expect(feed.items.find((item) => item.sourceKind === "failed_run")?.detail).toMatchObject({ kind: "failed_run", agentName: "Worker", @@ -872,6 +874,70 @@ describeEmbeddedPostgres("attention service", () => { }); }); + it("shows only the newest pending confirmation per issue and kind", async () => { + const { companyId, workerId, reviewerId } = await seedCompany("ATC"); + const issueId = await insertIssue({ + companyId, + identifier: "ATC-1", + title: "Repeated sign-offs", + status: "in_review", + assigneeAgentId: workerId, + }); + const olderConfirmationId = randomUUID(); + const newerConfirmationId = randomUUID(); + const checkboxId = randomUUID(); + await db.insert(issueThreadInteractions).values([ + { + id: olderConfirmationId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: workerId, + title: "Approve V1", + payload: { version: 1, prompt: "Approve V1?" }, + createdAt: new Date("2026-07-09T12:00:00.000Z"), + updatedAt: new Date("2026-07-09T12:10:00.000Z"), + }, + { + id: newerConfirmationId, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: reviewerId, + title: "Approve V2", + payload: { version: 1, prompt: "Approve V2?" }, + createdAt: new Date("2026-07-09T12:05:00.000Z"), + updatedAt: new Date("2026-07-09T12:05:00.000Z"), + }, + { + id: checkboxId, + companyId, + issueId, + kind: "request_checkbox_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: workerId, + title: "Select rollout", + payload: { version: 1, prompt: "Select rollout", options: [{ id: "one", label: "One" }] }, + createdAt: new Date("2026-07-09T12:01:00.000Z"), + updatedAt: new Date("2026-07-09T12:01:00.000Z"), + }, + ]); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const interactionIds = feed.items + .filter((item) => item.sourceKind === "issue_thread_interaction") + .map((item) => item.subject.id); + + expect(interactionIds).toContain(newerConfirmationId); + expect(interactionIds).toContain(checkboxId); + expect(interactionIds).not.toContain(olderConfirmationId); + }); + it("uses inbox_dismissals with attention-prefixed dedup keys and resurfaces newer activity", async () => { const { companyId } = await seedCompany("ATD"); const approvalId = randomUUID(); @@ -938,7 +1004,7 @@ describeEmbeddedPostgres("attention service", () => { const feed = await attentionService(db).list(companyId, { userId: "board-user" }); - expect(feed.items.some((item) => item.dedupKey === `blocker:${issueId}:ATP-1`)).toBe(true); + expect(feed.items.some((item) => item.dedupKey === `blocker:${issueId}`)).toBe(true); }); // Regression: both blocker_attention call sites fell back to the blocked @@ -955,15 +1021,14 @@ describeEmbeddedPostgres("attention service", () => { }); const feed = await attentionService(db).list(companyId, { userId: "board-user" }); - const row = feed.items.find((item) => item.dedupKey === `blocker:${issueId}:ATV-1`); + const row = feed.items.find((item) => item.dedupKey === `blocker:${issueId}`); expect(row).toBeTruthy(); expect(row?.detail).toMatchObject({ kind: "blocker", blockingIssue: null }); - // The dedup key keeps its original fallback so existing dismissals survive. - expect(row?.dismissalKey).toBe(`attention:blocker:${issueId}:ATV-1`); + expect(row?.dismissalKey).toBe(`attention:blocker:${issueId}`); }); - it("names the real blocking task when a blocks relation exists", async () => { + it("suppresses a blocked dependency row while its blocker is actively progressing", async () => { const { companyId } = await seedCompany("ATW"); const blockedId = await insertIssue({ companyId, @@ -986,12 +1051,129 @@ describeEmbeddedPostgres("attention service", () => { }); const feed = await attentionService(db).list(companyId, { userId: "board-user" }); - const row = feed.items.find((item) => item.sourceKind === "blocker_attention" && item.subject.id === blockedId); + expect(feed.items.some((item) => item.sourceKind === "blocker_attention")).toBe(false); + }); - expect(row?.detail).toMatchObject({ - kind: "blocker", - blockingIssue: { identifier: "ATW-2", title: "The actual blocker" }, + it("suppresses a mixed blocker tree when any blocker is live", async () => { + const { companyId } = await seedCompany("ATL"); + const blockedId = await insertIssue({ + companyId, + identifier: "ATL-1", + title: "Blocked rollout", + status: "blocked", }); + const blockerIds = await Promise.all([ + insertIssue({ companyId, identifier: "ATL-2", title: "Live phase one", status: "in_progress" }), + insertIssue({ companyId, identifier: "ATL-3", title: "Live phase two", status: "in_progress" }), + insertIssue({ companyId, identifier: "ATL-4", title: "Live phase three", status: "in_progress" }), + insertIssue({ companyId, identifier: "ATL-5", title: "Stopped phase", status: "todo" }), + ]); + await db.insert(issueRelations).values(blockerIds.map((issueId) => ({ + companyId, + issueId, + relatedIssueId: blockedId, + type: "blocks" as const, + }))); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + + expect(feed.items.filter((item) => item.sourceKind === "blocker_attention")).toEqual([]); + }); + + it("emits one terminal-blocker row with a cycle-safe transitive blocked-work count", async () => { + const { companyId } = await seedCompany("ATC"); + const terminalId = await insertIssue({ + companyId, + identifier: "ATC-1", + title: "Choose migration owner", + status: "todo", + }); + const blockedId = await insertIssue({ + companyId, + identifier: "ATC-2", + title: "Blocked migration", + status: "blocked", + }); + await insertIssue({ + companyId, + identifier: "ATC-3", + title: "Open migration child", + status: "todo", + parentId: blockedId, + }); + const transitiveId = await insertIssue({ + companyId, + identifier: "ATC-4", + title: "Transitively blocked follow-up", + status: "todo", + }); + await insertIssue({ + companyId, + identifier: "ATC-5", + title: "Closed child", + status: "done", + parentId: blockedId, + }); + await db.insert(issueRelations).values([ + { companyId, issueId: terminalId, relatedIssueId: blockedId, type: "blocks" }, + { companyId, issueId: blockedId, relatedIssueId: transitiveId, type: "blocks" }, + // Corrupt legacy cycles must not inflate or hang the count. + { companyId, issueId: transitiveId, relatedIssueId: blockedId, type: "blocks" }, + ]); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const rows = feed.items.filter((item) => item.sourceKind === "blocker_attention"); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + subject: { id: terminalId, identifier: "ATC-1", title: "Choose migration owner" }, + relatedIssue: { id: blockedId }, + whyNow: "Blocks 3 tasks and needs human attention.", + detail: { kind: "blocker", blockingIssue: null, blockedTaskCount: 3 }, + }); + expect(rows[0]?.subject.id).not.toBe(blockedId); + }); + + it("orders terminal blockers by blocked-work weight descending", async () => { + const { companyId } = await seedCompany("ATR"); + const heavyTerminalId = await insertIssue({ + companyId, + identifier: "ATR-1", + title: "Heavy blocker", + status: "todo", + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + }); + const lightTerminalId = await insertIssue({ + companyId, + identifier: "ATR-2", + title: "Light blocker", + status: "todo", + updatedAt: new Date("2026-07-03T00:00:00.000Z"), + }); + const heavyBlockedId = await insertIssue({ + companyId, + identifier: "ATR-3", + title: "Heavy blocked root", + status: "blocked", + }); + const lightBlockedId = await insertIssue({ + companyId, + identifier: "ATR-4", + title: "Light blocked root", + status: "blocked", + }); + await insertIssue({ companyId, identifier: "ATR-5", title: "Heavy child one", status: "todo", parentId: heavyBlockedId }); + await insertIssue({ companyId, identifier: "ATR-6", title: "Heavy child two", status: "todo", parentId: heavyBlockedId }); + await db.insert(issueRelations).values([ + { companyId, issueId: heavyTerminalId, relatedIssueId: heavyBlockedId, type: "blocks" }, + { companyId, issueId: lightTerminalId, relatedIssueId: lightBlockedId, type: "blocks" }, + ]); + + const feed = await attentionService(db).list(companyId, { userId: "board-user" }); + const rows = feed.items.filter((item) => item.sourceKind === "blocker_attention"); + + expect(rows.map((item) => item.subject.id)).toEqual([heavyTerminalId, lightTerminalId]); + expect(rows.map((item) => item.detail?.kind === "blocker" ? item.detail.blockedTaskCount : null)).toEqual([3, 1]); }); it("does not name the blocked task as its own blocker on a human-owned unblock row", async () => { @@ -1195,7 +1377,19 @@ describeEmbeddedPostgres("attention service", () => { sort: "decide", limit: 20, }); - expect(feed.decideNowCount).toBe(2); + // Desk badge = distinct items surfaced today OR with a due decide-by + // Everything here was seeded ~now, so every visible row + // counts; the whole page fits under limit:20 so items == rankedItems. + const startOfUtcDay = Date.UTC( + new Date(now).getUTCFullYear(), + new Date(now).getUTCMonth(), + new Date(now).getUTCDate(), + ); + const expectedBadge = feed.items.filter( + (item) => new Date(item.createdAt).getTime() >= startOfUtcDay || item.decideBy === "today", + ).length; + expect(expectedBadge).toBeGreaterThanOrEqual(2); + expect(feed.deskBadgeCount).toBe(expectedBadge); expect(feed.items.some((item) => item.subject.id === snoozedId)).toBe(false); expect(feed.items.slice(0, 3).map((item) => item.subject.id)).toEqual([ expiringSoonId, @@ -1228,7 +1422,7 @@ describeEmbeddedPostgres("attention service", () => { sort: "decide", limit: 1, }); - expect(firstPage).toMatchObject({ totalCount: 2, decideNowCount: 2 }); + expect(firstPage).toMatchObject({ totalCount: 2, deskBadgeCount: 2 }); expect(firstPage.items.map((item) => item.subject.id)).toEqual([expiringSoonId]); expect(firstPage.nextCursor).toBeTruthy(); const secondPage = await attentionService(db).list(companyId, { @@ -1334,6 +1528,51 @@ describeEmbeddedPostgres("attention service", () => { })).rejects.toThrow("all cannot be combined with cursor or limit"); }); + it("does not apply the open-decision safety limit to complete snapshots", async () => { + const { companyId, workerId } = await seedCompany("ATC"); + const originIssueId = await insertIssue({ + companyId, + identifier: "ATC-1", + title: "Decision origin", + status: "in_progress", + assigneeAgentId: workerId, + }); + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId: workerId, + status: "succeeded", + contextSnapshot: { issueId: originIssueId }, + }); + await db.insert(decisions).values(["First decision", "Second decision"].map((title) => ({ + id: randomUUID(), + companyId, + originAgentId: workerId, + originIssueId, + originRunId: runId, + title, + body: title, + options: [], + status: "open" as const, + expiresAt: new Date("2026-08-10T00:00:00.000Z"), + signedSpec: "test", + targetSnapshots: {}, + }))); + + const svc = attentionService(db, { openDecisionLimit: 1 }); + const limited = await svc.list(companyId, { userId: "board-user" }); + expect(limited.items.filter((item) => item.sourceKind === "decision")).toHaveLength(1); + + const complete = await svc.list(companyId, { + userId: "board-user", + all: true, + allowUnscopedAll: true, + }); + expect(complete.items.filter((item) => item.sourceKind === "decision")).toHaveLength(2); + expect(complete.nextCursor).toBeNull(); + }); + it("keeps this-week deadlines in the current UTC week", async () => { const { companyId, workerId } = await seedCompany("ATW"); const now = Date.parse("2026-08-02T12:00:00.000Z"); // Sunday in an ISO Monday-Sunday week. @@ -1442,6 +1681,10 @@ describeEmbeddedPostgres("attention service", () => { }; await request(app(board)).get(`/api/companies/${companyId}/attention`).expect(200); + const completeFeed = await request(app(board)) + .get(`/api/companies/${companyId}/attention?includeDismissed=true&all=true`) + .expect(200); + expect(completeFeed.body.nextCursor).toBeNull(); await request(app(board)) .get(`/api/companies/${companyId}/attention?activitySince=yesterday`) .expect(400, { error: "activitySince must be an ISO timestamp" }); diff --git a/server/src/__tests__/issue-thread-interactions-service.test.ts b/server/src/__tests__/issue-thread-interactions-service.test.ts index 096dd95a11..2083b1e426 100644 --- a/server/src/__tests__/issue-thread-interactions-service.test.ts +++ b/server/src/__tests__/issue-thread-interactions-service.test.ts @@ -892,6 +892,172 @@ describeEmbeddedPostgres("issueThreadInteractionService", () => { expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire"); }); + it("supersedes older pending confirmations from the same agent without crossing agent or kind", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Newer confirmation supersedes older"); + const firstAgentId = randomUUID(); + const secondAgentId = randomUUID(); + await db.insert(agents).values([ + { + id: firstAgentId, + companyId, + name: "First agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: secondAgentId, + companyId, + name: "Second agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + const older = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_confirmation", + idempotencyKey: "confirmation:first:older", + payload: { version: 1, prompt: "Approve the older draft?" }, + }, { agentId: firstAgentId }); + const otherKind = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_checkbox_confirmation", + idempotencyKey: "checkbox:first", + payload: { + version: 1, + prompt: "Select regions", + options: [{ id: "us", label: "US" }], + }, + }, { agentId: firstAgentId }); + const otherAgent = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_confirmation", + idempotencyKey: "confirmation:second", + payload: { version: 1, prompt: "Approve the second agent's draft?" }, + }, { agentId: secondAgentId }); + const replacement = await interactionsSvc.create({ id: issueId, companyId }, { + kind: "request_confirmation", + idempotencyKey: "confirmation:first:newer", + payload: { version: 1, prompt: "Approve the newer draft?" }, + }, { agentId: firstAgentId }); + + const interactions = await interactionsSvc.listForIssue(issueId); + expect(interactions.find((interaction) => interaction.id === older.id)).toMatchObject({ + status: "expired", + resolvedByAgentId: firstAgentId, + result: { + outcome: "superseded_by_newer_request", + supersededByInteractionId: replacement.id, + }, + }); + expect(interactions.find((interaction) => interaction.id === replacement.id)?.status).toBe("pending"); + expect(interactions.find((interaction) => interaction.id === otherAgent.id)?.status).toBe("pending"); + expect(interactions.find((interaction) => interaction.id === otherKind.id)?.status).toBe("pending"); + }); + + it("sweeps historical confirmation pile-ups idempotently per issue, kind, and agent", async () => { + const { companyId, issueId } = await seedConfirmationIssue("Historical confirmation sweep"); + const firstAgentId = randomUUID(); + const secondAgentId = randomUUID(); + await db.insert(agents).values([ + { + id: firstAgentId, + companyId, + name: "First agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: secondAgentId, + companyId, + name: "Second agent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + const firstAgentIds = [randomUUID(), randomUUID(), randomUUID()]; + const secondAgentIds = [randomUUID(), randomUUID()]; + const checkboxId = randomUUID(); + await db.insert(issueThreadInteractions).values([ + ...firstAgentIds.map((id, index) => ({ + id, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: firstAgentId, + payload: { version: 1 as const, prompt: `First agent draft ${index + 1}` }, + createdAt: new Date(`2026-07-01T12:0${index}:00.000Z`), + updatedAt: new Date(`2026-07-01T12:0${index}:00.000Z`), + })), + ...secondAgentIds.map((id, index) => ({ + id, + companyId, + issueId, + kind: "request_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: secondAgentId, + payload: { version: 1 as const, prompt: `Second agent draft ${index + 1}` }, + createdAt: new Date(`2026-07-01T13:0${index}:00.000Z`), + updatedAt: new Date(`2026-07-01T13:0${index}:00.000Z`), + })), + { + id: checkboxId, + companyId, + issueId, + kind: "request_checkbox_confirmation", + status: "pending", + continuationPolicy: "wake_assignee", + createdByAgentId: firstAgentId, + payload: { version: 1, prompt: "Select one", options: [{ id: "one", label: "One" }] }, + createdAt: new Date("2026-07-01T14:00:00.000Z"), + updatedAt: new Date("2026-07-01T14:00:00.000Z"), + }, + ]); + + await expect(interactionsSvc.sweepSupersededPendingRequestConfirmations()) + .resolves.toEqual({ expired: 3 }); + await expect(interactionsSvc.sweepSupersededPendingRequestConfirmations()) + .resolves.toEqual({ expired: 0 }); + + const interactions = await interactionsSvc.listForIssue(issueId); + for (const id of firstAgentIds.slice(0, -1)) { + expect(interactions.find((interaction) => interaction.id === id)).toMatchObject({ + status: "expired", + result: { + outcome: "superseded_by_newer_request", + supersededByInteractionId: firstAgentIds.at(-1), + }, + }); + } + expect(interactions.find((interaction) => interaction.id === firstAgentIds.at(-1))?.status).toBe("pending"); + expect(interactions.find((interaction) => interaction.id === secondAgentIds[0])).toMatchObject({ + status: "expired", + result: { + outcome: "superseded_by_newer_request", + supersededByInteractionId: secondAgentIds[1], + }, + }); + expect(interactions.find((interaction) => interaction.id === secondAgentIds[1])?.status).toBe("pending"); + expect(interactions.find((interaction) => interaction.id === checkboxId)?.status).toBe("pending"); + }); + it("refuses to create an interaction on a closed issue", async () => { const { companyId, issueId } = await seedConfirmationIssue("Closed issue create guard"); await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId)); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 4a6daa4f8b..790e0caab8 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -24,6 +24,8 @@ const { fakeServer, heartbeatServiceFactoryMock, heartbeatServiceMock, + issueThreadInteractionServiceFactoryMock, + issueThreadInteractionServiceMock, loadConfigMock, resolveHeartbeatSchedulingSuppressionMock, routineServiceFactoryMock, @@ -69,6 +71,10 @@ const { tickTimers: vi.fn(async () => ({ checked: 0, enqueued: 0, skipped: 0 })), }; const heartbeatServiceFactoryMock = vi.fn(() => heartbeatServiceMock); + const issueThreadInteractionServiceMock = { + sweepSupersededPendingRequestConfirmations: vi.fn(async () => ({ expired: 0 })), + }; + const issueThreadInteractionServiceFactoryMock = vi.fn(() => issueThreadInteractionServiceMock); const environmentCustomImagesServiceMock = { cleanupExpiredSetupSessions: vi.fn(async () => ({ scanned: 0, timedOut: 0, failed: 0 })), }; @@ -111,6 +117,8 @@ const { fakeServer, heartbeatServiceFactoryMock, heartbeatServiceMock, + issueThreadInteractionServiceFactoryMock, + issueThreadInteractionServiceMock, loadConfigMock, resolveHeartbeatSchedulingSuppressionMock, routineServiceFactoryMock, @@ -235,6 +243,7 @@ vi.mock("../services/index.js", () => ({ environmentCustomImageService: environmentCustomImagesServiceFactoryMock, externalObjectService: externalObjectsServiceFactoryMock, heartbeatService: heartbeatServiceFactoryMock, + issueThreadInteractionService: issueThreadInteractionServiceFactoryMock, issueService: vi.fn(() => ({ update: vi.fn(async () => null) })), instanceSettingsService: vi.fn(() => ({ getExperimental: vi.fn(async () => ({ diff --git a/server/src/index.ts b/server/src/index.ts index 53f1be5200..26ccfe9c39 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -51,6 +51,7 @@ import { decisionRetentionService, externalObjectService, heartbeatService, + issueThreadInteractionService, issueService, instanceSettingsService, reconcileBuiltInAgentsOnStartup, @@ -561,6 +562,11 @@ export async function startServer(): Promise { if (toolOAuthBackfill.sanitizedConnections > 0 || toolOAuthBackfill.migratedConnections > 0) { logger.info(toolOAuthBackfill, "Backfilled legacy tool OAuth credentials into company secrets"); } + const confirmationSweep = await issueThreadInteractionService(db as any) + .sweepSupersededPendingRequestConfirmations(); + if (confirmationSweep.expired > 0) { + logger.info(confirmationSweep, "Expired pending confirmations superseded by newer agent requests"); + } if (config.deploymentMode === "authenticated") { const { createBetterAuthHandler, diff --git a/server/src/routes/attention.ts b/server/src/routes/attention.ts index d9f9d2158a..1a9458b5f6 100644 --- a/server/src/routes/attention.ts +++ b/server/src/routes/attention.ts @@ -43,6 +43,7 @@ export function attentionRoutes(db: Db) { includeDismissed, archived, all, + allowUnscopedAll: all, activitySince, activityUntil, queue, diff --git a/server/src/services/attention.ts b/server/src/services/attention.ts index 16cd9ca68a..d6dae780f7 100644 --- a/server/src/services/attention.ts +++ b/server/src/services/attention.ts @@ -47,7 +47,11 @@ import type { import { badRequest } from "../errors.js"; import { PRODUCTIVITY_REVIEW_ORIGIN_KIND } from "./productivity-review.js"; import { budgetService } from "./budgets.js"; -import { issueService } from "./issues.js"; +import { + BLOCKER_ATTENTION_MAX_DEPTH, + BLOCKER_ATTENTION_MAX_NODES, + issueService, +} from "./issues.js"; import { parseIssueExecutionState } from "./issue-execution-policy.js"; import { isProspectiveBlockedTransition } from "./routable-blocked.js"; import { decisionQueueService } from "./decision-queues.js"; @@ -102,6 +106,13 @@ const OPEN_DECISION_DEFAULT_LIMIT = 500; const OPEN_DECISION_MAX_LIMIT = 1_000; const ATTENTION_PAGE_DEFAULT_LIMIT = 50; const ATTENTION_PAGE_MAX_LIMIT = 100; +const ATTENTION_GRAPH_QUERY_CHUNK_SIZE = 500; + +function chunkValues(values: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) chunks.push(values.slice(index, index + size)); + return chunks; +} type IssueSummaryRow = { id: string; @@ -223,10 +234,11 @@ function isPlanDocumentTarget(payload: Record) { return target.type === "issue_document" && target.key === "plan"; } -function issueContext(issue: IssueSummaryRow | null | undefined) { +function issueContext(issue: IssueSummaryRow | IssueSubjectRow | null | undefined) { + const summary = issue && "project" in issue ? issue : null; return { - project: issue?.project ?? null, - workspace: issue?.workspace ?? null, + project: summary?.project ?? null, + workspace: summary?.workspace ?? null, }; } @@ -405,6 +417,32 @@ function compareAttentionItems(left: AttentionItem, right: AttentionItem) { return left.dedupKey.localeCompare(right.dedupKey); } +function blockedTaskCount(item: AttentionItem) { + return item.sourceKind === "blocker_attention" + && item.detail?.kind === "blocker" + && typeof item.detail.blockedTaskCount === "number" + ? item.detail.blockedTaskCount + : null; +} + +/** Preserve the selected desk sort for every row slot while ordering blocker + * rows by the amount of work they hold up. */ +function orderBlockedAttentionByWeight( + items: AttentionItem[], + fallback: (left: AttentionItem, right: AttentionItem) => number, +) { + const blockers = items + .filter((item) => blockedTaskCount(item) !== null) + .sort((left, right) => { + const weightDiff = (blockedTaskCount(right) ?? 0) - (blockedTaskCount(left) ?? 0); + return weightDiff !== 0 ? weightDiff : fallback(left, right); + }); + if (blockers.length < 2) return items; + + let blockerIndex = 0; + return items.map((item) => blockedTaskCount(item) === null ? item : blockers[blockerIndex++]!); +} + function sourceKey(sourceKind: AttentionSourceKind, sourceId: string) { return `${sourceKind}:${sourceId}`; } @@ -454,6 +492,12 @@ function isDecideNow(item: AttentionItem, now: number) { return bucket === 0 && deadline <= endOfUtcDay(now); } +/** Surfaced today (arrival). Mirrors `attentionIsNewToday` in `ui/src/lib/attention.ts`. */ +function isNewToday(item: AttentionItem, now: number) { + const ts = timestamp(item.createdAt); + return ts > 0 && ts >= startOfUtcDay(now); +} + function compareDecideItems(left: AttentionItem, right: AttentionItem, now: number) { const [leftBucket, leftDeadline] = decideOrder(left, now); const [rightBucket, rightDeadline] = decideOrder(right, now); @@ -662,6 +706,32 @@ function interactionVerbs(kind: string, payload: Record) { ); } +function collapsePendingConfirmationsToNewest(rows: T[]) { + const newestByGroup = new Map(); + for (const row of rows) { + if (row.kind !== "request_confirmation") continue; + const groupKey = `${row.issueId}:${row.kind}`; + const newest = newestByGroup.get(groupKey); + if ( + !newest + || row.createdAt.getTime() > newest.createdAt.getTime() + || (row.createdAt.getTime() === newest.createdAt.getTime() && row.id > newest.id) + ) { + newestByGroup.set(groupKey, row); + } + } + + return rows.filter((row) => ( + row.kind !== "request_confirmation" + || newestByGroup.get(`${row.issueId}:${row.kind}`)?.id === row.id + )); +} + function budgetObservedPercent(amountObserved: number, amountLimit: number) { return amountLimit > 0 ? Math.round((amountObserved / amountLimit) * 10_000) / 100 : 0; } @@ -825,6 +895,94 @@ async function blockingIssueMap(db: Db, companyId: string, blockedIssueIds: Arra return map; } +type BlockedWorkEdge = { + fromIssueId: string | null; + issueId: string; +}; + +/** + * Counts open work held behind each blocker. The walk starts with explicit + * dependents, then follows both further dependency edges and issue children. + * Per-root visited sets make corrupt cycles harmless; the blocker analyzer's + * existing traversal caps bound unusually large graphs. + */ +async function blockedWorkCountMap(db: Db, companyId: string, blockerIssueIds: string[]) { + const rootIds = [...new Set(blockerIssueIds)]; + const seenByRoot = new Map(rootIds.map((rootId) => [rootId, new Set()])); + if (rootIds.length === 0) return new Map(); + + const loadEdges = async (fromIssueIds: string[], includeChildren: boolean) => { + const rows: BlockedWorkEdge[] = []; + for (const chunk of chunkValues(fromIssueIds, ATTENTION_GRAPH_QUERY_CHUNK_SIZE)) { + const dependentRowsPromise: Promise = db + .select({ + fromIssueId: issueRelations.issueId, + issueId: issues.id, + }) + .from(issueRelations) + .innerJoin(issues, eq(issueRelations.relatedIssueId, issues.id)) + .where(and( + eq(issueRelations.companyId, companyId), + eq(issueRelations.type, "blocks"), + inArray(issueRelations.issueId, chunk), + eq(issues.companyId, companyId), + isNull(issues.hiddenAt), + notInArray(issues.status, ["done", "cancelled"]), + )); + const childRowsPromise: Promise = includeChildren + ? db + .select({ + fromIssueId: issues.parentId, + issueId: issues.id, + }) + .from(issues) + .where(and( + eq(issues.companyId, companyId), + inArray(issues.parentId, chunk), + isNull(issues.hiddenAt), + notInArray(issues.status, ["done", "cancelled"]), + )) + : Promise.resolve([]); + const [dependentRows, childRows] = await Promise.all([dependentRowsPromise, childRowsPromise]); + rows.push(...dependentRows, ...childRows); + } + return rows; + }; + + let rootsByFrontierId = new Map>(); + for (const edge of await loadEdges(rootIds, false)) { + if (!edge.fromIssueId || edge.issueId === edge.fromIssueId) continue; + const seen = seenByRoot.get(edge.fromIssueId); + if (!seen || seen.size >= BLOCKER_ATTENTION_MAX_NODES || seen.has(edge.issueId)) continue; + seen.add(edge.issueId); + const roots = rootsByFrontierId.get(edge.issueId) ?? new Set(); + roots.add(edge.fromIssueId); + rootsByFrontierId.set(edge.issueId, roots); + } + + for (let depth = 1; rootsByFrontierId.size > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) { + const nextRootsByFrontierId = new Map>(); + const edges = await loadEdges([...rootsByFrontierId.keys()], true); + for (const edge of edges) { + if (!edge.fromIssueId) continue; + const roots = rootsByFrontierId.get(edge.fromIssueId); + if (!roots) continue; + for (const rootId of roots) { + if (edge.issueId === rootId) continue; + const seen = seenByRoot.get(rootId); + if (!seen || seen.size >= BLOCKER_ATTENTION_MAX_NODES || seen.has(edge.issueId)) continue; + seen.add(edge.issueId); + const nextRoots = nextRootsByFrontierId.get(edge.issueId) ?? new Set(); + nextRoots.add(rootId); + nextRootsByFrontierId.set(edge.issueId, nextRoots); + } + } + rootsByFrontierId = nextRootsByFrontierId; + } + + return new Map([...seenByRoot].map(([rootId, seen]) => [rootId, seen.size])); +} + /** * The task that blocks `issue` — never `issue` itself. * @@ -967,11 +1125,12 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions inArray(issueThreadInteractions.status, [...PENDING_INTERACTION_STATUSES]), )) .orderBy(desc(issueThreadInteractions.updatedAt), desc(issueThreadInteractions.id)); - const interactionIssueMap = await issueSummaryMap(db, companyId, interactionRows.map((row) => row.issueId)); - const interactionImageMap = await issueImageMap(db, companyId, interactionRows.map((row) => row.issueId)); - const interactionPlanDocumentMap = await planDocumentMap(db, companyId, interactionRows.map((row) => row.issueId)); + const visibleInteractionRows = collapsePendingConfirmationsToNewest(interactionRows); + const interactionIssueMap = await issueSummaryMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)); + const interactionImageMap = await issueImageMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)); + const interactionPlanDocumentMap = await planDocumentMap(db, companyId, visibleInteractionRows.map((row) => row.issueId)); - for (const interaction of interactionRows) { + for (const interaction of visibleInteractionRows) { const issue = interactionIssueMap.get(interaction.issueId) ?? null; const payload = readRecord(interaction.payload); const detail = interactionDetail({ @@ -1018,7 +1177,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions })); } - const openDecisions = await db.select({ + const openDecisionQuery = db.select({ id: decisions.id, bundleId: decisions.bundleId, originAgentId: decisions.originAgentId, @@ -1031,8 +1190,10 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions createdAt: decisions.createdAt, updatedAt: decisions.updatedAt, }).from(decisions).where(and(eq(decisions.companyId, companyId), eq(decisions.status, "open"))) - .orderBy(desc(decisions.updatedAt), desc(decisions.id)) - .limit(openDecisionLimit); + .orderBy(desc(decisions.updatedAt), desc(decisions.id)); + const openDecisions = options.all + ? await openDecisionQuery + : await openDecisionQuery.limit(openDecisionLimit); const decisionIssueMap = await issueSummaryMap(db, companyId, openDecisions.map((decision) => decision.originIssueId)); // Bundle titles let the feed render a single "Agent proposed N decisions" // group header over sibling decisions (v1 still decides each independently). @@ -1251,14 +1412,37 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions } const blockedIssues = await issueService(db).list(companyId, { status: "blocked", includeBlockedBy: true }); - const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id)); - const blockedImageMap = await issueImageMap(db, companyId, blockedIssues.map((issue) => issue.id)); - const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id)); - for (const issue of blockedIssues as Array) { + }; + const typedBlockedIssues = blockedIssues as BlockedAttentionIssue[]; + const terminalBlockerIssueIds = typedBlockedIssues + .map((issue) => issue.blockerAttention?.terminalBlockerIssueId) + .filter((issueId): issueId is string => Boolean(issueId)); + const blockedIssueSummaries = await issueSummaryMap(db, companyId, blockedIssues.map((issue) => issue.id)); + const terminalBlockerSummaries = await issueSummaryMap(db, companyId, terminalBlockerIssueIds); + const blockerImageMap = await issueImageMap( + db, + companyId, + [...blockedIssues.map((issue) => issue.id), ...terminalBlockerIssueIds], + ); + const blockingIssues = await blockingIssueMap(db, companyId, blockedIssues.map((issue) => issue.id)); + const terminalCandidates = new Map(); + + for (const issue of typedBlockedIssues) { const descriptor = issue.unblockDescriptor; const humanOwnerMatches = descriptor?.owner === "board" || (descriptor?.owner && "userId" in descriptor.owner && descriptor.owner.userId === options.userId); @@ -1286,44 +1470,62 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions detail: { kind: "blocker", blockingIssue: resolveBlockingIssue(issue, blockingIssues.get(issue.id)), - images: issueImages(blockedImageMap, issue.id), + images: issueImages(blockerImageMap, issue.id), }, })); } const blockerAttention = issue.blockerAttention; if (blockerAttention?.state !== "stalled" && blockerAttention?.state !== "needs_attention") continue; + if (blockerAttention.blockingTreeLive) continue; const issueSummary = blockedIssueSummaries.get(issue.id) ?? null; - const summarizedIssue = issueSummary ?? issue; - const sampledBlocker = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier; - const blockingIssue = resolveBlockingIssue(issue, blockingIssues.get(issue.id), sampledBlocker); - // The dedup key keeps its original fallback chain (including the issue's - // own identifier) on purpose: it is the stable identity a dismissal is - // recorded against, so narrowing it would resurrect dismissed rows. - const sample = sampledBlocker ?? issue.identifier ?? issue.id; - const dedupKey = `blocker:${issue.id}:${sample}`; + const terminalIssueId = blockerAttention.terminalBlockerIssueId ?? issue.id; + const terminalSummary = terminalBlockerSummaries.get(terminalIssueId) + ?? (terminalIssueId === issue.id ? issueSummary ?? issue : null); + if (!terminalSummary) continue; + const current = terminalCandidates.get(terminalIssueId); + if (!current || issue.updatedAt > current.issue.updatedAt) { + terminalCandidates.set(terminalIssueId, { + issue, + issueSummary, + terminalSummary, + state: blockerAttention.state, + }); + } + } + + const blockedWorkCounts = await blockedWorkCountMap(db, companyId, [...terminalCandidates.keys()]); + for (const [terminalIssueId, candidate] of terminalCandidates) { + const blockedTaskCount = blockedWorkCounts.get(terminalIssueId) ?? 0; + const taskLabel = blockedTaskCount === 1 ? "task" : "tasks"; + const dedupKey = `blocker:${terminalIssueId}`; add(createItem({ companyId, sourceKind: "blocker_attention", - subject: issueSubject(prefix, summarizedIssue), - whyNow: blockerAttention.state === "needs_attention" - ? "Blocked dependency chain needs human attention." - : "Blocked dependency chain is stalled and needs a human to choose the next owner or action.", + subject: issueSubject(prefix, candidate.terminalSummary), + whyNow: candidate.state === "needs_attention" + ? `Blocks ${blockedTaskCount} ${taskLabel} and needs human attention.` + : `Blocks ${blockedTaskCount} ${taskLabel}; choose the next owner or action.`, decisionVerbs: decisionVerbs( { id: "unblock", label: "Unblock", description: "Repair or replace the stalled blocker path." }, { id: "reassign", label: "Reassign", description: "Assign the stalled blocker to a live owner." }, { id: "nudge", label: "Nudge", description: "Wake or prompt the current owner." }, ), inlineResolvable: false, - entryRule: `blocked issue has blockerAttention.state = '${blockerAttention.state}'`, - exitRule: "Blocker chain is no longer stalled or the issue leaves blocked status.", + entryRule: `terminal blocker has a non-live blockerAttention.state = '${candidate.state}'`, + exitRule: "The blocking tree becomes live or no open work remains blocked.", dedupKey, severity: "high", - activityAt: toIso(issue.updatedAt), - createdAt: toIso(issue.createdAt), - updatedAt: toIso(issue.updatedAt), - relatedIssue: null, - ...issueContext(issueSummary), - detail: { kind: "blocker", blockingIssue, images: issueImages(blockedImageMap, issue.id) }, + activityAt: toIso(candidate.terminalSummary.updatedAt), + createdAt: toIso(candidate.terminalSummary.createdAt), + updatedAt: toIso(candidate.terminalSummary.updatedAt), + relatedIssue: candidate.issueSummary ? issueSubject(prefix, candidate.issueSummary) : null, + ...issueContext(candidate.terminalSummary), + detail: { + kind: "blocker", + blockingIssue: null, + blockedTaskCount, + images: issueImages(blockerImageMap, terminalIssueId), + }, })); } @@ -1653,10 +1855,13 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions const sort = options.sort ?? "activity"; if (sort !== "activity" && sort !== "decide") throw badRequest("sort must be 'activity' or 'decide'"); - const rankedItems = visibleItems - .sort(sort === "decide" - ? (left, right) => compareDecideItems(left, right, now) - : compareAttentionItems) + const selectedComparator = sort === "decide" + ? (left: AttentionItem, right: AttentionItem) => compareDecideItems(left, right, now) + : compareAttentionItems; + const rankedItems = orderBlockedAttentionByWeight( + visibleItems.sort(selectedComparator), + selectedComparator, + ) .map((item, index) => ({ ...item, rank: index + 1 })); let items: AttentionItem[]; let nextCursor: string | null; @@ -1726,7 +1931,11 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions companyId, generatedAt: new Date().toISOString(), totalCount: rankedItems.length, - decideNowCount: rankedItems.filter((item) => isDecideNow(item, now)).length, + // Desk badge: distinct items that surfaced + // today OR carry an explicit decide-by deadline due today/past. Counted + // over the full ranked set (pre-pagination) so the sidebar badge stays + // company-wide accurate even on a small first page. + deskBadgeCount: rankedItems.filter((item) => isNewToday(item, now) || isDecideNow(item, now)).length, nextCursor, countsBySourceKind, items, diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 5c7820b34f..c37fbb1800 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from "node:util"; -import { and, asc, eq, inArray, isNotNull, isNull } from "drizzle-orm"; +import { and, asc, desc, eq, inArray, isNotNull, isNull, ne } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, @@ -348,6 +348,14 @@ function buildStaleTargetResult( } as const; } +function buildSupersededByNewerRequestResult(replacementInteractionId: string) { + return { + version: 1, + outcome: "superseded_by_newer_request", + supersededByInteractionId: replacementInteractionId, + } as const; +} + function buildAdministrativeOutcomeResult( row: IssueThreadInteractionRow, outcome: "withdrawn" | "issue_closed", @@ -1207,6 +1215,83 @@ export function issueThreadInteractionService(db: Db) { return row ? hydrateInteraction(row) : null; }, + sweepSupersededPendingRequestConfirmations: async () => { + const rows = await db + .select() + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.kind, "request_confirmation"), + eq(issueThreadInteractions.status, "pending"), + isNotNull(issueThreadInteractions.createdByAgentId), + )) + .orderBy( + asc(issueThreadInteractions.companyId), + asc(issueThreadInteractions.issueId), + asc(issueThreadInteractions.kind), + asc(issueThreadInteractions.createdByAgentId), + desc(issueThreadInteractions.createdAt), + desc(issueThreadInteractions.id), + ); + + const newestByGroup = new Map(); + const supersededRows: Array<{ + row: IssueThreadInteractionRow; + replacementInteractionId: string; + }> = []; + for (const row of rows) { + if (!row.createdByAgentId) continue; + const groupKey = `${row.companyId}:${row.issueId}:${row.kind}:${row.createdByAgentId}`; + const newest = newestByGroup.get(groupKey); + if (!newest) { + newestByGroup.set(groupKey, row); + continue; + } + supersededRows.push({ row, replacementInteractionId: newest.id }); + } + + if (supersededRows.length === 0) return { expired: 0 }; + + const now = new Date(); + const expired: IssueThreadInteraction[] = []; + for (const { row, replacementInteractionId } of supersededRows) { + const updated = await db.transaction(async (tx) => { + const [updatedRow] = await tx + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByNewerRequestResult(replacementInteractionId), + resolvedByAgentId: null, + resolvedByUserId: null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.id, row.id), + eq(issueThreadInteractions.status, "pending"), + )) + .returning(); + if (!updatedRow) return null; + await resolveLinkedToolActionRequests(tx, updatedRow, { + status: "expired", + fromStatuses: ["pending", "approved"], + actor: {}, + now, + }); + return updatedRow; + }); + if (!updated) continue; + expired.push(hydrateInteraction(updated)); + } + + if (expired.length > 0) { + for (const issueId of new Set(expired.map((interaction) => interaction.issueId))) { + await touchIssue(db, issueId); + } + await emitResolvedInteractionsTelemetry(db, expired); + } + return { expired: expired.length }; + }, + create: async ( issue: { id: string; companyId: string }, input: CreateIssueThreadInteraction, @@ -1270,20 +1355,19 @@ export function issueThreadInteractionService(db: Db) { } let created: IssueThreadInteractionRow; + let superseded: IssueThreadInteractionRow[] = []; try { - // A terminal issue must not regain pending actionable cards. FOR SHARE - // on the issue row serializes this insert against the terminal status - // transition's row lock: either the close committed first and this - // read rejects the create, or the insert commits before the close - // proceeds and the close's expiry sweep collects the new row. + // A terminal issue must not regain pending actionable cards. FOR UPDATE + // on the issue row serializes this insert both against terminal status + // transitions and against concurrent confirmations on the same issue. // Idempotent reuse above stays allowed so retries of a pre-close // create keep returning the (by now expired) original. - created = await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const [issueRow] = await tx .select({ status: issues.status }) .from(issues) .where(and(eq(issues.id, issue.id), eq(issues.companyId, issue.companyId))) - .for("share"); + .for("update"); if (!issueRow || isTerminalIssueStatus(issueRow.status)) { throw conflict("Cannot create an interaction on a closed issue"); } @@ -1305,8 +1389,43 @@ export function issueThreadInteractionService(db: Db) { payload: data.payload, }) .returning(); - return row; + + if (data.kind !== "request_confirmation" || !actor.agentId) { + return { row, supersededRows: [] }; + } + + const now = new Date(); + const supersededRows = await tx + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByNewerRequestResult(row.id), + resolvedByAgentId: actor.agentId, + resolvedByUserId: actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.companyId, issue.companyId), + eq(issueThreadInteractions.issueId, issue.id), + eq(issueThreadInteractions.kind, data.kind), + eq(issueThreadInteractions.createdByAgentId, actor.agentId), + eq(issueThreadInteractions.status, "pending"), + ne(issueThreadInteractions.id, row.id), + )) + .returning(); + for (const supersededRow of supersededRows) { + await resolveLinkedToolActionRequests(tx, supersededRow, { + status: "expired", + fromStatuses: ["pending", "approved"], + actor, + now, + }); + } + return { row, supersededRows }; }); + created = result.row; + superseded = result.supersededRows; } catch (error) { if (!data.idempotencyKey || !isIssueThreadInteractionIdempotencyConflict(error)) { throw error; @@ -1326,6 +1445,9 @@ export function issueThreadInteractionService(db: Db) { } await touchIssue(db, issue.id); + if (superseded.length > 0) { + await emitResolvedInteractionsTelemetry(db, superseded.map(hydrateInteraction)); + } return hydrateInteraction(created); }, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b42d8382b9..5793487f25 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1879,8 +1879,8 @@ function lowTrustBoundaryIssueCondition( } const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"]; -const BLOCKER_ATTENTION_MAX_DEPTH = 8; -const BLOCKER_ATTENTION_MAX_NODES = 2000; +export const BLOCKER_ATTENTION_MAX_DEPTH = 8; +export const BLOCKER_ATTENTION_MAX_NODES = 2000; const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]); type IssueBlockerAttentionNode = { @@ -2049,6 +2049,8 @@ function createIssueBlockerAttention(input: Partial = {}) pendingFinalizeBlockerIssueIds: input.pendingFinalizeBlockerIssueIds ?? [], sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null, sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null, + blockingTreeLive: input.blockingTreeLive ?? false, + terminalBlockerIssueId: input.terminalBlockerIssueId ?? null, }; } @@ -2534,6 +2536,7 @@ async function listIssueBlockerAttentionMap( stalled: boolean; sampleBlockerIdentifier: string | null; sampleStalledBlockerIdentifier: string | null; + terminalBlockerIssueId?: string | null; }; const classifyPath = ( nodeId: string, @@ -2562,16 +2565,34 @@ async function listIssueBlockerAttentionMap( if (hasWaitingPath) { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } - return { covered: false, stalled: true, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: nodeSample }; + return { + covered: false, + stalled: true, + sampleBlockerIdentifier: nodeSample, + sampleStalledBlockerIdentifier: nodeSample, + terminalBlockerIssueId: node.id, + }; } if (activeIssueIds.has(node.id)) { return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; } if (node.status === "cancelled") { - return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; + return { + covered: false, + stalled: false, + sampleBlockerIdentifier: nodeSample, + sampleStalledBlockerIdentifier: null, + terminalBlockerIssueId: node.id, + }; } if (node.status === "backlog" && node.assigneeAgentId) { - return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; + return { + covered: false, + stalled: false, + sampleBlockerIdentifier: nodeSample, + sampleStalledBlockerIdentifier: null, + terminalBlockerIssueId: node.id, + }; } const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => { @@ -2584,13 +2605,16 @@ async function listIssueBlockerAttentionMap( const classified = downstream.map((edge) => classifyPath(edge.blockerIssueId, nextSeen)); const stalledChild = classified.find((result) => result.stalled || result.sampleStalledBlockerIdentifier); const sampleStalled = stalledChild?.sampleStalledBlockerIdentifier ?? null; - const hardAttention = classified.find((result) => !result.covered && !result.stalled); + const hardAttention = classified.find((result) => + !result.covered && !result.stalled && result.terminalBlockerIssueId + ) ?? classified.find((result) => !result.covered && !result.stalled); if (hardAttention) { return { covered: false, stalled: false, sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier, sampleStalledBlockerIdentifier: sampleStalled, + terminalBlockerIssueId: hardAttention.terminalBlockerIssueId ?? null, }; } const stalledEntry = classified.find((result) => result.stalled); @@ -2600,6 +2624,7 @@ async function listIssueBlockerAttentionMap( stalled: true, sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier, sampleStalledBlockerIdentifier: sampleStalled, + terminalBlockerIssueId: stalledEntry.terminalBlockerIssueId ?? null, }; } return { @@ -2613,11 +2638,46 @@ async function listIssueBlockerAttentionMap( if (node.assigneeAgentId) { const assignee = agentsById.get(node.assigneeAgentId); if (!assignee || assignee.companyId !== companyId || !BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)) { - return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; + return { + covered: false, + stalled: false, + sampleBlockerIdentifier: nodeSample, + sampleStalledBlockerIdentifier: null, + terminalBlockerIssueId: node.id, + }; } } - return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null }; + return { + covered: false, + stalled: false, + sampleBlockerIdentifier: nodeSample, + sampleStalledBlockerIdentifier: null, + terminalBlockerIssueId: node.id, + }; + }; + + const pathHasLiveWork = (nodeId: string, seen: Set): boolean => { + if (seen.has(nodeId)) return false; + const node = nodesById.get(nodeId); + if (!node || node.companyId !== companyId) return false; + if (node.status === "in_progress" || activeIssueIds.has(node.id)) return true; + + const nextSeen = new Set(seen); + nextSeen.add(nodeId); + return (edgesByIssueId.get(node.id) ?? []).some((edge) => { + const blocker = nodesById.get(edge.blockerIssueId); + if (blocker?.status === "done" && !pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId)) return false; + return pathHasLiveWork(edge.blockerIssueId, nextSeen); + }); + }; + + const issueIdForSample = (sample: string | null | undefined) => { + if (!sample) return null; + for (const node of nodesById.values()) { + if (node.id === sample || node.identifier === sample) return node.id; + } + return null; }; for (const root of roots) { @@ -2629,6 +2689,7 @@ async function listIssueBlockerAttentionMap( attentionMap.set(root.id, createIssueBlockerAttention({ state: "needs_attention", reason: "attention_required", + terminalBlockerIssueId: root.id, })); continue; } @@ -2640,13 +2701,19 @@ async function listIssueBlockerAttentionMap( const coveredBlockerCount = classified.filter((entry) => entry.result.covered).length; const stalledBlockerCount = classified.filter((entry) => entry.result.stalled).length; const attentionBlockerCount = classified.length - coveredBlockerCount - stalledBlockerCount; - const hardAttentionEntry = classified.find((entry) => !entry.result.covered && !entry.result.stalled); - const stalledEntry = classified.find((entry) => entry.result.stalled); + const hardAttentionEntry = classified.find((entry) => + !entry.result.covered && !entry.result.stalled && entry.result.terminalBlockerIssueId + ) ?? classified.find((entry) => !entry.result.covered && !entry.result.stalled); + const stalledEntry = classified.find((entry) => entry.result.stalled && entry.result.terminalBlockerIssueId) + ?? classified.find((entry) => entry.result.stalled); const sampleEntry = hardAttentionEntry ?? stalledEntry ?? classified[0] ?? null; const sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null; const sampleStalledFromChain = classified .map((entry) => entry.result.sampleStalledBlockerIdentifier) .find((value) => value); + const sampledTerminalIdentifier = sampleEntry?.result.stalled + ? sampleEntry.result.sampleStalledBlockerIdentifier ?? sampleEntry.result.sampleBlockerIdentifier + : sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode); let state: IssueBlockerAttention["state"]; let reason: IssueBlockerAttention["reason"]; @@ -2676,6 +2743,9 @@ async function listIssueBlockerAttentionMap( sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode), sampleStalledBlockerIdentifier: stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null, + blockingTreeLive: topLevelEdges.some((edge) => pathHasLiveWork(edge.blockerIssueId, new Set([root.id]))), + terminalBlockerIssueId: + sampleEntry?.result.terminalBlockerIssueId ?? issueIdForSample(sampledTerminalIdentifier), })); } diff --git a/ui/src/components/AttentionQueueRow.test.tsx b/ui/src/components/AttentionQueueRow.test.tsx index 24674ac5e9..554f598bfb 100644 --- a/ui/src/components/AttentionQueueRow.test.tsx +++ b/ui/src/components/AttentionQueueRow.test.tsx @@ -731,12 +731,13 @@ describe("AttentionQueueRow", () => { }); } - // Training moved off the header strip into the row's overflow menu, so the - // header carries only recency + overflow. Menu items live in a portal that + // Training lives in the row's overflow menu AND on a visible inline "Train" + // pill for untrained rows. Menu items live in a portal that // only mounts once opened — environment-flaky in jsdom (see the dismiss test - // above) — so the untrained path asserts the menu exists and no badge is - // shown, and the onTrain contract is exercised through the inline badge. - it("offers training through the row menu and shows no badge until trained", () => { + // above) — so the untrained path asserts the menu exists, no trained badge is + // shown, and the onTrain contract is exercised through the visible pill. + it("shows a visible Train pill (no trained badge) and fires onTrain when clicked", () => { + const onTrain = vi.fn(); render( { expanded={false} onToggleExpand={noop} onDismiss={noop} - onTrain={noop} + onTrain={onTrain} />, ); expect(container?.querySelector('[aria-label="Row actions"]')).toBeTruthy(); expect(container?.querySelector('[data-testid="attention-trained-badge"]')).toBeNull(); + const trainPill = container?.querySelector('[data-testid="attention-train-inline"]'); + expect(trainPill?.textContent).toContain("Train"); + act(() => trainPill?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(onTrain).toHaveBeenCalledWith(expect.objectContaining({ id: "a1" })); + }); + + it("hides the visible Train pill once trained (the badge stands in for it)", () => { + render( + , + ); + expect(container?.querySelector('[data-testid="attention-train-inline"]')).toBeNull(); + expect(container?.querySelector('[data-testid="attention-trained-badge"]')).toBeTruthy(); }); it("renders a Trained ✓ badge once trained and fires onTrain when it is clicked", () => { diff --git a/ui/src/components/AttentionQueueRow.tsx b/ui/src/components/AttentionQueueRow.tsx index d4eb60e83d..788131df79 100644 --- a/ui/src/components/AttentionQueueRow.tsx +++ b/ui/src/components/AttentionQueueRow.tsx @@ -297,6 +297,24 @@ export const AttentionQueueRow = memo(function AttentionQueueRow({ Trained ✓ )} + {/* Visible train affordance for untrained rows. Trained + rows already carry the "Trained ✓" badge above; both surfaces also + keep the overflow "Train this decision" entry. Sits in the same slot + as the badge so a row's training state reads from one place. */} + {trainable && !trained && ( + + )}
diff --git a/ui/src/components/DecisionShelf.tsx b/ui/src/components/DecisionShelf.tsx new file mode 100644 index 0000000000..a5a05807dd --- /dev/null +++ b/ui/src/components/DecisionShelf.tsx @@ -0,0 +1,126 @@ +import { type ReactNode } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2, Sun } from "lucide-react"; +import type { Agent, AttentionItem } from "@paperclipai/shared"; +import { decisionQueuesApi } from "../api/decisionQueues"; +import { useToastActions } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { attentionIdleDays } from "../lib/attention"; +import { AttentionQueueRow } from "./AttentionQueueRow"; +import { IssueGroupHeader } from "./IssueGroupHeader"; +import { Button } from "./ui/button"; + +/** + * A collapsible shelf header + body (snoozed / dismissed / aging / decided / + * expired). Shared by the desk and the per-queue page so both collapse the same + * way across both decision surfaces. + */ +export function Curtain({ + label, + count, + open, + onToggle, + children, +}: { + label: string; + count?: number | string; + open: boolean; + onToggle: () => void; + children: ReactNode; +}) { + return ( +
+ + {open &&
{children}
} +
+ ); +} + +/** + * An aging-shelf row (§4.4): the standard card, prefaced by an idle-duration + * label and a "Keep on desk" affordance that clears the shelf flag server-side + * (P1 retention `keep`). Shared by the desk and the per-queue page. + */ +export function AgingItemRow({ + item, + companyId, + now, + agentMap, + agents, + currentUserId, + expanded, + onToggleExpand, + onDismiss, + onSnooze, + onTrain, +}: { + item: AttentionItem; + companyId: string; + now: number; + agentMap: Map; + agents: Agent[] | undefined; + currentUserId: string | null; + expanded: boolean; + onToggleExpand: (item: AttentionItem) => void; + onDismiss: (item: AttentionItem) => void; + onSnooze: (item: AttentionItem, snoozedUntil: string) => void; + onTrain: (item: AttentionItem) => void; +}) { + const queryClient = useQueryClient(); + const { pushToast } = useToastActions(); + const idleDays = attentionIdleDays(item, now); + const keep = useMutation({ + mutationFn: () => decisionQueuesApi.setKeep(companyId, item.sourceKind, item.subject.id, true), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); + pushToast({ title: "Kept on desk", body: item.subject.title ?? undefined, tone: "success" }); + }, + onError: (error) => + pushToast({ + title: "Could not keep this decision", + body: error instanceof Error ? error.message : "Please try again.", + tone: "error", + }), + }); + + return ( +
+
+ + Idle {idleDays} {idleDays === 1 ? "day" : "days"} + + +
+ +
+ ); +} diff --git a/ui/src/components/DecisionTriageStrip.tsx b/ui/src/components/DecisionTriageStrip.tsx index 8eb7137a6d..1fb7d9d7e1 100644 --- a/ui/src/components/DecisionTriageStrip.tsx +++ b/ui/src/components/DecisionTriageStrip.tsx @@ -132,9 +132,9 @@ export function DecisionTriageStrip({ item, companyId, agents }: DecisionTriageS }, onSuccess: (_result, agent) => { invalidate(); - pushToast({ title: `Routed to ${agent.name}`, tone: "success" }); + pushToast({ title: `Asked ${agent.name} for a recommendation`, tone: "success" }); }, - onError: onError("route to agent"), + onError: onError("ask that agent for a recommendation"), }); const pending = setDecideBy.isPending || setSnooze.isPending || addToQueue.isPending || removeFromQueue.isPending; @@ -261,10 +261,10 @@ export function DecisionTriageStrip({ item, companyId, agents }: DecisionTriageS )} - routeToAgent.mutate(agent)} /> {pending && } @@ -418,7 +418,13 @@ function DropdownMenuSeparatorLike() { return
; } -function RouteToAgentPicker({ +/** + * "Ask agent for recommendation" — posts a mention-comment on the linked task + * asking the agent to prepare a recommendation and re-surface the decision. It + * does not reassign the task, so the label says exactly what it does + * (Previously labeled "Route to agent".) + */ +function AskAgentPicker({ agents, disabled, disabledReason, @@ -442,7 +448,7 @@ function RouteToAgentPicker({ title={disabledReason} > - Route to agent + Ask agent for recommendation diff --git a/ui/src/components/DecisionsToolbar.tsx b/ui/src/components/DecisionsToolbar.tsx new file mode 100644 index 0000000000..6fb00a619b --- /dev/null +++ b/ui/src/components/DecisionsToolbar.tsx @@ -0,0 +1,295 @@ +import { type ReactNode } from "react"; +import { ArrowUpDown, Check, GraduationCap, Layers, ListFilter } from "lucide-react"; +import { + ATTENTION_GROUP_BY_OPTIONS, + ATTENTION_SORT_OPTIONS, + buildAttentionFilterOptions, + countActiveAttentionFilters, + defaultAttentionFilterState, + NO_GROUP_SENTINEL, + sourceMeta, + type AttentionFilterState, + type AttentionGroupBy, + type AttentionSortOrder, +} from "../lib/attention"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; + +const SEVERITY_LABELS: Record = { + critical: "Critical", + high: "High", + medium: "Medium", + low: "Low", +}; + +interface DecisionsToolbarProps { + /** Number of decisions currently shown, for the count pill. */ + visibleCount: number; + filterOptions: ReturnType; + filters: AttentionFilterState; + onFiltersChange: (next: AttentionFilterState) => void; + groupBy: AttentionGroupBy; + onGroupByChange: (next: AttentionGroupBy) => void; + sortOrder: AttentionSortOrder; + onSortOrderChange: (next: AttentionSortOrder) => void; + /** Open the decision-training index. */ + onOpenTraining: () => void; +} + +/** + * The decisions filter / group / sort / training toolbar, shared verbatim by the + * desk (WhatNeedsMe) and the per-queue page so both surfaces expose an identical + * control set. All state lives in the parent; this is presentation + * plus the filter popover only. + */ +export function DecisionsToolbar({ + visibleCount, + filterOptions, + filters, + onFiltersChange, + groupBy, + onGroupByChange, + sortOrder, + onSortOrderChange, + onOpenTraining, +}: DecisionsToolbarProps) { + const activeFilterCount = countActiveAttentionFilters(filters); + return ( +
+ {visibleCount > 0 && ( + + {visibleCount} {visibleCount === 1 ? "decision" : "decisions"} + + )} + {/* Filter */} + + + + + + + + + {/* Group by */} + + + + + +
+ {ATTENTION_GROUP_BY_OPTIONS.map(([value, label]) => ( + + ))} +
+
+
+ + {/* Sort */} + + + + + +
+ {ATTENTION_SORT_OPTIONS.map(([value, label]) => ( + + ))} +
+
+
+
+ ); +} + +function FilterMenu({ + options, + filters, + onChange, +}: { + options: ReturnType; + filters: AttentionFilterState; + onChange: (next: AttentionFilterState) => void; +}) { + const toggle = (key: keyof AttentionFilterState, value: string) => { + const list = filters[key] as string[]; + const nextList = list.includes(value) ? list.filter((v) => v !== value) : [...list, value]; + onChange({ ...filters, [key]: nextList }); + }; + const hasActive = countActiveAttentionFilters(filters) > 0; + + return ( +
+
+ Filter + {hasActive && ( + + )} +
+ + {options.sourceKinds.length > 1 && ( + + {options.sourceKinds.map((kind) => ( + toggle("sourceKinds", kind)} + /> + ))} + + )} + + {options.severities.length > 1 && ( + + {options.severities.map((severity) => ( + toggle("severities", severity)} + /> + ))} + + )} + + {(options.projects.length > 0 || options.hasNoProject) && ( + + {options.projects.map((project) => ( + toggle("projectIds", project.id)} + /> + ))} + {options.hasNoProject && ( + toggle("projectIds", NO_GROUP_SENTINEL)} + /> + )} + + )} + + {(options.workspaces.length > 0 || options.hasNoWorkspace) && ( + + {options.workspaces.map((workspace) => ( + toggle("workspaceIds", workspace.id)} + /> + ))} + {options.hasNoWorkspace && ( + toggle("workspaceIds", NO_GROUP_SENTINEL)} + /> + )} + + )} +
+ ); +} + +function FilterSection({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +function FilterRow({ + label, + checked, + onToggle, +}: { + label: string; + checked: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/ui/src/lib/attention.test.ts b/ui/src/lib/attention.test.ts index 0b24bd19b6..88cc3ec595 100644 --- a/ui/src/lib/attention.test.ts +++ b/ui/src/lib/attention.test.ts @@ -6,10 +6,12 @@ import { attentionBadgeCount, attentionDateBucket, attentionDetailLine, + attentionIsNewToday, attentionKind, attentionStatus, attentionTaskRef, buildAttentionFilterOptions, + buildDeskShelves, countActiveAttentionFilters, defaultAttentionFilterState, filterAttentionItems, @@ -106,12 +108,12 @@ describe("isInlineResolvable", () => { }); describe("attentionBadgeCount", () => { - it("uses the server's pre-pagination decide-now count", () => { + it("uses the server's pre-pagination desk badge count", () => { const feed: AttentionFeed = { companyId: "c1", generatedAt: "2026-07-09T12:00:00Z", totalCount: 3, - decideNowCount: 2, + deskBadgeCount: 2, nextCursor: "next-page", countsBySourceKind: {} as AttentionFeed["countsBySourceKind"], items: [buildItem({ id: "1" }), buildItem({ id: "2" }), buildItem({ id: "3" })], @@ -125,6 +127,59 @@ describe("attentionBadgeCount", () => { }); }); +// Desk grouping — arrival-based ("New today" / "Earlier") with a "Decide now" +// shelf only when an explicit decide-by deadline is due. +describe("buildDeskShelves", () => { + const NOW = Date.parse("2026-07-09T12:00:00Z"); + const todayIso = "2026-07-09T09:00:00Z"; + const earlierIso = "2026-07-01T09:00:00Z"; + + it("groups by arrival with no shelf when nothing has a due deadline", () => { + const items = [ + buildItem({ id: "new-1", createdAt: todayIso }), + buildItem({ id: "old-1", createdAt: earlierIso }), + buildItem({ id: "new-2", createdAt: "2026-07-09T02:00:00Z" }), + ]; + const shelves = buildDeskShelves(items, NOW); + expect(shelves.map((s) => s.key)).toEqual(["desk:new-today", "desk:earlier"]); + expect(shelves[0]!.label).toBe("New today"); + expect(shelves[0]!.items.map((i) => i.id).sort()).toEqual(["new-1", "new-2"]); + expect(shelves[1]!.items.map((i) => i.id)).toEqual(["old-1"]); + }); + + it("adds the 'Decide now' shelf only for items with a due decide-by, and never double-buckets them", () => { + const items = [ + buildItem({ id: "due", decideBy: "today", createdAt: todayIso }), + buildItem({ id: "overdue", decideBy: "2026-07-01", createdAt: earlierIso }), + buildItem({ id: "new", createdAt: "2026-07-09T05:00:00Z" }), + buildItem({ id: "old", createdAt: earlierIso }), + buildItem({ id: "whenever", decideBy: "whenever", createdAt: "2026-07-09T11:00:00Z" }), + ]; + const shelves = buildDeskShelves(items, NOW); + expect(shelves.map((s) => s.key)).toEqual(["desk:decide-now", "desk:new-today", "desk:earlier"]); + // Decide-now items are pulled out of the arrival groups (disjoint shelves). + expect(shelves[0]!.items.map((i) => i.id)).toEqual(["overdue", "due"]); + // "New today" is newest-arrival-first: whenever (11:00) before new (05:00). + expect(shelves[1]!.items.map((i) => i.id)).toEqual(["whenever", "new"]); + expect(shelves[2]!.items.map((i) => i.id)).toEqual(["old"]); + // Every item lands in exactly one shelf. + const total = shelves.reduce((n, s) => n + s.items.length, 0); + expect(total).toBe(items.length); + }); + + it("returns no shelves for an empty desk", () => { + expect(buildDeskShelves([], NOW)).toEqual([]); + }); +}); + +describe("attentionIsNewToday", () => { + const NOW = Date.parse("2026-07-09T12:00:00Z"); + it("is true when the item surfaced on the current UTC day", () => { + expect(attentionIsNewToday(buildItem({ createdAt: "2026-07-09T00:00:01Z" }), NOW)).toBe(true); + expect(attentionIsNewToday(buildItem({ createdAt: "2026-07-08T23:59:59Z" }), NOW)).toBe(false); + }); +}); + describe("sourceMeta + severityStyle", () => { it("labels every catalog source kind", () => { const kinds: AttentionSourceKind[] = [ diff --git a/ui/src/lib/attention.ts b/ui/src/lib/attention.ts index 767df0df98..c01dd597cf 100644 --- a/ui/src/lib/attention.ts +++ b/ui/src/lib/attention.ts @@ -255,23 +255,29 @@ export function attentionImageUrl(assetId: string): string { } /** - * The sidebar intentionally reflects only items whose decide-by deadline is - * due now. The count is computed before pagination, so badge polling can fetch - * a small first page without losing the company-wide urgency signal. + * 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. */ export function attentionBadgeCount(feed: AttentionFeed | null | undefined): number { - return feed?.decideNowCount ?? 0; + return feed?.deskBadgeCount ?? 0; } // --------------------------------------------------------------------------- -// Decide-by / today's desk (PAP-16032 §4.3) +// Today's desk // -// The desk splits items into "Decide now" and "Can wait" and orders them by a -// per-item `decideBy` field. The server owns the authoritative ranking -// (`sort=decide`) and the badge's `decideNowCount`; these client helpers mirror -// that logic *exactly* (same UTC day/week boundaries) so the on-page split and -// the badge never disagree. Keep in lockstep with -// `server/src/services/attention.ts` (`decideOrder`/`isDecideNow`). +// The default ungrouped desk reflects *what came up*, not a judgement about +// what "can wait". It builds up to three shelves in order: +// • "Decide now" — only when an item has an explicit decide-by deadline that +// 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`). // --------------------------------------------------------------------------- const MS_PER_DAY_DECIDE = 24 * 60 * 60 * 1_000; @@ -304,24 +310,67 @@ export function attentionDecideOrder(item: AttentionItem, now: number): [number, return [2, Number.MAX_SAFE_INTEGER]; } -/** Due today or overdue — the "Decide now" shelf, and what the badge counts. */ +/** Due today or overdue — the "Decide now" shelf. Only fires when `decideBy` is set. */ export function attentionIsDecideNow(item: AttentionItem, now: number): boolean { const [bucket, deadline] = attentionDecideOrder(item, now); return bucket === 0 && deadline <= endOfUtcDay(now); } -/** Split the desk into its two shelves, preserving input order within each. */ -export function partitionDecideNow( - items: AttentionItem[], - now: number, -): { decideNow: AttentionItem[]; canWait: AttentionItem[] } { - const decideNow: AttentionItem[] = []; - const canWait: AttentionItem[] = []; - for (const item of items) { - if (attentionIsDecideNow(item, now)) decideNow.push(item); - else canWait.push(item); - } - return { decideNow, canWait }; +/** Surfaced today (arrival) — the "New today" desk group. Uses UTC day, matching the badge. */ +export function attentionIsNewToday(item: AttentionItem, now: number): boolean { + const ts = new Date(item.createdAt).getTime(); + return Number.isFinite(ts) && ts >= startOfUtcDay(now); +} + +/** A rendered desk shelf: shares the shape of {@link AttentionGroup}. */ +export interface DeskShelf { + key: string; + label: string; + items: AttentionItem[]; +} + +/** + * Build the default (ungrouped) desk layout — the arrival-based grouping that + * replaced the "Decide now" / "Can wait" split: + * + * • "Decide now" — items with an explicit decide-by deadline due today/past, + * ordered by deadline. Omitted entirely when nothing has a due deadline, so + * the desk never leads with a shelf built on unset metadata. + * • "New today" — remaining items that surfaced today, newest arrival first. + * • "Earlier" — remaining older arrivals, newest arrival first. + * + * A decide-now item is only ever on the "Decide now" shelf, so the three shelves + * are disjoint and their sizes sum to `items.length`. + */ +export function buildDeskShelves(items: AttentionItem[], now: number): DeskShelf[] { + const decideNow = items + .filter((item) => attentionIsDecideNow(item, now)) + .sort((a, b) => { + const [, aDeadline] = attentionDecideOrder(a, now); + const [, bDeadline] = attentionDecideOrder(b, now); + if (aDeadline !== bDeadline) return aDeadline - bDeadline; + return a.rank - b.rank; + }); + const rest = items + .filter((item) => !attentionIsDecideNow(item, now)) + .sort((a, b) => { + const diff = attentionArrivalTimestamp(b) - attentionArrivalTimestamp(a); + if (diff !== 0) return diff; + return a.rank - b.rank; + }); + const newToday = rest.filter((item) => attentionIsNewToday(item, now)); + const earlier = rest.filter((item) => !attentionIsNewToday(item, now)); + + const shelves: DeskShelf[] = []; + if (decideNow.length > 0) shelves.push({ key: "desk:decide-now", label: "Decide now", items: decideNow }); + if (newToday.length > 0) shelves.push({ key: "desk:new-today", label: "New today", items: newToday }); + if (earlier.length > 0) shelves.push({ key: "desk:earlier", label: "Earlier", items: earlier }); + return shelves; +} + +function attentionArrivalTimestamp(item: AttentionItem): number { + const ts = new Date(item.createdAt).getTime(); + return Number.isFinite(ts) ? ts : 0; } // --------------------------------------------------------------------------- diff --git a/ui/src/pages/DecisionQueuePage.tsx b/ui/src/pages/DecisionQueuePage.tsx index 016944e2d2..673ffe1f64 100644 --- a/ui/src/pages/DecisionQueuePage.tsx +++ b/ui/src/pages/DecisionQueuePage.tsx @@ -1,8 +1,8 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Settings2, X } from "lucide-react"; import type { Agent, AttentionItem } from "@paperclipai/shared"; -import { useParams } from "@/lib/router"; +import { useNavigate, useParams } from "@/lib/router"; import { attentionApi } from "../api/attention"; import { agentsApi } from "../api/agents"; import { authApi } from "../api/auth"; @@ -12,29 +12,84 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { useInboxDismissals } from "../hooks/useInboxBadge"; import { queryKeys } from "../lib/queryKeys"; +import { + ATTENTION_AGING_DAYS, + attentionIsAging, + buildAttentionFilterOptions, + buildDeskShelves, + defaultAttentionFilterState, + filterAttentionItems, + groupAttentionItems, + loadAttentionFilters, + loadAttentionGroupBy, + loadAttentionSortOrder, + loadCollapsedAttentionGroupKeys, + resolveAttentionDateRange, + saveAttentionFilters, + saveAttentionGroupBy, + saveAttentionSortOrder, + saveCollapsedAttentionGroupKeys, + sortAttentionItems, + type AttentionDateRangeId, + type AttentionFilterState, + type AttentionGroup, + type AttentionGroupBy, + type AttentionSortOrder, +} from "../lib/attention"; +import { decisionTrainingHref } from "../lib/decisionTraining"; import { cn } from "../lib/utils"; import { PageSkeleton } from "../components/PageSkeleton"; import { AttentionQueueRow } from "../components/AttentionQueueRow"; +import { DecisionsToolbar } from "../components/DecisionsToolbar"; +import { Curtain, AgingItemRow } from "../components/DecisionShelf"; import { DecisionQueueRail } from "../components/DecisionQueueRail"; +import { DecisionDateChips, type AttentionCustomRange } from "../components/DecisionDateChips"; +import { DecisionTrainingDrawer } from "../components/DecisionTrainingDrawer"; +import { IssueGroupHeader } from "../components/IssueGroupHeader"; import { Button } from "../components/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover"; + /** - * Queue page (PAP-16032 §4.1 / wireframe screen 2). A homogeneous list of one - * queue's pending decisions with per-item resolution, exclusion (with an - * optional reason), and the queue's seed-rules card with an enable/disable - * toggle. The list reuses the desk's card so a decision looks and resolves the - * same wherever it is surfaced. + * Queue page. A single queue's pending + * decisions with per-item resolution, exclusion (with an optional reason), and + * the queue's seed-rules card with an enable/disable toggle. + * + * The queue exposes the same toolbar the desk does — filter, + * group-by, sort, the arrival timeline groupings, the date-range chips, and the + * aging shelf — sharing the desk's `DecisionsToolbar`, grouping helpers, and + * shelf components so a decision looks, groups, and resolves the same wherever it + * is surfaced. */ export function DecisionQueuePage() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const { pushToast } = useToastActions(); + const navigate = useNavigate(); const queryClient = useQueryClient(); const params = useParams<{ key: string }>(); const queueKey = params.key ?? ""; const [expandedId, setExpandedId] = useState(null); const { dismiss, snooze } = useInboxDismissals(selectedCompanyId); + // Decision-training drawer target. `null` when closed. + const [trainingItem, setTrainingItem] = useState(null); + + // Toolbar preferences (persisted to localStorage, shared with the desk). + const [groupBy, setGroupBy] = useState(() => loadAttentionGroupBy()); + const [sortOrder, setSortOrder] = useState(() => loadAttentionSortOrder()); + const [filters, setFilters] = useState(() => defaultAttentionFilterState); + const [collapsedGroupKeys, setCollapsedGroupKeys] = useState>(() => new Set()); + const [agingOpen, setAgingOpen] = useState(false); + + // Date-range chips (§4.2) — resolve to server-side activity bounds. + const [dateRange, setDateRange] = useState("all"); + const [customRange, setCustomRange] = useState({ from: null, to: null }); + + const activityBounds = useMemo( + () => resolveAttentionDateRange(dateRange, Date.now(), customRange), + [dateRange, customRange], + ); + const { data: queues } = useQuery({ queryKey: queryKeys.decisionQueues.list(selectedCompanyId!), queryFn: () => decisionQueuesApi.list(selectedCompanyId!), @@ -47,8 +102,14 @@ export function DecisionQueuePage() { isLoading, error, } = useQuery({ - queryKey: [...queryKeys.attention(selectedCompanyId!), "queue", queueKey], - queryFn: () => attentionApi.list(selectedCompanyId!, { queue: queueKey, all: true }), + queryKey: [ + ...queryKeys.attention(selectedCompanyId!), + "queue", + queueKey, + activityBounds.activitySince ?? null, + activityBounds.activityUntil ?? null, + ], + queryFn: () => attentionApi.list(selectedCompanyId!, { queue: queueKey, all: true, ...activityBounds }), enabled: !!selectedCompanyId && !!queueKey, refetchOnWindowFocus: true, }); @@ -74,11 +135,75 @@ export function DecisionQueuePage() { setBreadcrumbs([{ label: "Decisions", href: "/decisions" }, { label: queue?.title ?? queueKey }]); }, [setBreadcrumbs, queue?.title, queueKey]); - const items = useMemo( + // Re-hydrate per-company preferences when the company changes. + useEffect(() => { + setFilters(loadAttentionFilters(selectedCompanyId)); + setCollapsedGroupKeys(loadCollapsedAttentionGroupKeys(selectedCompanyId)); + }, [selectedCompanyId]); + + // The server's clock at feed time — used for the arrival/decide-by shelves and + // the aging idle labels so they match the desk exactly. + const now = useMemo( + () => (feed?.generatedAt ? new Date(feed.generatedAt).getTime() : Date.now()), + [feed?.generatedAt], + ); + + const activeItems = useMemo( () => (feed?.items ?? []).filter((item) => !(item.dismissal?.isActive ?? false)), [feed], ); + // Aging shelf (§4.4): items the server flags as idle past retention leave the + // live list for their own curtain, mirroring the desk. + const agingItems = useMemo(() => activeItems.filter(attentionIsAging), [activeItems]); + const listItems = useMemo(() => activeItems.filter((item) => !attentionIsAging(item)), [activeItems]); + + const filterOptions = useMemo(() => buildAttentionFilterOptions(listItems), [listItems]); + + // Filter → sort → group, matching the desk. In the default (ungrouped) view the + // list groups by arrival ("New today" / "Earlier", plus a "Decide now" shelf + // when something carries an explicit due decide-by); any explicit group-by keeps + // the Inbox-style activity grouping. + const groups = useMemo(() => { + const filtered = filterAttentionItems(listItems, filters); + if (groupBy === "none") { + return buildDeskShelves(filtered, now); + } + const sorted = sortAttentionItems(filtered, sortOrder); + return groupAttentionItems(sorted, groupBy); + }, [listItems, filters, sortOrder, groupBy, now]); + + const visibleCount = useMemo(() => groups.reduce((sum, group) => sum + group.items.length, 0), [groups]); + + const updateGroupBy = (next: AttentionGroupBy) => { + setGroupBy(next); + saveAttentionGroupBy(next); + }; + const updateSortOrder = (next: AttentionSortOrder) => { + setSortOrder(next); + saveAttentionSortOrder(next); + }; + const updateFilters = (next: AttentionFilterState) => { + setFilters(next); + saveAttentionFilters(selectedCompanyId, next); + }; + const toggleGroupCollapse = (key: string) => { + setCollapsedGroupKeys((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + saveCollapsedAttentionGroupKeys(selectedCompanyId, next); + return next; + }); + }; + + const handleToggleExpand = useCallback((item: AttentionItem) => { + setExpandedId((prev) => (prev === item.id ? null : item.id)); + }, []); + const handleTrain = useCallback((item: AttentionItem) => { + setTrainingItem(item); + }, []); + const invalidate = () => { queryClient.invalidateQueries({ queryKey: queryKeys.attention(selectedCompanyId!) }); queryClient.invalidateQueries({ queryKey: queryKeys.decisionQueues.list(selectedCompanyId!) }); @@ -103,6 +228,8 @@ export function DecisionQueuePage() { return ; } + const isEmpty = activeItems.length === 0; + return (
@@ -110,9 +237,30 @@ export function DecisionQueuePage() {

{queue?.title ?? queueKey}

{queue?.description &&

{queue.description}

}
+ navigate(decisionTrainingHref())} + />
- +
+ + { + setDateRange(value); + setCustomRange(custom); + }} + /> +
{queue && queue.seedRules.length > 0 && ( {(error as Error).message}

} - {items.length === 0 ? ( + {isEmpty ? (

This queue is empty.

@@ -134,28 +282,104 @@ export function DecisionQueuePage() {

) : (
- {items.map((item) => ( - setExpandedId((prev) => (prev === next.id ? null : next.id))} - onDismiss={(next) => dismiss(next.dismissalKey)} - onSnooze={(next, until) => snooze(next.dismissalKey, until)} - onExcluded={invalidate} - /> - ))} + {visibleCount === 0 ? ( +
+

No decisions match your filters.

+

Adjust or clear the filters to see the rest.

+
+ ) : ( + groups.map((group) => { + const groupLabel = group.label; + const collapsed = groupLabel !== null && collapsedGroupKeys.has(group.key); + return ( +
+ {groupLabel !== null && ( + toggleGroupCollapse(group.key)} + trailing={ + {group.items.length} + } + /> + )} + {!collapsed && ( +
+ {group.items.map((item) => ( + dismiss(next.dismissalKey)} + onSnooze={(next, until) => snooze(next.dismissalKey, until)} + onTrain={handleTrain} + onExcluded={invalidate} + /> + ))} +
+ )} +
+ ); + }) + )} + + {agingItems.length > 0 && ( + setAgingOpen((prev) => !prev)} + > +

+ Idle past {ATTENTION_AGING_DAYS} days — kept off the queue. Keep any you still want surfaced. +

+ {agingItems.map((item) => ( + dismiss(next.dismissalKey)} + onSnooze={(next, until) => snooze(next.dismissalKey, until)} + onTrain={handleTrain} + /> + ))} +
+ )}
)} + + { + if (!next) setTrainingItem(null); + }} + companyId={selectedCompanyId} + item={trainingItem} + currentUserId={currentUserId} + />
); } +/** + * Auto-seed card: explains in place what seeding does — the queue's seed rules + * (from `DECISION_QUEUE_SEEDS`) that pull matching decisions in automatically — + * and what the toggle changes. Disabling stops only the automatic adds; anything + * already here stays, and manual adds keep working. + */ function SeedRulesCard({ enabled, rules, @@ -172,9 +396,14 @@ function SeedRulesCard({
-
-

Auto-seeding {enabled ? "on" : "off"}

-
    +
    +

    Auto-seeding is {enabled ? "on" : "off"}

    +

    + {enabled + ? "This queue fills itself automatically. Decisions are added the moment they match any of its rules:" + : "Automatic adds are paused. These rules would add decisions to the queue when on:"} +

    +
      {rules.map((rule) => (
    • @@ -182,6 +411,11 @@ function SeedRulesCard({
    • ))}
    +

    + {enabled + ? "Turning it off stops new automatic adds only — decisions already here stay, and you can still add or remove decisions by hand." + : "Adding or removing decisions by hand still works while automatic seeding is off."} +

- - - - - - {/* Group by */} - - - - - -
- {ATTENTION_GROUP_BY_OPTIONS.map(([value, label]) => ( - - ))} -
-
-
- - {/* Sort */} - - - - - -
- {ATTENTION_SORT_OPTIONS.map(([value, label]) => ( - - ))} -
-
-
-
+ navigate(decisionTrainingHref())} + />
{/* Queue quicklinks + date-range chips (§4.1–§4.2). The rail self-hides @@ -692,7 +566,6 @@ export function WhatNeedsMe() { 0} /> ) : ( <> - {deskClearToday && } {groups.map((group) => { const groupLabel = group.label; const collapsed = groupLabel !== null && collapsedGroupKeys.has(group.key); @@ -949,265 +822,6 @@ export function DecisionBundleHeader({ ); } -function FilterMenu({ - options, - filters, - onChange, -}: { - options: ReturnType; - filters: AttentionFilterState; - onChange: (next: AttentionFilterState) => void; -}) { - const toggle = (key: keyof AttentionFilterState, value: string) => { - const list = filters[key] as string[]; - const nextList = list.includes(value) ? list.filter((v) => v !== value) : [...list, value]; - onChange({ ...filters, [key]: nextList }); - }; - const hasActive = countActiveAttentionFilters(filters) > 0; - - return ( -
-
- Filter - {hasActive && ( - - )} -
- - {options.sourceKinds.length > 1 && ( - - {options.sourceKinds.map((kind) => ( - toggle("sourceKinds", kind)} - /> - ))} - - )} - - {options.severities.length > 1 && ( - - {options.severities.map((severity) => ( - toggle("severities", severity)} - /> - ))} - - )} - - {(options.projects.length > 0 || options.hasNoProject) && ( - - {options.projects.map((project) => ( - toggle("projectIds", project.id)} - /> - ))} - {options.hasNoProject && ( - toggle("projectIds", NO_GROUP_SENTINEL)} - /> - )} - - )} - - {(options.workspaces.length > 0 || options.hasNoWorkspace) && ( - - {options.workspaces.map((workspace) => ( - toggle("workspaceIds", workspace.id)} - /> - ))} - {options.hasNoWorkspace && ( - toggle("workspaceIds", NO_GROUP_SENTINEL)} - /> - )} - - )} -
- ); -} - -function FilterSection({ title, children }: { title: string; children: ReactNode }) { - return ( -
-

- {title} -

-
{children}
-
- ); -} - -function FilterRow({ - label, - checked, - onToggle, -}: { - label: string; - checked: boolean; - onToggle: () => void; -}) { - return ( - - ); -} - -function Curtain({ - label, - count, - open, - onToggle, - children, -}: { - label: string; - count?: number | string; - open: boolean; - onToggle: () => void; - children: ReactNode; -}) { - return ( -
- - {open &&
{children}
} -
- ); -} - -/** - * Slim banner shown at the top of the desk when there are decisions but none - * are due today — the "Decide now" shelf is empty, so we say so rather than - * leading with a bare "Can wait" header (§4.3 "empty state when today is clear"). - */ -function TodayClearNote() { - return ( -
- -

- Nothing needs a decision today. Everything below can wait. -

-
- ); -} - -/** - * An aging-shelf row (§4.4): the standard card, prefaced by an idle-duration - * label and a "Keep on desk" affordance that clears the shelf flag server-side - * (P1 retention `keep`). Archival/sweeper mechanics are P5 — this is the split - * plus the Keep stub only. - */ -function AgingItemRow({ - item, - companyId, - now, - agentMap, - agents, - currentUserId, - expanded, - onToggleExpand, - onDismiss, - onSnooze, - onTrain, -}: { - item: AttentionItem; - companyId: string; - now: number; - agentMap: Map; - agents: Agent[] | undefined; - currentUserId: string | null; - expanded: boolean; - onToggleExpand: (item: AttentionItem) => void; - onDismiss: (item: AttentionItem) => void; - onSnooze: (item: AttentionItem, snoozedUntil: string) => void; - onTrain: (item: AttentionItem) => void; -}) { - const queryClient = useQueryClient(); - const { pushToast } = useToastActions(); - const idleDays = attentionIdleDays(item, now); - const keep = useMutation({ - mutationFn: () => decisionQueuesApi.setKeep(companyId, item.sourceKind, item.subject.id, true), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.attention(companyId) }); - pushToast({ title: "Kept on desk", body: item.subject.title ?? undefined, tone: "success" }); - }, - onError: (error) => - pushToast({ - title: "Could not keep this decision", - body: error instanceof Error ? error.message : "Please try again.", - tone: "error", - }), - }); - - return ( -
-
- - Idle {idleDays} {idleDays === 1 ? "day" : "days"} - - -
- -
- ); -} - function CaughtUpNote({ filtered }: { filtered: boolean }) { return (
diff --git a/ui/storybook/stories/decisions-desk.stories.tsx b/ui/storybook/stories/decisions-desk.stories.tsx index 6681e18b88..c0a0928541 100644 --- a/ui/storybook/stories/decisions-desk.stories.tsx +++ b/ui/storybook/stories/decisions-desk.stories.tsx @@ -137,14 +137,22 @@ function approval(id: string, title: string, whyNow: string, overrides: Partial< } function feed(items: AttentionItem[]): AttentionFeed { - const decideNowCount = items.filter( - (it) => it.decideBy === "today" && !it.shelf, + // New-today (surfaced today) or overdue decide-by — the sidebar badge load. + const startOfToday = Date.UTC( + new Date(NOW).getUTCFullYear(), + new Date(NOW).getUTCMonth(), + new Date(NOW).getUTCDate(), + ); + const deskBadgeCount = items.filter( + (it) => + !it.shelf && + (it.decideBy === "today" || new Date(it.createdAt).getTime() >= startOfToday), ).length; return { companyId, generatedAt: iso(NOW), totalCount: items.length, - decideNowCount, + deskBadgeCount, nextCursor: null, countsBySourceKind: {} as AttentionFeed["countsBySourceKind"], items, @@ -258,6 +266,7 @@ const DESK_ITEMS: AttentionItem[] = [ decideBy: "this_week", decideByAttribution: attribution("Prioritizer"), queues: [PRS_QUEUE_REF], + createdAt: iso(NOW - 3 * HOUR), activityAt: iso(NOW - 5 * HOUR), }), item( @@ -268,6 +277,7 @@ const DESK_ITEMS: AttentionItem[] = [ "In-review issue is waiting on a human reviewer.", { decideBy: "whenever", + createdAt: iso(NOW - 2 * HOUR), activityAt: iso(NOW - 26 * HOUR), project: { id: "proj-beta", name: "Beta", urlKey: "beta", color: "#0f766e", icon: "rocket" }, }, @@ -278,12 +288,13 @@ const DESK_ITEMS: AttentionItem[] = [ "low", "Company budget crossed 85%", "Budget crossed the 85% threshold.", - { relatedIssue: null, inlineResolvable: false, activityAt: iso(NOW - 2 * DAY) }, + { relatedIssue: null, inlineResolvable: false, createdAt: iso(NOW - 2 * DAY), activityAt: iso(NOW - 2 * DAY) }, ), ]; -// The desk when nothing is due today — every item is deferred ("whenever"), so -// the "Decide now" shelf is empty and the desk shows the "today is clear" note. +// The desk when nothing has a due decide-by — every item is deferred +// ("whenever"), so there is no "Decide now" shelf and no "can wait" claim; the +// desk is purely the arrival groups "New today" / "Earlier". const CLEAR_TODAY_ITEMS: AttentionItem[] = DESK_ITEMS.filter((it) => it.decideBy !== "today").map((it) => ({ ...it, decideBy: "whenever", @@ -363,8 +374,10 @@ function PrimeDeskFixtures({ ); } if (queueItems) { + // The queue feed key carries the resolved activity bounds (null/null for + // the default "all" range), matching the page's real query key. queryClient.setQueryData( - [...queryKeys.attention(companyId), "queue", "prs"], + [...queryKeys.attention(companyId), "queue", "prs", null, null], feed(queueItems), ); } @@ -382,9 +395,10 @@ export default meta; type Story = StoryObj; /** - * Screen 1 — today's desk. Queue rail + date chips on top, then the desk split - * into "Decide now" (due today) and "Can wait", with decide-by + provenance - * ("set by Prioritizer") on the cards. + * Screen 1 — today's desk. Queue rail + date chips on top, then a "Decide now" + * shelf (only because some items have an explicit due decide-by) followed by the + * arrival groups "New today" / "Earlier", with decide-by + provenance ("set by + * Prioritizer") on the cards. */ export const TodaysDesk: Story = { render: () => ( @@ -396,7 +410,11 @@ export const TodaysDesk: Story = { ), }; -/** Screen 1 variant — the desk when today is clear (nothing due today). */ +/** + * Screen 1 variant — no item has an explicit decide-by deadline, so there is no + * "Decide now" shelf and no "can wait" claim; the desk is purely the arrival + * groups "New today" / "Earlier". + */ export const DeskClearToday: Story = { render: () => ( @@ -419,21 +437,40 @@ export const AgingShelf: Story = { }; /** - * Screen 2 — a queue page. Homogeneous list where each source-native decision - * resolves per item (approve/reject on the card, plus Exclude-with-reason); - * the queue rail (active chip) and the seed-rules card with its enable/disable - * toggle sit above. Bulk accept/reject was pulled until a cross-domain - * exact-set transaction can preserve side effects atomically (see PAP-16032 - * follow-up); screen 2 ships per-item-only for now. + * Screen 2 — a queue page. The queue carries the same toolbar as + * the desk (filter / group / sort / training), the date-range chips, the arrival + * timeline groupings ("Decide now" / "New today" / "Earlier") and the aging + * shelf, above the seed-rules card (with its rewritten copy) and the per-item + * Exclude-with-reason affordance. Each source-native decision still resolves per + * item (approve/reject on the card). */ export const QueuePage: Story = { render: () => (