feat(requests): P3 — canvas Tasks/Approvals tabs on unified requests (RFC) #2527
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user