feat(mobile): Tasks + Approvals inbox — decision-on-the-go (core#2697 Phase 1) #2765

Merged
devops-engineer merged 1 commits from feat/mobile-inbox-tasks-approvals into main 2026-06-13 19:59:35 +00:00
5 changed files with 293 additions and 14 deletions
+6 -2
View File
@@ -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");
});
});
+2 -1
View File
@@ -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" },