From 0a049a338008b15104f9c7c2a16e6a61673cea1a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 22:16:06 -0700 Subject: [PATCH] feat(web): + menu replaces Mission Control; workbench view with activity, artifacts, approvals and docked composer (CL-8431) --- apps/web/src/app.css | 200 +++------ apps/web/src/chat/strings.ts | 3 +- apps/web/src/pages/mission-control-page.tsx | 401 ------------------ .../src/pages/run-status-tone-parity.test.ts | 13 - apps/web/src/pages/workbench-room-page.tsx | 215 +++++++++- apps/web/src/pending-approvals.test.tsx | 10 +- apps/web/src/routes.tsx | 22 +- apps/web/src/shell/sidebar.tsx | 96 +++-- 8 files changed, 322 insertions(+), 638 deletions(-) delete mode 100644 apps/web/src/pages/mission-control-page.tsx diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 1a5001d9e..3b8e7d256 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1139,47 +1139,6 @@ select:disabled, color: var(--primary); } -/* Mission Control — pinned above the footer rail as its own row (DESIGN.md), - never a 7th button mixed into Routines/Files/Skills/Agents/Tools/ - Insights below. The border-bottom is what reads it as a separate band - rather than the first row of that rail. */ -.shell-sidebar-mission-control { - flex-shrink: 0; - padding: 0.35rem; - border-bottom: 1px solid var(--border); -} -.shell-sidebar-mission-control-row { - display: flex; - align-items: center; - gap: 0.55rem; - width: 100%; - padding: 0.45rem 0.4rem; - border: 0; - border-radius: 0; - background: transparent; - color: var(--foreground); - font: inherit; - font-weight: 600; - cursor: pointer; - text-align: left; -} -.shell-sidebar-mission-control-row svg { - width: 1.05rem; - height: 1.05rem; - color: var(--muted-foreground); -} -.shell-sidebar-mission-control-row:hover, -.shell-sidebar-mission-control-row[data-active="true"] { - background: color-mix(in srgb, var(--foreground) 8%, transparent); -} -.shell-sidebar-mission-control-row[data-active="true"] { - box-shadow: inset 2px 0 0 0 var(--shell-accent); -} -.shell-sidebar-mission-control-row:focus-visible { - outline: 1px solid var(--foreground); - outline-offset: -1px; -} - /* Footer (reference shape): a Tools row, then the account row that anchors the pop-up menu. Both are full-width quiet rows; the account row has no top margin of its own so the two never read as separate @@ -3654,144 +3613,59 @@ tr.insights-row-clickable:hover { } } -/* Mission Control — stat strip, then a main column - (Needs you / In flight) beside a 320px rail (Jump back in / This week), - the same "stack under 1100px" rule DESIGN.md gives every right rail. */ -.mission-control-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 20rem; - grid-template-areas: - "stats stats" - "main rail"; - gap: 1rem; - width: 100%; -} -.mission-control-layout > .mission-control-stats { - grid-area: stats; -} -.mission-control-layout > .mission-control-panel { - grid-area: main; -} -.mission-control-layout > .mission-control-panel + .mission-control-panel { - margin-top: 1rem; -} -.mission-control-layout > .mission-control-rail { - grid-area: rail; - display: flex; - flex-direction: column; - gap: 1rem; - min-width: 0; -} -@media (max-width: 1100px) { - .mission-control-layout { - grid-template-columns: 1fr; - grid-template-areas: - "stats" - "main" - "rail"; - } -} - -.mission-control-panel { +/* A workbench room's left info column: activity, artifacts, approvals and + participants, stacked as quiet panels beside the running chat. */ +.room-info-panel { padding: 0.9rem 1rem; border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); background: var(--card, var(--background)); min-width: 0; } -.mission-control-panel-header { +.room-info-panel + .room-info-panel { + margin-top: 1rem; +} +.room-info-panel-header { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.6rem; } -.mission-control-panel-header h2 { +.room-info-panel-header h2 { margin: 0; font-size: 0.85rem; font-weight: 700; } -.mission-control-hint { - font-size: 0.72rem; - color: var(--muted-foreground); -} -.mission-control-hint-link { - margin-left: auto; - font-size: 0.75rem; - font-weight: 700; - color: var(--primary); -} -.mission-control-empty-note { +.room-info-empty-note { margin: 0; font-size: 0.8rem; color: var(--muted-foreground); } -.mission-control-cell-primary { +.room-info-cell-primary { font-size: 0.85rem; font-weight: 700; } -.mission-control-cell-context { +.room-info-cell-context { font-size: 0.7rem; color: var(--muted-foreground); } -.mission-control-row-actions { +.room-info-row-actions { display: flex; justify-content: flex-end; gap: 0.4rem; } -.mission-control-opt { - white-space: nowrap; -} -@media (max-width: 880px) { - .mission-control-opt { - display: none; - } -} - -.mission-control-rows { +.room-info-approval-list, +.room-info-artifact-list { display: flex; flex-direction: column; - border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); -} -.mission-control-jump-row { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; gap: 0.6rem; - width: 100%; - padding: 0.55rem 0.7rem; - border: 0; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); - background: transparent; - color: var(--foreground); - font: inherit; - text-align: left; - cursor: pointer; -} -button.mission-control-jump-row:hover { - background: color-mix(in srgb, var(--primary) 8%, transparent); -} -.mission-control-rows > .mission-control-jump-row:last-child { - border-bottom: 0; -} -.mission-control-jump-row svg { - width: 1rem; - height: 1rem; - color: var(--muted-foreground); - flex-shrink: 0; + margin: 0; + padding: 0; + list-style: none; } -.mission-control-jump-body { +.room-info-approval-row { display: flex; flex-direction: column; - min-width: 0; -} -.mission-control-jump-when { - font-size: 0.72rem; - color: var(--muted-foreground); - white-space: nowrap; -} -.mission-control-week-summary { - margin: 0; - font-size: 0.85rem; - font-weight: 600; + gap: 0.4rem; } /* Sidebar sections: Workbenches above Chats, each with its own label. */ @@ -3861,12 +3735,46 @@ button.mission-control-jump-row:hover { width: 100%; } -/* A workbench room: timeline in the stage, sub-thread replies beside it. */ +/* A workbench room: a left info column (activity, artifacts, approvals, + participants), the running chat in the main column, and an optional + sub-thread panel on the right. */ .room-layout { display: flex; min-height: 0; flex: 1; } +.room-info-column { + display: flex; + flex-direction: column; + width: 18rem; + flex-shrink: 0; + min-height: 0; + overflow-y: auto; + padding: 1rem; + border-right: 1px solid var(--border); +} +@media (max-width: 1100px) { + .room-info-column { + display: none; + } +} +.room-main { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + min-height: 0; +} +.room-main-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; +} +.room-main-composer { + flex-shrink: 0; + border-top: 1px solid var(--border); + padding: 0.75rem 0; +} .room-participants { display: flex; flex-wrap: wrap; diff --git a/apps/web/src/chat/strings.ts b/apps/web/src/chat/strings.ts index 5fd21767d..4b2dad1c6 100644 --- a/apps/web/src/chat/strings.ts +++ b/apps/web/src/chat/strings.ts @@ -26,9 +26,8 @@ export const CHAT_STRINGS = { couldNotLoadMessages: "messages", workbenchNotFoundTitle: "This workbench isn't here anymore", workbenchNotFoundDescription: "It may have been deleted, or the link is out of date.", - /** @deprecated Prefer Mission Control / New workbench recovery. */ + /** @deprecated Prefer New workbench recovery. */ workbenchNotFoundAction: "Back to workbenches", - workbenchNotFoundMissionControlAction: "Mission Control", workbenchNotFoundNewWorkbenchAction: NEW_WORKBENCH_LABEL, /** Create-workbench CTA on every mint surface. */ newWorkbenchAction: NEW_WORKBENCH_LABEL, diff --git a/apps/web/src/pages/mission-control-page.tsx b/apps/web/src/pages/mission-control-page.tsx deleted file mode 100644 index ce23566d5..000000000 --- a/apps/web/src/pages/mission-control-page.tsx +++ /dev/null @@ -1,401 +0,0 @@ -// Mission Control: the bench's dashboard — what needs a -// decision, what's running, and a way back into recent context. A new -// top-level route (`/mission-control`), never `/` — `/` stays the Myra -// land-hop redirect (see routes.tsx's header comment). Every panel here is -// backed by a query already used elsewhere in this app (pending approvals, -// top-level runs); nothing on this page is invented. A panel with no honest -// data source renders an empty state naming what's missing instead of a -// fabricated number. dropped the "Runs today" / "Spend today" KPI -// tiles and the "This week" panel — both read packages/insights routes with -// no stock equivalent. - -import { - Badge, - Button, - PageShell, - RichEmptyState, - RUN_STATUS_TONE, - Skeleton, - StatGrid, - StatGridItem, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, - formatRelativeTime, - toast, -} from "@corbits/react-ui"; -import type { BadgeTone } from "@corbits/react-ui"; -import { ChatCircleDots, Plus, Robot } from "@/lib/icons"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { CHAT_STRINGS, type Workbench } from "@/chat"; -import { runOutcomeStatus, runStatusLabel, withListingAbandoned } from "@corbits/workflows/client"; - -import { approveApproval, rejectApproval } from "../api"; -import { useBench } from "../bench-context"; -import { usePendingApprovals, type PendingApproval } from "../pending-approvals"; -import { tenantKeys } from "../query-client"; -import { NEW_WORKBENCH_PATH } from "../routes"; -import { useBenchActivity } from "../shell/bench-activity"; -import type { RoutineActivityItem } from "../shell/routine-activity"; -import { StageTopBar } from "../shell/stage-top-bar"; -import { workbenchPath } from "../workbench-path"; - -function dash(value: string | number | null | undefined): string { - if (value === null || value === undefined || value === "") return "—"; - return String(value); -} - -type InFlightRow = { - readonly key: string; - readonly label: string; - readonly context: string; - readonly createdAt: string; - readonly statusLabel: string; - readonly statusTone: BadgeTone; - readonly steps: string; -}; - -function listingFromRoutine(routine: RoutineActivityItem, now: number) { - return withListingAbandoned( - { - createdAt: routine.startedAt, - status: routine.status, - ...(routine.endedAt !== undefined ? { endedAt: routine.endedAt } : {}), - ...(routine.hasInFlightTurn !== undefined - ? { hasInFlightTurn: routine.hasInFlightTurn } - : {}), - ...(routine.turns !== undefined ? { turns: routine.turns } : {}), - }, - now, - ); -} - -function routineInFlightRow(routine: RoutineActivityItem, now: number): InFlightRow { - const status = runOutcomeStatus(listingFromRoutine(routine, now), now) ?? routine.status; - return { - key: `routine:${routine.id}`, - label: routine.name, - context: "routine", - createdAt: routine.startedAt, - statusLabel: runStatusLabel(status), - statusTone: RUN_STATUS_TONE.running, - // The routine feed carries no step count — an honest dash, not a guess. - steps: "—", - }; -} - -/** Every routine this bench is actively running right now, newest first. */ -export function computeInFlightRows( - routines: readonly RoutineActivityItem[], - now: number = Date.now(), -): readonly InFlightRow[] { - const rows = routines - .filter((routine) => runOutcomeStatus(listingFromRoutine(routine, now), now) === "running") - .map((routine) => routineInFlightRow(routine, now)); - return rows.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); -} - -type JumpBackRow = { - readonly key: string; - readonly icon: "chat" | "agent"; - readonly label: string; - readonly context: string; - readonly when: string; - readonly onSelect?: () => void; -}; - -/** Recent conversations and agents to jump back into — real bench activity - * (workbenches, chats, visible agent definitions), sorted by their own - * recency field. Nothing here is invented: a workbench with no recorded - * activity timestamp is left out rather than given a fake one. */ -export function computeJumpBackRows( - workbenches: readonly Workbench[], - chats: readonly Workbench[], - agents: readonly { id: string; name: string; createdAt: string }[], - navigate: (to: string) => void, - limit = 4, -): readonly JumpBackRow[] { - const conversations = [...workbenches, ...chats] - .filter( - (bench): bench is Workbench & { lastActivityAt: string } => - bench.lastActivityAt !== undefined, - ) - .map((bench) => ({ - key: `bench:${bench.id}`, - icon: "chat" as const, - label: bench.title, - context: bench.kind === "chat" ? "chat" : "workbench", - when: bench.lastActivityAt, - onSelect: () => navigate(workbenchPath(bench.id)), - })); - const agentRows = agents.map((agent) => ({ - key: `agent:${agent.id}`, - icon: "agent" as const, - label: agent.name, - context: "agent", - when: agent.createdAt, - })); - return [...conversations, ...agentRows] - .sort((a, b) => Date.parse(b.when) - Date.parse(a.when)) - .slice(0, limit); -} - -function ApprovalRow({ - item, - tenantId, -}: { - readonly item: PendingApproval; - readonly tenantId: string; -}) { - const queryClient = useQueryClient(); - const resolveMutation = useMutation({ - mutationFn: (action: "approve" | "deny") => - action === "approve" ? approveApproval(tenantId, item.id) : rejectApproval(tenantId, item.id), - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: tenantKeys.pendingApprovals(tenantId), - }); - }, - onError: (cause, action) => { - toast( - cause instanceof Error - ? cause.message - : `Couldn't ${action === "approve" ? "approve" : "deny"} that request.`, - ); - }, - }); - const pending = resolveMutation.isPending ? resolveMutation.variables : null; - - return ( - - - {item.headline} - - {item.agentName} - {formatRelativeTime(item.createdAt)} - -
- - -
-
-
- ); -} - -export function MissionControlRoute({ navigate }: { readonly navigate: (to: string) => void }) { - const { selectedTenantId: tenantId } = useBench(); - const approvalsQuery = usePendingApprovals(tenantId); - const activity = useBenchActivity(tenantId); - - const pendingApprovals = approvalsQuery.kind === "ready" ? approvalsQuery.data : null; - const oldestWaitingAt = - pendingApprovals !== null && pendingApprovals.length > 0 - ? pendingApprovals.reduce((oldest, item) => - Date.parse(item.createdAt) < Date.parse(oldest.createdAt) ? item : oldest, - ).createdAt - : null; - - const inFlightRows = activity.kind === "ready" ? computeInFlightRows(activity.routines) : []; - const activeRunsCount = activity.kind === "ready" ? inFlightRows.length : null; - - const jumpBackRows = - activity.kind === "ready" - ? computeJumpBackRows(activity.workbenches, activity.chats, activity.agents, navigate) - : []; - - return ( -
- navigate(NEW_WORKBENCH_PATH)}> - {CHAT_STRINGS.newWorkbenchAction} - - } - /> -
- -
- - 0 ? "live now" : "nothing running" - } - /> - 0} - sub={ - oldestWaitingAt !== null - ? `oldest ${formatRelativeTime(oldestWaitingAt)}` - : "all caught up" - } - /> - - -
-
-

Needs you

- approvals block agents until you act -
- {approvalsQuery.kind === "loading" ? : null} - {approvalsQuery.kind === "error" ? ( - - ) : null} - {approvalsQuery.kind === "unauthenticated" ? ( - - ) : null} - {pendingApprovals !== null && pendingApprovals.length === 0 ? ( - - ) : null} - {pendingApprovals !== null && pendingApprovals.length > 0 ? ( - - - - Request - From - Waiting - - - - - {tenantId !== null && - pendingApprovals.map((item) => ( - - ))} - -
- ) : null} -
- -
-
-

In flight

- live -
- {activity.kind === "loading" ? : null} - {activity.kind === "error" ? ( - - ) : null} - {activity.kind !== "loading" && - activity.kind !== "error" && - inFlightRows.length === 0 ? ( - - ) : null} - {inFlightRows.length > 0 ? ( - - - - Run - Elapsed - Steps - - - - - {inFlightRows.map((row) => ( - - - {row.label} -
- {row.context} -
- - {formatRelativeTime(row.createdAt)} - - {row.steps} - - {row.statusLabel} - -
- ))} -
-
- ) : null} -
- - -
-
-
-
- ); -} diff --git a/apps/web/src/pages/run-status-tone-parity.test.ts b/apps/web/src/pages/run-status-tone-parity.test.ts index b87b55dbf..453a185cc 100644 --- a/apps/web/src/pages/run-status-tone-parity.test.ts +++ b/apps/web/src/pages/run-status-tone-parity.test.ts @@ -19,9 +19,7 @@ import { describe, expect, test } from "bun:test"; import { RUN_STATUS_TONE } from "@corbits/react-ui"; import { statusTone } from "./insights-page"; -import { computeInFlightRows } from "./mission-control-page"; import { AGENT_ROSTER_STATUS_TONE } from "./agents-page"; -import type { RoutineActivityItem } from "../shell/routine-activity"; describe("run-status tone parity with react-ui's RUN_STATUS_TONE", () => { test("Insights' statusTone agrees with canonical for every shared status", () => { @@ -31,17 +29,6 @@ describe("run-status tone parity with react-ui's RUN_STATUS_TONE", () => { expect(statusTone("stopped")).toBe(RUN_STATUS_TONE.stopped); }); - test("Mission Control's in-flight rows agree with canonical for every shared status", () => { - const routine: RoutineActivityItem = { - id: "rtn_1", - name: "Daily brief", - status: "running", - startedAt: new Date().toISOString(), - }; - const [routineRow] = computeInFlightRows([routine]); - expect(routineRow?.statusTone).toBe(RUN_STATUS_TONE.running); - }); - test("Agents' roster status tone (a genuinely different vocabulary) still agrees where it overlaps", () => { // AgentRosterStatus is its own enum, not RunStatus — this is the one // local map the ticket calls defensible. It only shares one name diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index a57212247..d437675d0 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -1,9 +1,20 @@ // A workbench is a child tenant, and its room is that tenant's mailbox: // the timeline is the tenant's mail threads, participants are its -// principals, and a message is addressed to every agent in the room. -// Sub-threads are native in-reply-to chains, shown in a side panel. +// principals, and a message is addressed to every agent in the room. The +// left info column folds in what Mission Control used to show for a +// bench overall — here scoped to this one workbench: latest activity, +// relevant artifacts, and pending approvals with approve/deny. Sub-threads +// are native in-reply-to chains, shown in a side panel. -import { Button, EmptyState, PageShell, Textarea } from "@corbits/react-ui"; +import { + Button, + EmptyState, + PageShell, + Skeleton, + Textarea, + formatRelativeTime, + toast, +} from "@corbits/react-ui"; import { WarningCircle } from "@/lib/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; @@ -18,9 +29,12 @@ import { type RoomMessage, type RoomParticipant, } from "@/chat/threads-api"; +import { approveApproval, ArtifactListPageSchema, rejectApproval, useAPIQuery } from "../api"; import { useBench } from "../bench-context"; import { createFetchStockHub } from "../needs-converge"; +import { usePendingApprovals, type PendingApproval } from "../pending-approvals"; import { roomKeys } from "../chat-path"; +import { tenantKeys } from "../query-client"; import { StageTopBar } from "../shell/stage-top-bar"; import { workbenchIdFromPath } from "../workbench-path"; @@ -113,6 +127,152 @@ function ParticipantList({ participants }: { readonly participants: readonly Roo ); } +function ApprovalRow({ + item, + tenantId, +}: { + readonly item: PendingApproval; + readonly tenantId: string; +}) { + const queryClient = useQueryClient(); + const resolveMutation = useMutation({ + mutationFn: (action: "approve" | "deny") => + action === "approve" ? approveApproval(tenantId, item.id) : rejectApproval(tenantId, item.id), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: tenantKeys.pendingApprovals(tenantId), + }); + }, + onError: (cause, action) => { + toast( + cause instanceof Error + ? cause.message + : `Couldn't ${action === "approve" ? "approve" : "deny"} that request.`, + ); + }, + }); + const pending = resolveMutation.isPending ? resolveMutation.variables : null; + + return ( +
  • +
    + {item.headline} +
    + + {item.agentName} · {formatRelativeTime(item.createdAt)} + +
    +
    + + +
    +
  • + ); +} + +/** The left info column: what Mission Control used to show, scoped to this + * one workbench — its latest activity, its relevant artifacts, and what's + * pending on it — plus the participant roster already read for the room. */ +function RoomInfoColumn({ + roomTenantId, + latestMessage, + participants, +}: { + readonly roomTenantId: string; + readonly latestMessage: RoomMessage | undefined; + readonly participants: readonly RoomParticipant[]; +}) { + const approvalsQuery = usePendingApprovals(roomTenantId); + const artifactsQuery = useAPIQuery( + `/api/tenants/${roomTenantId}/artifacts`, + ArtifactListPageSchema, + ); + const pendingApprovals = approvalsQuery.kind === "ready" ? approvalsQuery.data : null; + + return ( + + ); +} + function Room({ roomTenantId }: { readonly roomTenantId: string }) { const queryClient = useQueryClient(); const [openThread, setOpenThread] = useState(null); @@ -159,6 +319,7 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) { }); const messages = timeline.data ?? []; + const latestMessage = [...messages].sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0]; const opened = messages.find((message) => message.id === openThread); const failure: unknown = timeline.error ?? participants.error; @@ -178,26 +339,38 @@ function Room({ roomTenantId }: { readonly roomTenantId: string }) {
    - - -
    - {messages.map((message) => ( - setOpenThread(target.id)} + +
    +
    + +
    + {messages.map((message) => ( + setOpenThread(target.id)} + /> + ))} +
    + {send.error === null ? null : ( +

    {errorText(send.error)}

    + )} +
    +
    +
    + + send.mutate({ content: text })} /> - ))} +
    - {send.error === null ? null : ( -

    {errorText(send.error)}

    - )} - send.mutate({ content: text })} - /> - +
    {opened === undefined ? null : (