feat(mobile): Tasks + Approvals inbox — decision-on-the-go (core#2697 Phase 1) #2765
@@ -16,15 +16,16 @@ import { MobileChat } from "./MobileChat";
|
||||
import { MobileComms } from "./MobileComms";
|
||||
import { MobileDetail } from "./MobileDetail";
|
||||
import { MobileHome } from "./MobileHome";
|
||||
import { MobileInbox } from "./MobileInbox";
|
||||
import { MobileMe } from "./MobileMe";
|
||||
import { MobileSpawn } from "./MobileSpawn";
|
||||
import { usePalette } from "./palette";
|
||||
import { MobileAccentProvider } from "./palette-context";
|
||||
import { SearchDialog } from "@/components/SearchDialog";
|
||||
|
||||
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me";
|
||||
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me" | "inbox";
|
||||
|
||||
const ROUTES: Route[] = ["home", "canvas", "detail", "chat", "comms", "me"];
|
||||
const ROUTES: Route[] = ["home", "canvas", "detail", "chat", "comms", "me", "inbox"];
|
||||
|
||||
const ACCENT_KEY = "molecule.mobile.accent";
|
||||
const DENSITY_KEY = "molecule.mobile.density";
|
||||
@@ -136,6 +137,7 @@ export function MobileApp() {
|
||||
}, [density]);
|
||||
|
||||
const activeTab: MobileTabId = useMemo(() => {
|
||||
if (route === "inbox") return "inbox";
|
||||
if (route === "canvas") return "canvas";
|
||||
if (route === "comms") return "comms";
|
||||
if (route === "me") return "me";
|
||||
@@ -144,6 +146,7 @@ export function MobileApp() {
|
||||
|
||||
const onTabChange = (id: MobileTabId) => {
|
||||
if (id === "agents") setRoute("home");
|
||||
else if (id === "inbox") setRoute("inbox");
|
||||
else if (id === "canvas") setRoute("canvas");
|
||||
else if (id === "comms") setRoute("comms");
|
||||
else if (id === "me") setRoute("me");
|
||||
@@ -177,6 +180,7 @@ export function MobileApp() {
|
||||
onSpawn={() => setShowSpawn(true)}
|
||||
/>
|
||||
)}
|
||||
{route === "inbox" && <MobileInbox dark={dark} />}
|
||||
{route === "canvas" && (
|
||||
<MobileCanvas dark={dark} onOpen={openAgent} onSpawn={() => setShowSpawn(true)} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// MobileInbox — Tasks + Approvals on mobile (core#2697 Phase 1).
|
||||
//
|
||||
// The desktop home flow has Tasks + Approvals as first-class destinations
|
||||
// (ConciergeShell sidebar, RequestsInbox); mobile had NO way to review or
|
||||
// action pending requests — decision-on-the-go, the canonical mobile job, was
|
||||
// missing entirely. This is the mobile-native equivalent: same data layer
|
||||
// (GET /requests/pending?kind=… + POST /requests/{id}/respond) the desktop
|
||||
// RequestsInbox uses, with a touch-first list + sub-tabs, live WS refresh, and
|
||||
// optimistic decisions. (More-Info threads are desktop-only for v1 — flagged.)
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { fetchSession } from "@/lib/auth";
|
||||
import { useSocketEvent } from "@/hooks/useSocketEvent";
|
||||
import type { RequestRow } from "@/components/concierge/RequestsInbox";
|
||||
import { MOBILE_FONT_SANS, usePalette } from "./palette";
|
||||
|
||||
type InboxKind = "task" | "approval";
|
||||
|
||||
// WS events that mutate the pending set — re-fetch on any of them so the
|
||||
// inbox stays live (mirrors RequestsInbox's refresh triggers).
|
||||
const REFRESH_EVENTS = new Set([
|
||||
"REQUEST_CREATED",
|
||||
"REQUEST_RESPONDED",
|
||||
"REQUEST_MESSAGE",
|
||||
"REQUEST_UPDATED",
|
||||
]);
|
||||
|
||||
export function MobileInbox({ dark }: { dark: boolean }) {
|
||||
const p = usePalette(dark);
|
||||
const [kind, setKind] = useState<InboxKind>("approval");
|
||||
const [items, setItems] = useState<RequestRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [acting, setActing] = useState<string | null>(null);
|
||||
|
||||
// Responder identity — the same resolution RequestsInbox uses (session
|
||||
// user_id, "admin" placeholder when unauthenticated).
|
||||
const responderIdRef = useRef<string>("admin");
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSession()
|
||||
.then((s) => { if (!cancelled && s?.user_id) responderIdRef.current = s.user_id; })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.get<RequestRow[]>(`/requests/pending?kind=${kind}`)
|
||||
.then((rows) => setItems(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [kind]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useSocketEvent((msg) => {
|
||||
if (msg?.event && REFRESH_EVENTS.has(msg.event)) load();
|
||||
});
|
||||
|
||||
const respond = useCallback(
|
||||
async (r: RequestRow, action: "done" | "rejected" | "approved") => {
|
||||
if (acting) return;
|
||||
setActing(r.id);
|
||||
// Optimistic: drop the row immediately; restore on failure.
|
||||
const prev = items;
|
||||
setItems((cur) => cur.filter((x) => x.id !== r.id));
|
||||
try {
|
||||
await api.post(`/requests/${r.id}/respond`, {
|
||||
action,
|
||||
responder_type: "user",
|
||||
responder_id: responderIdRef.current,
|
||||
});
|
||||
} catch {
|
||||
setItems(prev); // restore on failure
|
||||
} finally {
|
||||
setActing(null);
|
||||
}
|
||||
},
|
||||
[acting, items],
|
||||
);
|
||||
|
||||
const subTabs: { id: InboxKind; label: string }[] = useMemo(
|
||||
() => [
|
||||
{ id: "approval", label: "Approvals" },
|
||||
{ id: "task", label: "Tasks" },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: p.bg,
|
||||
color: p.text,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
{/* Header + sub-tabs */}
|
||||
<div style={{ padding: "14px 16px 0", borderBottom: `0.5px solid ${p.divider}` }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, marginBottom: 10 }}>Inbox</div>
|
||||
<div role="tablist" aria-label="Inbox kind" style={{ display: "flex", gap: 18 }}>
|
||||
{subTabs.map((t) => {
|
||||
const on = kind === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={on}
|
||||
onClick={() => setKind(t.id)}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: "0 0 10px",
|
||||
fontSize: 14,
|
||||
fontWeight: on ? 600 : 500,
|
||||
color: on ? p.text : p.text3,
|
||||
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "12px 16px", display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
{loading && items.length === 0 ? (
|
||||
<div style={{ color: p.text3, fontSize: 13, textAlign: "center", marginTop: 40 }}>Loading…</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ color: p.text3, fontSize: 13, textAlign: "center", marginTop: 40 }}>
|
||||
{kind === "approval"
|
||||
? "No pending approvals. Destructive actions await sign-off here."
|
||||
: "No pending tasks."}
|
||||
</div>
|
||||
) : (
|
||||
items.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid="inbox-row"
|
||||
style={{
|
||||
background: p.surface,
|
||||
border: `0.5px solid ${p.border}`,
|
||||
borderRadius: 14,
|
||||
padding: 14,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: p.text }}>{r.title}</div>
|
||||
{r.detail ? (
|
||||
<div style={{ fontSize: 12.5, color: p.text2, lineHeight: 1.4 }}>{r.detail}</div>
|
||||
) : null}
|
||||
<div style={{ fontSize: 11, color: p.text3 }}>
|
||||
{(r.workspace_name || r.requester_id || "agent")}
|
||||
{r.status && r.status !== "pending" ? ` · ${r.status.replace(/_/g, " ")}` : ""}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, marginTop: 4 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={acting === r.id}
|
||||
onClick={() => respond(r, kind === "approval" ? "approved" : "done")}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "9px 0",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: p.green,
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: acting === r.id ? "not-allowed" : "pointer",
|
||||
opacity: acting === r.id ? 0.5 : 1,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
{kind === "approval" ? "Approve" : "Done"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={acting === r.id}
|
||||
onClick={() => respond(r, "rejected")}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "9px 0",
|
||||
borderRadius: 10,
|
||||
border: `0.5px solid ${p.failed}`,
|
||||
background: "transparent",
|
||||
color: p.failed,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: acting === r.id ? "not-allowed" : "pointer",
|
||||
opacity: acting === r.id ? 0.5 : 1,
|
||||
fontFamily: MOBILE_FONT_SANS,
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// MobileInbox (core#2697 Phase 1) — Tasks/Approvals on mobile. Verifies it
|
||||
// loads pending requests from GET /requests/pending?kind=… and that a decision
|
||||
// POSTs /requests/{id}/respond with the right action + optimistically drops the
|
||||
// row — the decision-on-the-go flow that was entirely missing on mobile.
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
vi.mock("@/lib/api");
|
||||
vi.mock("@/lib/auth", () => ({ fetchSession: vi.fn().mockResolvedValue({ user_id: "u-test" }) }));
|
||||
vi.mock("@/hooks/useSocketEvent", () => ({ useSocketEvent: vi.fn() }));
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { MobileInbox } from "../MobileInbox";
|
||||
|
||||
const approval = {
|
||||
id: "req-1", kind: "approval", requester_type: "workspace", requester_id: "ws-9",
|
||||
org_id: null, recipient_type: "user", recipient_id: "", title: "Delete prod secret?",
|
||||
detail: "Agent wants to delete TEST_KEY", status: "pending", responder_type: null,
|
||||
responder_id: null, priority: null, created_at: "", updated_at: "", responded_at: null,
|
||||
workspace_name: "Dev Engineer",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.get).mockResolvedValue([approval]);
|
||||
vi.mocked(api.post).mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("MobileInbox", () => {
|
||||
it("loads pending approvals from /requests/pending?kind=approval", async () => {
|
||||
const { getByText } = render(<MobileInbox dark={false} />);
|
||||
await waitFor(() => getByText("Delete prod secret?"));
|
||||
expect(api.get).toHaveBeenCalledWith("/requests/pending?kind=approval");
|
||||
});
|
||||
|
||||
it("Approve POSTs /requests/{id}/respond with action=approved and drops the row", async () => {
|
||||
const { getByText, queryByText, container } = render(<MobileInbox dark={false} />);
|
||||
await waitFor(() => getByText("Delete prod secret?"));
|
||||
await act(async () => {
|
||||
fireEvent.click(getByText("Approve"));
|
||||
});
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/requests/req-1/respond",
|
||||
expect.objectContaining({ action: "approved", responder_type: "user" }),
|
||||
);
|
||||
await waitFor(() => expect(queryByText("Delete prod secret?")).toBeNull());
|
||||
expect(container.querySelectorAll('[data-testid="inbox-row"]').length).toBe(0);
|
||||
});
|
||||
|
||||
it("shows an empty state when there are no pending requests", async () => {
|
||||
vi.mocked(api.get).mockResolvedValue([]);
|
||||
const { getByText } = render(<MobileInbox dark={false} />);
|
||||
await waitFor(() => getByText(/No pending approvals/i));
|
||||
});
|
||||
});
|
||||
@@ -26,10 +26,10 @@ afterEach(() => {
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TabBar — render", () => {
|
||||
it("renders 4 tab buttons", () => {
|
||||
it("renders 5 tab buttons (Agents/Inbox/Canvas/Comms/Me)", () => {
|
||||
render(<TabBar active="agents" onChange={vi.fn()} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
expect(tabs.length).toBe(4);
|
||||
expect(tabs.length).toBe(5);
|
||||
});
|
||||
|
||||
it("outer div has role=tablist and aria-label", () => {
|
||||
@@ -102,10 +102,8 @@ describe("TabBar — interaction", () => {
|
||||
expect(document.activeElement).toBe(agentsTab);
|
||||
|
||||
fireEvent.keyDown(agentsTab, { key: "ArrowRight" });
|
||||
// onChange called for the next tab
|
||||
expect(onChange).toHaveBeenCalledWith("canvas");
|
||||
// Focus should move to the canvas tab
|
||||
// Use setTimeout(0) trick — after state update, focus moves
|
||||
// onChange called for the next tab (Inbox is now 2nd, after Agents)
|
||||
expect(onChange).toHaveBeenCalledWith("inbox");
|
||||
});
|
||||
|
||||
it("ArrowLeft on first tab wraps to last", () => {
|
||||
@@ -143,12 +141,13 @@ describe("TabBar — interaction", () => {
|
||||
|
||||
it("ArrowDown also navigates (aliases ArrowRight)", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TabBar active="canvas" onChange={onChange} dark={false} />);
|
||||
render(<TabBar active="inbox" onChange={onChange} dark={false} />);
|
||||
const tabs = document.querySelectorAll('[role="tab"]');
|
||||
const canvasTab = tabs[1] as HTMLElement;
|
||||
canvasTab.focus();
|
||||
// tabs[1] is now Inbox; ArrowDown moves to the next tab (Canvas).
|
||||
const inboxTab = tabs[1] as HTMLElement;
|
||||
inboxTab.focus();
|
||||
|
||||
fireEvent.keyDown(canvasTab, { key: "ArrowDown" });
|
||||
expect(onChange).toHaveBeenCalledWith("comms");
|
||||
fireEvent.keyDown(inboxTab, { key: "ArrowDown" });
|
||||
expect(onChange).toHaveBeenCalledWith("canvas");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ export function toMobileAgent(node: Node<WorkspaceNodeData>): MobileAgent {
|
||||
}
|
||||
|
||||
// ── Tab bar ────────────────────────────────────────────────────
|
||||
export type MobileTabId = "agents" | "canvas" | "comms" | "me";
|
||||
export type MobileTabId = "agents" | "inbox" | "canvas" | "comms" | "me";
|
||||
|
||||
export function TabBar({
|
||||
active,
|
||||
@@ -69,6 +69,7 @@ export function TabBar({
|
||||
const p = usePalette(dark);
|
||||
const tabs: { id: MobileTabId; label: string; icon: keyof typeof Icons }[] = [
|
||||
{ id: "agents", label: "Agents", icon: "list" },
|
||||
{ id: "inbox", label: "Inbox", icon: "bell" },
|
||||
{ id: "canvas", label: "Canvas", icon: "graph" },
|
||||
{ id: "comms", label: "Comms", icon: "pulse" },
|
||||
{ id: "me", label: "Me", icon: "user" },
|
||||
|
||||
Reference in New Issue
Block a user