feat(requests): P3 — canvas Tasks/Approvals tabs on unified requests (RFC) #2527

Merged
agent-reviewer merged 1 commits from feat/unified-requests-inbox-p3-canvas into main 2026-06-10 13:41:25 +00:00
4 changed files with 741 additions and 131 deletions
@@ -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<PendingApproval[]>([]);
const [userTasks, setUserTasks] = useState<UserTask[]>([]);
// ── 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<ActivityEntry[]>([]);
const [deciding, setDeciding] = useState<string | null>(null);
const [resolving, setResolving] = useState<string | null>(null);
const loadApprovals = useCallback(() => {
api.get<PendingApproval[]>("/approvals/pending")
.then((r) => setApprovals(r ?? []))
.catch(() => setApprovals([]));
}, []);
const loadUserTasks = useCallback(() => {
api.get<UserTask[]>("/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() {
<div className={s.sbTabs}>
<button data-testid="home-subtab-agents" className={`${s.sbTab} ${sbTab === "agents" ? s.active : ""}`} onClick={() => setSbTab("agents")}>Agents</button>
<button data-testid="home-subtab-tasks" className={`${s.sbTab} ${sbTab === "tasks" ? s.active : ""}`} onClick={() => setSbTab("tasks")}>
Tasks{userTasks.length > 0 && <span className={s.cnt}>{userTasks.length}</span>}
Tasks{taskCount > 0 && <span className={s.cnt}>{taskCount}</span>}
</button>
<button data-testid="home-subtab-approvals" className={`${s.sbTab} ${sbTab === "approvals" ? s.active : ""}`} onClick={() => setSbTab("approvals")}>
Approvals{approvals.length > 0 && <span className={s.cnt}>{approvals.length}</span>}
Approvals{apprCount > 0 && <span className={s.cnt}>{apprCount}</span>}
</button>
</div>
<div className={s.sbBody}>
@@ -507,65 +447,16 @@ export function ConciergeShell() {
</div>
</>
)}
{sbTab === "tasks" && (
<>
{userTasks.length === 0 && (
<div className={s.empty}>Nothing needs you right now. When an agent needs you to do something, it shows up here.</div>
)}
{userTasks.map((t) => (
<div key={t.id} className={s.task}>
<div className={s.taskRow}>
<div className={`${s.taskIc} ${s.run}`}><IcClock /></div>
<div className={s.taskMeta}>
<div className={s.taskT}>{t.title}</div>
<div className={s.taskS}>
{t.workspace_name}<span className={s.pip} />asked {clockTime(t.created_at)}
</div>
{t.detail && (
<div style={{ fontSize: 12, color: "var(--tx-3)", marginTop: 6, lineHeight: 1.45 }}>
{t.detail}
</div>
)}
</div>
</div>
<div className={s.taskActions}>
<button className={`${s.tbtn} ${s.done}`} disabled={resolving === t.id} onClick={() => resolveTask(t, "done")}>
<IcCheck />Done
</button>
<button className={s.tbtn} disabled={resolving === t.id} onClick={() => resolveTask(t, "dismissed")}>
Dismiss
</button>
</div>
</div>
))}
</>
)}
{sbTab === "approvals" && (
<>
{approvals.length === 0 && (
<div className={s.empty}>No pending approvals. Destructive actions await sign-off here.</div>
)}
{approvals.map((a) => (
<div key={a.id} className={s.apprCard} style={{ marginBottom: 7 }}>
<div className={s.apprRow}>
<div className={s.apprIc}><IcTrash /></div>
<div className={s.apprMeta}>
<div className={s.apprT}>{a.action.replace(/_/g, " ")} <code>{a.workspace_name}</code></div>
<div className={s.apprS}>{a.reason || "destructive"}</div>
</div>
</div>
<div className={s.apprActions}>
<button className={`${s.btn} ${s.approve} ${s.flex}`} disabled={deciding === a.id} onClick={() => decide(a, "approved")}>
{deciding === a.id ? "…" : "Approve"}
</button>
<button className={`${s.btn} ${s.deny} ${s.flex}`} disabled={deciding === a.id} onClick={() => decide(a, "denied")}>
{deciding === a.id ? "…" : "Deny"}
</button>
</div>
</div>
))}
</>
)}
{/* 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. */}
<div style={{ display: sbTab === "tasks" ? "block" : "none" }}>
<RequestsInbox kind="task" onCountChange={setTaskCount} />
</div>
<div style={{ display: sbTab === "approvals" ? "block" : "none" }}>
<RequestsInbox kind="approval" onCountChange={setApprCount} />
</div>
</div>
</aside>
@@ -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<RequestRow[]>([]);
// Guards double-submit while a respond POST is in flight (per-item id).
const [acting, setActing] = useState<string | null>(null);
// Which item's More-Info thread is expanded inline (null = none).
const [openThread, setOpenThread] = useState<string | null>(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<string>("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<RequestRow[]>(`/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 && <div className={s.empty}>{emptyCopy}</div>}
{items.map((r) => (
<RequestItem
key={r.id}
row={r}
kind={kind}
acting={acting === r.id}
threadOpen={openThread === r.id}
onRespond={respond}
onToggleThread={() => 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 ? (
<div className={s.apprActions}>
<button
type="button"
className={`${s.btn} ${s.approve} ${s.flex}`}
disabled={acting}
onClick={() => onRespond(row, "approved")}
>
{acting ? "…" : "Approve"}
</button>
<button
type="button"
className={`${s.btn} ${s.deny} ${s.flex}`}
disabled={acting}
onClick={() => onRespond(row, "rejected")}
>
{acting ? "…" : "Reject"}
</button>
<button
type="button"
className={s.btn}
disabled={acting}
onClick={onToggleThread}
aria-expanded={threadOpen}
>
<IcChat /> More Info
</button>
</div>
) : (
<div className={s.taskActions}>
<button
type="button"
className={`${s.tbtn} ${s.done}`}
disabled={acting}
onClick={() => onRespond(row, "done")}
>
<IcCheck />Done
</button>
<button
type="button"
className={s.tbtn}
disabled={acting}
onClick={() => onRespond(row, "rejected")}
>
Reject
</button>
<button
type="button"
className={s.tbtn}
disabled={acting}
onClick={onToggleThread}
aria-expanded={threadOpen}
>
<IcChat />More Info
</button>
</div>
);
if (isApproval) {
return (
<div className={s.apprCard} style={{ marginBottom: 7 }} data-testid="request-item" data-kind="approval">
<div className={s.apprRow}>
<div className={s.apprIc}><IcTrash /></div>
<div className={s.apprMeta}>
<div className={s.apprT}>
{row.title} <code>{requesterLabel(row)}</code>
</div>
<div className={s.apprS}>
<span data-testid="request-status">{badge}</span>
{" · asked "}{clockTime(row.created_at)}
</div>
{row.detail && <div className={s.apprS} style={{ marginTop: 4 }}>{row.detail}</div>}
{resolvedBy && <div className={s.apprS} style={{ marginTop: 4 }}>{resolvedBy}</div>}
</div>
</div>
{actions}
{threadOpen && (
<MoreInfoThread requestId={row.id} responderId={responderId} />
)}
</div>
);
}
return (
<div className={s.task} data-testid="request-item" data-kind="task">
<div className={s.taskRow}>
<div className={`${s.taskIc} ${s.run}`}><IcClock /></div>
<div className={s.taskMeta}>
<div className={s.taskT}>{row.title}</div>
<div className={s.taskS}>
{requesterLabel(row)}<span className={s.pip} />
<span data-testid="request-status">{badge}</span>
{" · asked "}{clockTime(row.created_at)}
</div>
{row.detail && (
<div style={{ fontSize: 12, color: "var(--tx-3)", marginTop: 6, lineHeight: 1.45 }}>
{row.detail}
</div>
)}
{resolvedBy && (
<div style={{ fontSize: 12, color: "var(--tx-3)", marginTop: 6 }}>{resolvedBy}</div>
)}
</div>
</div>
{actions}
{threadOpen && (
<MoreInfoThread requestId={row.id} responderId={responderId} />
)}
</div>
);
}
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<RequestMessageRow[]>([]);
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [loaded, setLoaded] = useState(false);
const load = useCallback(() => {
api.get<RequestWithThread>(`/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 (
<div
data-testid="more-info-thread"
style={{
borderTop: "1px solid var(--hair)",
padding: "11px 13px",
background: "var(--card-2)",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 8, maxHeight: 180, overflowY: "auto" }}>
{loaded && messages.length === 0 && (
<div style={{ fontSize: 11.5, color: "var(--tx-3)" }}>
No messages yet. Ask the agent for more detail.
</div>
)}
{messages.map((m) => (
<div key={m.id} data-testid="thread-message" style={{ fontSize: 12, lineHeight: 1.45 }}>
<span style={{ fontWeight: 600, color: "var(--tx-2)" }}>
{m.author_type === "user" ? "You" : m.author_id || "agent"}
</span>
<span style={{ color: "var(--tx-3)", marginLeft: 6, fontSize: 10.5 }}>
{clockTime(m.created_at)}
</span>
<div style={{ color: "var(--tx-2)", marginTop: 2 }}>{m.body}</div>
</div>
))}
</div>
<div style={{ display: "flex", gap: 7, marginTop: 9 }}>
<input
aria-label="More info message"
data-testid="more-info-input"
value={draft}
onChange={(e) => 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)",
}}
/>
<button
type="button"
className={`${s.tbtn}`}
data-testid="more-info-send"
disabled={sending || draft.trim().length === 0}
onClick={send}
>
<IcSend />{sending ? "…" : "Send"}
</button>
</div>
</div>
);
}
@@ -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<unknown>>(),
mockApiPost: vi.fn<(args: unknown[]) => Promise<unknown>>(),
mockFetchSession: vi.fn<(args: unknown[]) => Promise<unknown>>(),
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(<RequestsInbox kind="task" />); });
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(<RequestsInbox kind="task" />); });
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(<RequestsInbox kind="task" />); });
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(<RequestsInbox kind="task" />); });
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(<RequestsInbox kind="approval" />); });
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(<RequestsInbox kind="approval" />); });
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(<RequestsInbox kind="approval" />); });
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(<RequestsInbox kind="task" />); });
// 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(<RequestsInbox kind="task" />); });
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(<RequestsInbox kind="task" />); });
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(<RequestsInbox kind="task" />); });
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();
});
});
+53
View File
@@ -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<string> = new Set<string>([
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);
}