From 802b31c41bf7053039e86a26d1abbfb872624f43 Mon Sep 17 00:00:00 2001 From: devops-engineer Date: Wed, 10 Jun 2026 10:47:12 +0000 Subject: [PATCH] =?UTF-8?q?feat(requests):=20P3=20=E2=80=94=20canvas=20Tas?= =?UTF-8?q?ks/Approvals=20tabs=20on=20unified=20requests=20(RFC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evolve the concierge Home Tasks + Approvals tabs onto the unified `requests` inbox model (RFC unified-requests-inbox, P1 = molecule-core #2525). Switches both tabs off the legacy /approvals/pending + /user-tasks/pending sources to the single /requests/pending?kind=… endpoint P1 backfilled, adds the full action set + an inline More-Info thread, and mirrors the new REQUEST_* events on the canvas side. - RequestsInbox.tsx: new self-contained, unit-tested inbox component (kind=task|approval). Reuses the existing Concierge.module.css card/button classes and the `api` client — no restyle. Per item: requester agent name + title + detail + age + status badge. Tasks → Done / Reject / More Info; Approvals → Approve / Reject / More Info. More-Info is an inline expandable thread (GET /requests/{id} → messages; POST /requests/{id}/messages; status shows info_requested). Optimistic removal on respond; responder identity from the session user (GET /cp/auth/me user_id) with an "admin" placeholder fallback. - ConciergeShell.tsx: render for the two tabs; both stay mounted so the tab-count badges stay live. Drops the old approvals/userTasks state + decide/resolveTask handlers. - ws-events.ts: TS mirror of the Go events.EventType taxonomy; adds REQUEST_CREATED / REQUEST_RESPONDED / REQUEST_MESSAGE. The inbox subscribes to the shared socket bus and refreshes on those events. - RequestsInbox.test.tsx: mirrors ApprovalBanner.test patterns — renders task + approval items, Done/Approve/Reject assert the right POST body, More-Info opens the thread + posts a message. Field names matched exactly to workspace-server RequestRow / RequestMessageRow. Visual verification against live endpoints is pending P1's deploy; this PR is the wired + unit-tested UI. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/concierge/ConciergeShell.tsx | 153 +----- .../components/concierge/RequestsInbox.tsx | 464 ++++++++++++++++++ .../__tests__/RequestsInbox.test.tsx | 202 ++++++++ canvas/src/lib/ws-events.ts | 53 ++ 4 files changed, 741 insertions(+), 131 deletions(-) create mode 100644 canvas/src/components/concierge/RequestsInbox.tsx create mode 100644 canvas/src/components/concierge/__tests__/RequestsInbox.test.tsx create mode 100644 canvas/src/lib/ws-events.ts diff --git a/canvas/src/components/concierge/ConciergeShell.tsx b/canvas/src/components/concierge/ConciergeShell.tsx index f1344268a..5fde48287 100644 --- a/canvas/src/components/concierge/ConciergeShell.tsx +++ b/canvas/src/components/concierge/ConciergeShell.tsx @@ -6,7 +6,6 @@ import { WORKSPACE_KIND } from "@/lib/workspace-kind"; import { useTheme } from "@/lib/theme-provider"; import { api, PLATFORM_URL } from "@/lib/api"; import { switchOrgUrl } from "@/lib/org-switch"; -import { showToast } from "@/components/Toaster"; import type { ActivityEntry } from "@/types/activity"; import { Canvas } from "@/components/Canvas"; import { CommunicationOverlay } from "@/components/CommunicationOverlay"; @@ -15,9 +14,10 @@ import { ChatTab } from "@/components/tabs/ChatTab"; import { WorkspacePanelTabs } from "@/components/WorkspacePanelTabs"; import { SettingsTabs } from "@/components/settings"; import s from "./Concierge.module.css"; +import { RequestsInbox } from "./RequestsInbox"; import { IcHome, IcOrgMap, IcSettings, IcSearch, IcBell, IcSun, IcMoon, IcChevDown, - IcQueue, IcCaret, IcMolecule, IcClock, IcCheck, IcTrash, IcChat, + IcQueue, IcCaret, IcMolecule, IcCheck, IcChat, } from "./icons"; /* ── status → concept palette ─────────────────────────────────────────── */ @@ -56,25 +56,6 @@ function gradientFor(id: string): string { type SbTab = "agents" | "tasks" | "approvals"; -interface PendingApproval { - id: string; - workspace_id: string; - workspace_name: string; - action: string; - reason: string | null; - status: string; - created_at: string; -} -interface UserTask { - id: string; - workspace_id: string; - workspace_name: string; - title: string; - detail: string | null; - status: string; - created_at: string; -} - /** ISO timestamp → "9:05 PM" (local). Empty string on a bad/missing value. */ function clockTime(iso: string | null | undefined): string { if (!iso) return ""; @@ -233,24 +214,15 @@ export function ConciergeShell() { const chatId = chatNode?.id ?? null; const chatIsRoot = chatId !== null && chatId === platformId; - // ── live data: approvals + user-tasks (org-wide), activity (platform agent) ── - const [approvals, setApprovals] = useState([]); - const [userTasks, setUserTasks] = useState([]); + // ── live data: requests (Tasks + Approvals, org-wide), activity (platform agent) ── + // The Tasks/Approvals tabs are now driven by the unified RequestsInbox + // component (RFC unified-requests-inbox P3) over /requests/pending?kind=…; + // the shell keeps only the per-tab pending counts so the tab badges render. + // The inbox owns its own fetch, optimistic update, live-refresh and + // More-Info thread — see RequestsInbox.tsx. + const [taskCount, setTaskCount] = useState(0); + const [apprCount, setApprCount] = useState(0); const [activity, setActivity] = useState([]); - const [deciding, setDeciding] = useState(null); - const [resolving, setResolving] = useState(null); - - const loadApprovals = useCallback(() => { - api.get("/approvals/pending") - .then((r) => setApprovals(r ?? [])) - .catch(() => setApprovals([])); - }, []); - const loadUserTasks = useCallback(() => { - api.get("/user-tasks/pending") - .then((r) => setUserTasks(r ?? [])) - .catch(() => setUserTasks([])); - }, []); - useEffect(() => { loadApprovals(); loadUserTasks(); }, [loadApprovals, loadUserTasks]); useEffect(() => { if (!platformId) return; @@ -261,38 +233,6 @@ export function ConciergeShell() { return () => { cancelled = true; }; }, [platformId]); - const decide = useCallback(async (a: PendingApproval, decision: "approved" | "denied") => { - if (deciding) return; - setDeciding(a.id); - try { - await api.post(`/workspaces/${a.workspace_id}/approvals/${a.id}/decide`, { - decision, decided_by: "human", - }); - showToast(decision === "approved" ? "Approved" : "Denied", decision === "approved" ? "success" : "info"); - setApprovals((prev) => prev.filter((x) => x.id !== a.id)); - } catch { - showToast("Failed to record decision", "error"); - } finally { - setDeciding(null); - } - }, [deciding]); - - const resolveTask = useCallback(async (t: UserTask, status: "done" | "dismissed") => { - if (resolving) return; - setResolving(t.id); - try { - await api.post(`/workspaces/${t.workspace_id}/user-tasks/${t.id}/resolve`, { - status, resolved_by: "human", - }); - showToast(status === "done" ? "Marked done" : "Dismissed", status === "done" ? "success" : "info"); - setUserTasks((prev) => prev.filter((x) => x.id !== t.id)); - } catch { - showToast("Failed to resolve task", "error"); - } finally { - setResolving(null); - } - }, [resolving]); - const nav = (v: TopView) => setTopView(v); /* ── agents tree (recursive) ──────────────────────────────────────── */ @@ -473,10 +413,10 @@ export function ConciergeShell() {
@@ -507,65 +447,16 @@ export function ConciergeShell() {
)} - {sbTab === "tasks" && ( - <> - {userTasks.length === 0 && ( -
Nothing needs you right now. When an agent needs you to do something, it shows up here.
- )} - {userTasks.map((t) => ( -
-
-
-
-
{t.title}
-
- {t.workspace_name}asked {clockTime(t.created_at)} -
- {t.detail && ( -
- {t.detail} -
- )} -
-
-
- - -
-
- ))} - - )} - {sbTab === "approvals" && ( - <> - {approvals.length === 0 && ( -
No pending approvals. Destructive actions await sign-off here.
- )} - {approvals.map((a) => ( -
-
-
-
-
{a.action.replace(/_/g, " ")} {a.workspace_name}
-
{a.reason || "destructive"}
-
-
-
- - -
-
- ))} - - )} + {/* Both inboxes stay mounted so their pending-count badges + remain live on the tab bar even while the Agents tab is + shown; only the active one is visible. Each owns its own + fetch + optimistic update + live WS refresh. */} +
+ +
+
+ +
diff --git a/canvas/src/components/concierge/RequestsInbox.tsx b/canvas/src/components/concierge/RequestsInbox.tsx new file mode 100644 index 000000000..b4d452e25 --- /dev/null +++ b/canvas/src/components/concierge/RequestsInbox.tsx @@ -0,0 +1,464 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { api } from "@/lib/api"; +import { fetchSession } from "@/lib/auth"; +import { showToast } from "@/components/Toaster"; +import { subscribeSocketEvents } from "@/store/socket-events"; +import { isRequestEvent } from "@/lib/ws-events"; +import s from "./Concierge.module.css"; +import { IcClock, IcCheck, IcTrash, IcChat, IcSend } from "./icons"; + +/** + * RequestsInbox renders the Home sidebar's Tasks + Approvals tabs on the + * unified `requests` model (RFC unified-requests-inbox, P3 canvas). It + * replaces the old split /approvals/pending + /user-tasks/pending sources + * with the single /requests/pending?kind=… endpoint that P1 backfilled, and + * adds the full action set (Done/Reject for tasks, Approve/Reject for + * approvals) plus an inline More-Info thread per item. + * + * It is a self-contained, unit-testable unit (mirroring ApprovalBanner) that + * ConciergeShell embeds inside its existing `.sbBody`; the visual language is + * the existing Concierge.module.css card/button classes — no restyle. + */ + +/** One row of GET /requests/pending — matches the Go RequestRow JSON shape + * (workspace-server/internal/handlers/request_store.go RequestRow). */ +export interface RequestRow { + id: string; + kind: string; + requester_type: string; + requester_id: string; + org_id: string | null; + recipient_type: string; + recipient_id: string; + title: string; + detail: string | null; + status: string; + responder_type: string | null; + responder_id: string | null; + priority: number | null; + created_at: string; + updated_at: string; + responded_at: string | null; + // Non-empty only when the requester party is an agent (LEFT JOIN workspaces). + workspace_name?: string; +} + +/** One row of a request's More-Info thread — matches Go RequestMessageRow. */ +export interface RequestMessageRow { + id: string; + request_id: string; + author_type: string; + author_id: string; + body: string; + created_at: string; +} + +/** GET /requests/{id} envelope. */ +interface RequestWithThread { + request: RequestRow; + messages: RequestMessageRow[]; +} + +export type RequestKind = "task" | "approval"; + +/** ISO timestamp → "9:05 PM" (local). Empty string on a bad/missing value. + * Duplicated tiny formatter (also in ConciergeShell) — kept local so this + * unit has no coupling back to the shell. */ +function clockTime(iso: string | null | undefined): string { + if (!iso) return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +/** Human label for who raised a request: the joined workspace name when the + * requester is an agent, else a generic party label. */ +function requesterLabel(r: RequestRow): string { + if (r.workspace_name) return r.workspace_name; + if (r.requester_type === "user") return "You"; + return r.requester_id || "agent"; +} + +/** A short, human status badge label. info_requested is the More-Info state. */ +function statusLabel(status: string): string { + switch (status) { + case "info_requested": return "info requested"; + case "pending": return "pending"; + default: return status.replace(/_/g, " "); + } +} + +interface RequestsInboxProps { + kind: RequestKind; + /** Lifted setter so the parent (ConciergeShell) can show the tab count. + * Called with the current pending-list length on every load. */ + onCountChange?: (n: number) => void; +} + +export function RequestsInbox({ kind, onCountChange }: RequestsInboxProps) { + const [items, setItems] = useState([]); + // Guards double-submit while a respond POST is in flight (per-item id). + const [acting, setActing] = useState(null); + // Which item's More-Info thread is expanded inline (null = none). + const [openThread, setOpenThread] = useState(null); + + // Responder identity. The canvas is effectively single-user today; the + // real responder is the logged-in session user (GET /cp/auth/me → user_id). + // We resolve it once and reuse it; if no session is reachable we fall back + // to a clear placeholder so an action is never blocked on auth. + // TODO(multi-user): when the canvas grows real per-action attribution, plumb + // the acting user through props instead of a single module-level resolve. + const responderIdRef = useRef("admin"); + useEffect(() => { + let cancelled = false; + fetchSession() + .then((sess) => { + if (!cancelled && sess?.user_id) responderIdRef.current = sess.user_id; + }) + .catch(() => { + // No session reachable — keep the "admin" placeholder. + }); + return () => { cancelled = true; }; + }, []); + + const load = useCallback(() => { + api.get(`/requests/pending?kind=${kind}`) + .then((r) => { + const list = r ?? []; + setItems(list); + onCountChange?.(list.length); + }) + .catch(() => { + setItems([]); + onCountChange?.(0); + }); + }, [kind, onCountChange]); + + useEffect(() => { load(); }, [load]); + + // Live refresh: the Go side emits REQUEST_CREATED / REQUEST_RESPONDED / + // REQUEST_MESSAGE over the shared WS bus. Re-fetch on any of them so a + // request raised/answered elsewhere (another tab, an agent) reflects here + // without a manual refresh — same "subscribe to the global bus" pattern the + // rest of the canvas uses. + useEffect(() => { + const unsub = subscribeSocketEvents((msg) => { + if (isRequestEvent(msg.event)) load(); + }); + return unsub; + }, [load]); + + /** Terminal action: POST /requests/{id}/respond. Optimistically drops the + * item from the pending list on success (it leaves the pending view). */ + const respond = useCallback( + async (r: RequestRow, action: "done" | "rejected" | "approved") => { + if (acting) return; + setActing(r.id); + try { + await api.post(`/requests/${r.id}/respond`, { + action, + responder_type: "user", + responder_id: responderIdRef.current, + }); + const verb = + action === "approved" ? "Approved" : + action === "done" ? "Marked done" : "Rejected"; + showToast(verb, action === "rejected" ? "info" : "success"); + setItems((prev) => { + const next = prev.filter((x) => x.id !== r.id); + onCountChange?.(next.length); + return next; + }); + if (openThread === r.id) setOpenThread(null); + } catch { + showToast("Failed to record response", "error"); + } finally { + setActing(null); + } + }, + [acting, openThread, onCountChange], + ); + + const toggleThread = useCallback((id: string) => { + setOpenThread((cur) => (cur === id ? null : id)); + }, []); + + const emptyCopy = + kind === "task" + ? "Nothing needs you right now. When an agent needs you to do something, it shows up here." + : "No pending approvals. Destructive actions await sign-off here."; + + return ( + <> + {items.length === 0 &&
{emptyCopy}
} + {items.map((r) => ( + toggleThread(r.id)} + responderId={responderIdRef.current} + /> + ))} + + ); +} + +interface RequestItemProps { + row: RequestRow; + kind: RequestKind; + acting: boolean; + threadOpen: boolean; + onRespond: (r: RequestRow, action: "done" | "rejected" | "approved") => void; + onToggleThread: () => void; + responderId: string; +} + +/** A single Tasks/Approvals card. Task layout reuses the .task classes; + * approval layout reuses the .apprCard classes. Both share the inline + * More-Info thread panel. */ +function RequestItem({ + row, kind, acting, threadOpen, onRespond, onToggleThread, responderId, +}: RequestItemProps) { + const badge = statusLabel(row.status); + const isApproval = kind === "approval"; + + // "Responder identity" on a resolved row (shows only if a resolved item + // ever renders in this pending view — defensive, since pending excludes + // resolved, but info_requested rows carry a responder in some flows). + const resolvedBy = + row.status !== "pending" && row.responder_id + ? `${isApproval ? "Approved by" : "Done by"} ${row.responder_id}` + : null; + + const actions = isApproval ? ( +
+ + + +
+ ) : ( +
+ + + +
+ ); + + if (isApproval) { + return ( +
+
+
+
+
+ {row.title} {requesterLabel(row)} +
+
+ {badge} + {" · asked "}{clockTime(row.created_at)} +
+ {row.detail &&
{row.detail}
} + {resolvedBy &&
{resolvedBy}
} +
+
+ {actions} + {threadOpen && ( + + )} +
+ ); + } + + return ( +
+
+
+
+
{row.title}
+
+ {requesterLabel(row)} + {badge} + {" · asked "}{clockTime(row.created_at)} +
+ {row.detail && ( +
+ {row.detail} +
+ )} + {resolvedBy && ( +
{resolvedBy}
+ )} +
+
+ {actions} + {threadOpen && ( + + )} +
+ ); +} + +interface MoreInfoThreadProps { + requestId: string; + responderId: string; +} + +/** + * Inline "chat about this" panel: loads GET /requests/{id} for the message + * thread and posts replies to POST /requests/{id}/messages (which flips the + * request to info_requested server-side). Rendered inside the existing card, + * styled with the existing card/button vars — no new visual language. + */ +function MoreInfoThread({ requestId, responderId }: MoreInfoThreadProps) { + const [messages, setMessages] = useState([]); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const [loaded, setLoaded] = useState(false); + + const load = useCallback(() => { + api.get(`/requests/${requestId}`) + .then((r) => { + setMessages(r?.messages ?? []); + setLoaded(true); + }) + .catch(() => { + setMessages([]); + setLoaded(true); + }); + }, [requestId]); + + useEffect(() => { load(); }, [load]); + + const send = useCallback(async () => { + const body = draft.trim(); + if (!body || sending) return; + setSending(true); + try { + await api.post(`/requests/${requestId}/messages`, { + body, + author_type: "user", + author_id: responderId, + }); + setDraft(""); + load(); // re-fetch so the new message (and flipped status) shows + } catch { + showToast("Failed to send message", "error"); + } finally { + setSending(false); + } + }, [draft, sending, requestId, responderId, load]); + + return ( +
+
+ {loaded && messages.length === 0 && ( +
+ No messages yet. Ask the agent for more detail. +
+ )} + {messages.map((m) => ( +
+ + {m.author_type === "user" ? "You" : m.author_id || "agent"} + + + {clockTime(m.created_at)} + +
{m.body}
+
+ ))} +
+
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + }} + placeholder="Ask about this…" + style={{ + flex: 1, + fontFamily: "var(--sans)", + fontSize: 12, + padding: "6px 10px", + borderRadius: 8, + border: "1px solid var(--hair-2)", + background: "var(--card)", + color: "var(--tx)", + }} + /> + +
+
+ ); +} diff --git a/canvas/src/components/concierge/__tests__/RequestsInbox.test.tsx b/canvas/src/components/concierge/__tests__/RequestsInbox.test.tsx new file mode 100644 index 000000000..443afbd27 --- /dev/null +++ b/canvas/src/components/concierge/__tests__/RequestsInbox.test.tsx @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +/** + * Tests for RequestsInbox — the unified Tasks/Approvals inbox (RFC P3 canvas). + * + * Covers: rendering a task + an approval item from /requests/pending, the + * Done/Approve/Reject actions (asserting the right POST /requests/:id/respond + * body), and the More-Info thread (GET /requests/:id load + POST + * /requests/:id/messages on send). + * + * Mock style mirrors ApprovalBanner.test.tsx: vi.hoisted refs + file-level + * vi.mock for @/lib/api, @/lib/auth, @/components/Toaster, and the socket bus. + * vi.resetModules() in afterEach undoes the mocks for other files. + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RequestsInbox, type RequestRow } from "../RequestsInbox"; +import { showToast } from "@/components/Toaster"; + +const { mockApiGet, mockApiPost, mockFetchSession, mockSubscribe } = vi.hoisted(() => ({ + mockApiGet: vi.fn<(args: unknown[]) => Promise>(), + mockApiPost: vi.fn<(args: unknown[]) => Promise>(), + mockFetchSession: vi.fn<(args: unknown[]) => Promise>(), + mockSubscribe: vi.fn(() => () => {}), +})); + +vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() })); +vi.mock("@/lib/api", () => ({ api: { get: mockApiGet, post: mockApiPost } })); +vi.mock("@/lib/auth", () => ({ fetchSession: mockFetchSession })); +vi.mock("@/store/socket-events", () => ({ subscribeSocketEvents: mockSubscribe })); +// CSS modules → empty object; icons → trivial stubs so render is DOM-only. +vi.mock("../Concierge.module.css", () => ({ default: {} })); + +const taskRow = (id = "t1"): RequestRow => ({ + id, + kind: "task", + requester_type: "agent", + requester_id: "ws-9", + org_id: "org-1", + recipient_type: "user", + recipient_id: "u-1", + title: "Review the Q3 deck", + detail: "Needs your eyes before send.", + status: "pending", + responder_type: null, + responder_id: null, + priority: null, + created_at: "2026-06-10T10:00:00Z", + updated_at: "2026-06-10T10:00:00Z", + responded_at: null, + workspace_name: "Researcher", +}); + +const approvalRow = (id = "a1"): RequestRow => ({ + ...taskRow(id), + kind: "approval", + title: "Delete production volume", + detail: "Destructive", + workspace_name: "Ops Agent", +}); + +beforeEach(() => { + mockApiGet.mockReset().mockResolvedValue([]); + mockApiPost.mockReset().mockResolvedValue({}); + mockFetchSession.mockReset().mockResolvedValue({ user_id: "user-42", org_id: "org-1", email: "x@y.z" }); + mockSubscribe.mockReset().mockReturnValue(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("RequestsInbox — task tab", () => { + it("fetches /requests/pending?kind=task and renders a task item", async () => { + mockApiGet.mockResolvedValue([taskRow("t1")]); + await act(async () => { render(); }); + expect(mockApiGet).toHaveBeenCalledWith("/requests/pending?kind=task"); + expect(screen.getByText("Review the Q3 deck")).toBeTruthy(); + expect(screen.getByText(/Researcher/)).toBeTruthy(); + expect(screen.getByTestId("request-status").textContent).toContain("pending"); + }); + + it("POSTs respond done with responder_type:user on Done click", async () => { + mockApiGet.mockResolvedValue([taskRow("t1")]); + await act(async () => { render(); }); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: /done/i })); }); + expect(mockApiPost).toHaveBeenCalledWith( + "/requests/t1/respond", + expect.objectContaining({ action: "done", responder_type: "user", responder_id: "user-42" }), + ); + expect(screen.queryByText("Review the Q3 deck")).toBeNull(); // optimistically removed + }); + + it("POSTs respond rejected on Reject click", async () => { + mockApiGet.mockResolvedValue([taskRow("t1")]); + await act(async () => { render(); }); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: /reject/i })); }); + expect(mockApiPost).toHaveBeenCalledWith( + "/requests/t1/respond", + expect.objectContaining({ action: "rejected" }), + ); + }); + + it("renders the empty-state copy when no tasks", async () => { + mockApiGet.mockResolvedValue([]); + await act(async () => { render(); }); + expect(screen.getByText(/Nothing needs you right now/i)).toBeTruthy(); + }); +}); + +describe("RequestsInbox — approval tab", () => { + it("fetches /requests/pending?kind=approval and renders an approval item", async () => { + mockApiGet.mockResolvedValue([approvalRow("a1")]); + await act(async () => { render(); }); + expect(mockApiGet).toHaveBeenCalledWith("/requests/pending?kind=approval"); + expect(screen.getByText("Delete production volume")).toBeTruthy(); + }); + + it("POSTs respond approved on Approve click", async () => { + mockApiGet.mockResolvedValue([approvalRow("a1")]); + await act(async () => { render(); }); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: /approve/i })); }); + expect(mockApiPost).toHaveBeenCalledWith( + "/requests/a1/respond", + expect.objectContaining({ action: "approved", responder_type: "user" }), + ); + }); + + it("POSTs respond rejected on Reject click (approval)", async () => { + mockApiGet.mockResolvedValue([approvalRow("a1")]); + await act(async () => { render(); }); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: /reject/i })); }); + expect(mockApiPost).toHaveBeenCalledWith( + "/requests/a1/respond", + expect.objectContaining({ action: "rejected" }), + ); + }); +}); + +describe("RequestsInbox — More Info thread", () => { + it("opens the thread, loads GET /requests/:id, and posts a message", async () => { + // First GET (pending list) → one task. Then GET /requests/t1 → thread. + mockApiGet.mockImplementation((path: string) => { + if (path === "/requests/pending?kind=task") return Promise.resolve([taskRow("t1")]); + if (path === "/requests/t1") { + return Promise.resolve({ + request: taskRow("t1"), + messages: [ + { id: "m1", request_id: "t1", author_type: "agent", author_id: "ws-9", body: "Why rejected?", created_at: "2026-06-10T10:01:00Z" }, + ], + }); + } + return Promise.resolve([]); + }); + + await act(async () => { render(); }); + // Open More Info. + await act(async () => { fireEvent.click(screen.getByRole("button", { name: /more info/i })); }); + expect(mockApiGet).toHaveBeenCalledWith("/requests/t1"); + expect(screen.getByTestId("more-info-thread")).toBeTruthy(); + expect(screen.getByText("Why rejected?")).toBeTruthy(); + + // Type + send a reply. + const input = screen.getByTestId("more-info-input") as HTMLInputElement; + await act(async () => { fireEvent.change(input, { target: { value: "Here is more context" } }); }); + await act(async () => { fireEvent.click(screen.getByTestId("more-info-send")); }); + expect(mockApiPost).toHaveBeenCalledWith( + "/requests/t1/messages", + expect.objectContaining({ body: "Here is more context", author_type: "user" }), + ); + }); + + it("Send is disabled when the draft is empty", async () => { + mockApiGet.mockImplementation((path: string) => { + if (path === "/requests/pending?kind=task") return Promise.resolve([taskRow("t1")]); + if (path === "/requests/t1") return Promise.resolve({ request: taskRow("t1"), messages: [] }); + return Promise.resolve([]); + }); + await act(async () => { render(); }); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: /more info/i })); }); + const send = screen.getByTestId("more-info-send") as HTMLButtonElement; + expect(send.disabled).toBe(true); + }); +}); + +describe("RequestsInbox — live refresh + toasts", () => { + it("subscribes to the socket bus on mount", async () => { + await act(async () => { render(); }); + expect(mockSubscribe).toHaveBeenCalled(); + }); + + it("shows an error toast when respond POST fails and keeps the item", async () => { + mockApiGet.mockResolvedValue([taskRow("t1")]); + mockApiPost.mockImplementation(() => Promise.reject(new Error("boom"))); + await act(async () => { render(); }); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: /done/i })); }); + expect(vi.mocked(showToast)).toHaveBeenCalledWith("Failed to record response", "error"); + expect(screen.getByText("Review the Q3 deck")).toBeTruthy(); + }); +}); diff --git a/canvas/src/lib/ws-events.ts b/canvas/src/lib/ws-events.ts new file mode 100644 index 000000000..9d2b969d8 --- /dev/null +++ b/canvas/src/lib/ws-events.ts @@ -0,0 +1,53 @@ +/** + * Canvas-side mirror of the Go `events.EventType` taxonomy + * (workspace-server/internal/events/types.go). The Go side is the SSOT; + * this union keeps the TS consumers (the socket bus + feature panels) + * honest about which `WSMessage.event` strings can arrive, so a typo in + * a handler `case` is a compile error rather than a silently-dropped + * event. + * + * Keep this list in sync with the Go `AllEventTypes` slice. When P1 added + * the unified requests inbox it introduced REQUEST_CREATED / REQUEST_RESPONDED + * / REQUEST_MESSAGE to the Go taxonomy; those are mirrored here so the canvas + * Tasks/Approvals tabs can react to them live (RFC unified-requests-inbox, P3). + * + * Only the event names the canvas actually consumes need to be exhaustive + * for type-safety; this file intentionally lists the full known set so the + * union reads as the contract, not a subset. + */ +export const WS_EVENTS = { + WorkspaceOnline: "WORKSPACE_ONLINE", + WorkspaceOffline: "WORKSPACE_OFFLINE", + WorkspacePaused: "WORKSPACE_PAUSED", + WorkspaceDegraded: "WORKSPACE_DEGRADED", + WorkspaceProvisioning: "WORKSPACE_PROVISIONING", + WorkspaceProvisionFailed: "WORKSPACE_PROVISION_FAILED", + WorkspaceRemoved: "WORKSPACE_REMOVED", + AgentCardUpdated: "AGENT_CARD_UPDATED", + AgentMessage: "AGENT_MESSAGE", + TaskUpdated: "TASK_UPDATED", + A2AResponse: "A2A_RESPONSE", + // --- Unified requests inbox (RFC P1; mirrored from events/types.go) --- + RequestCreated: "REQUEST_CREATED", + RequestResponded: "REQUEST_RESPONDED", + RequestMessage: "REQUEST_MESSAGE", +} as const; + +/** The event-name string union — the values of WS_EVENTS. */ +export type WsEventName = (typeof WS_EVENTS)[keyof typeof WS_EVENTS]; + +/** + * REQUEST_* event names the canvas Tasks/Approvals tabs refresh on. A single + * Set keeps the ConciergeShell subscriber's membership test O(1) and the list + * declarative — adding a fourth request event is one line here. + */ +export const REQUEST_EVENT_NAMES: ReadonlySet = new Set([ + WS_EVENTS.RequestCreated, + WS_EVENTS.RequestResponded, + WS_EVENTS.RequestMessage, +]); + +/** True when a WS message's event is one of the unified-requests events. */ +export function isRequestEvent(event: string): boolean { + return REQUEST_EVENT_NAMES.has(event); +} -- 2.52.0