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: () => (