From bf55f2e667d73d9fe4896767dc7beee698c0288b Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:16:13 -0700 Subject: [PATCH 1/2] feat(web): bench pill opens a drawer that pushes the thread left (CL-9564) --- apps/web/src/app.css | 259 ++++++++++++++++-- apps/web/src/bench/bench-drawer.tsx | 99 +++++++ apps/web/src/bench/bench-pill.tsx | 39 +++ apps/web/src/bench/information-tab.tsx | 103 ++++++++ apps/web/src/pages/workbench-page.tsx | 348 ++++++++++--------------- 5 files changed, 620 insertions(+), 228 deletions(-) create mode 100644 apps/web/src/bench/bench-drawer.tsx create mode 100644 apps/web/src/bench/bench-pill.tsx create mode 100644 apps/web/src/bench/information-tab.tsx diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 7180f16d5..94d390bf8 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -18,7 +18,8 @@ (injected at runtime, unrelated to this stylesheet); this just stops react-ui's override from fighting it. Higher specificity than that rule wins the cascade on its own, so this is safe regardless of load order. */ -[data-sonner-toaster][data-x-position="right"] [data-sonner-toast].corbits-toast { +[data-sonner-toaster][data-x-position="right"] + [data-sonner-toast].corbits-toast { left: auto !important; transform: none !important; } @@ -39,7 +40,8 @@ html { /* Inputs in side drawers already have a visible control boundary. Color that edge on focus so the focus treatment reads as one control, not a second outline outside it. */ -[data-slot="dialog-content"][data-side="right"] [data-slot="input"]:focus-visible { +[data-slot="dialog-content"][data-side="right"] + [data-slot="input"]:focus-visible { outline: none; border-color: var(--accent-rail, var(--primary)); } @@ -112,7 +114,9 @@ select:disabled, Scoped to the sidebar so stage content keeps library defaults. */ .shell-sidebar button:not(:disabled):not([aria-disabled="true"]), .shell-sidebar [data-slot="button"]:not(:disabled):not([aria-disabled="true"]), -.shell-sidebar [data-slot="sidebar-item-row"] button:not(:disabled):not([aria-disabled="true"]), +.shell-sidebar + [data-slot="sidebar-item-row"] + button:not(:disabled):not([aria-disabled="true"]), .shell-sidebar .shell-ch-row:not(:disabled):not([aria-disabled="true"]) { cursor: pointer; } @@ -195,7 +199,10 @@ select:disabled, /* Shared dialog craft — layout/scroll/chrome on react-ui data slots only. Feature forms and dialog body content keep their own styles. */ [data-slot="dialog-content"][data-side="center"] { - width: min(calc(100vw - (var(--dialog-gutter) * 2)), var(--dialog-content-max-width)); + width: min( + calc(100vw - (var(--dialog-gutter) * 2)), + var(--dialog-content-max-width) + ); max-height: var(--dialog-content-max-height); overflow: hidden; } @@ -854,7 +861,8 @@ select:disabled, align-items: center; gap: 0.5rem; padding: 0.45rem 0.7rem; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--foreground) 10%, transparent); } .shell-routine-new { @@ -1028,7 +1036,8 @@ select:disabled, justify-content: space-between; gap: 0.5rem; padding-bottom: 0.55rem; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--foreground) 10%, transparent); } .shell-canvas-pane-heading { @@ -1656,7 +1665,9 @@ select:disabled, } .shell-sidebar .shell-panel-search [data-slot="input"]::placeholder, -.shell-sidebar [data-slot="sidebar-panel-body"] [data-slot="input"]::placeholder { +.shell-sidebar + [data-slot="sidebar-panel-body"] + [data-slot="input"]::placeholder { color: var(--shell-panel-faint); } @@ -1672,7 +1683,9 @@ select:disabled, box-shadow: none; } -.shell-sidebar [data-slot="sidebar-panel-body"] [data-slot="input"]:focus-visible { +.shell-sidebar + [data-slot="sidebar-panel-body"] + [data-slot="input"]:focus-visible { outline: none; border-color: var(--foreground); box-shadow: 0 0 0 1px var(--foreground); @@ -1703,14 +1716,19 @@ select:disabled, left edge. Selector doubles as the kill for react-ui's bg-primary/10 selected wash. */ .shell-sidebar [data-slot="sidebar-item-row"] > div.bg-primary\/10, -.shell-sidebar [data-slot="sidebar-item-row"] > div:has(button[aria-current="true"]) { +.shell-sidebar + [data-slot="sidebar-item-row"] + > div:has(button[aria-current="true"]) { background-color: var(--background); color: var(--shell-accent); box-shadow: inset 2px 0 0 0 var(--shell-accent); } .shell-sidebar [data-slot="sidebar-item-row"] > div.bg-primary\/10 > button, -.shell-sidebar [data-slot="sidebar-item-row"] > div:has(button[aria-current="true"]) > button { +.shell-sidebar + [data-slot="sidebar-item-row"] + > div:has(button[aria-current="true"]) + > button { font-weight: 600; } @@ -1748,7 +1766,12 @@ select:disabled, } /* Mock .count: the trailing meta slot when it is plain text. */ -.shell-sidebar [data-slot="sidebar-item-row"] > div > button > span + span:last-child { +.shell-sidebar + [data-slot="sidebar-item-row"] + > div + > button + > span + + span:last-child { font-size: 0.72rem; color: var(--shell-panel-faint); } @@ -2254,7 +2277,8 @@ select:disabled, attention-seeking animation, fully off under reduced motion. DESIGN.md's motion ceiling is 300ms — stays at 280ms. */ .onboarding-phase { - animation: onboarding-phase-in var(--duration-large, 300ms) var(--ease-out, ease) both; + animation: onboarding-phase-in var(--duration-large, 300ms) + var(--ease-out, ease) both; } /* The wait phases drop the usual title/subtitle/form stack for the shared @@ -2536,7 +2560,8 @@ select:disabled, .insights-grid > .insights-panel { border: 0; - border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--foreground) 10%, transparent); } .insights-grid > .insights-panel:last-child { @@ -2834,7 +2859,11 @@ tr.insights-row-clickable:hover { } /* The current row's copy follows the row's accent ink. */ -.shell-sidebar [data-slot="sidebar-item-row"] button[aria-current="true"] .panel-row-copy strong { +.shell-sidebar + [data-slot="sidebar-item-row"] + button[aria-current="true"] + .panel-row-copy + strong { color: inherit; } @@ -3719,7 +3748,9 @@ tr.insights-row-clickable:hover { list-style: none; } /* A stopped schedule stays listed, dimmed, since only a new one revives it. */ -.workbench-info-schedule-list > li[data-stopped="true"] .workbench-info-cell-primary { +.workbench-info-schedule-list + > li[data-stopped="true"] + .workbench-info-cell-primary { color: var(--muted-foreground); text-decoration: line-through; } @@ -3909,21 +3940,205 @@ tr.insights-row-clickable:hover { min-height: 0; flex: 1; } -.workbench-info-column { +.wb { + display: grid; + grid-template-columns: minmax(0, 1fr) 0px; + flex: 1; + min-height: 0; + position: relative; + transition: grid-template-columns 320ms var(--ease-drawer); +} +.wb[data-drawer="open"] { + grid-template-columns: minmax(0, 1fr) min(420px, 42%); +} +.wb-thread { + position: relative; + min-width: 0; +} +.bench-pill { + position: absolute; + top: 10px; + left: 50%; + transform: translateX(-50%); + z-index: 20; + display: inline-flex; + align-items: center; + gap: 8px; + height: 36px; + max-width: min(440px, calc(100% - 120px)); + padding: 0 10px 0 6px; + border: 0; + border-radius: var(--r-full); + background: color-mix(in srgb, var(--surface) 88%, transparent); + backdrop-filter: blur(12px); + box-shadow: var(--floating); + font-size: 13.5px; + color: var(--ink); + cursor: pointer; + transition: + box-shadow 150ms ease-out, + transform 160ms var(--ease-out); +} +.bench-pill:hover { + box-shadow: var(--overlay); +} +.bench-pill:active { + transform: translateX(-50%) scale(0.97); +} +.bench-pill b { + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bench-pill-status { + color: var(--ink-3); + white-space: nowrap; +} +.drawer-cell { + min-width: 0; + overflow: hidden; + position: relative; +} +.drawer:focus { + outline: none; +} +.drawer { + position: absolute; + top: 8px; + right: 8px; + bottom: 8px; + width: calc(min(420px, 42vw) - 8px); + background: var(--surface); + border-radius: var(--r-lg); + box-shadow: var(--overlay); display: flex; flex-direction: column; - width: 18rem; - flex-shrink: 0; min-height: 0; + opacity: 0; + transform: translateX(24px); + transition: + opacity 220ms var(--ease-out), + transform 320ms var(--ease-drawer); + pointer-events: none; +} +.wb[data-drawer="open"] .drawer { + opacity: 1; + transform: none; + pointer-events: auto; +} +.drawer-scrim { + display: none; +} +.drawer-head { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 12px 12px 20px; +} +.drawer-head > div { + flex: 1; + min-width: 0; +} +.drawer-head h2 { + margin: 0; + font-size: 16px; + font-weight: 800; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.drawer-head p { + margin: 2px 0 0; + font-size: 13px; + color: var(--ink-3); +} +.drawer-tabs { + display: flex; + gap: 2px; + padding: 0 16px; + border-bottom: 1px solid var(--line); + overflow-x: auto; + scrollbar-width: none; + flex: 0 0 auto; + mask: linear-gradient(90deg, #000 calc(100% - 24px), transparent); +} +.drawer-tabs::-webkit-scrollbar { + display: none; +} +.drawer-tabs button { + flex: 0 0 auto; + height: 40px; + padding: 0 8px; + border: 0; + background: none; + cursor: pointer; + font-size: 13px; + font-weight: 700; + color: var(--ink-3); + box-shadow: inset 0 -2px 0 transparent; + transition: + color 120ms ease, + box-shadow 150ms ease-out; +} +.drawer-tabs button:hover { + color: var(--ink); +} +.drawer-tabs button.active { + color: var(--ink); + box-shadow: inset 0 -2px 0 var(--ink); +} +.drawer-tabs button:last-child { + margin-right: 24px; +} +.drawer-body { + flex: 1; overflow-y: auto; - padding: 1rem; - border-right: 1px solid var(--border); + padding: 20px; } -@media (max-width: 1100px) { - .workbench-info-column { +@media (max-width: 860px) { + .wb[data-drawer="open"] { + grid-template-columns: minmax(0, 1fr) 0px; + } + .drawer-cell { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; + z-index: 31; + } + .drawer { + width: auto; + left: 8px; + } + .wb[data-drawer="open"] .drawer-cell { + pointer-events: auto; + } + .drawer-scrim { + display: block; + position: absolute; + inset: 0; + background: rgb(0 0 0 / 24%); + opacity: 0; + pointer-events: none; + transition: opacity 200ms ease-out; + } + .wb[data-drawer="open"] .drawer-scrim { + opacity: 1; + pointer-events: auto; + } +} +@media (max-width: 640px) { + .bench-pill-status { display: none; } } +@media (prefers-reduced-motion: reduce) { + .wb, + .drawer { + transition: opacity 150ms ease-out; + } +} .workbench-main { display: flex; flex-direction: column; diff --git a/apps/web/src/bench/bench-drawer.tsx b/apps/web/src/bench/bench-drawer.tsx new file mode 100644 index 000000000..c525ee24d --- /dev/null +++ b/apps/web/src/bench/bench-drawer.tsx @@ -0,0 +1,99 @@ +import { Button } from "@corbits/react-ui"; +import { X } from "@/lib/icons"; +import { useEffect, useRef, useState, type ReactNode } from "react"; + +export const DRAWER_TABS = [ + "Information", + "Artifacts", + "Tools", + "Grants", + "Insights", + "Members", + "Workflows", +] as const; + +export type DrawerTab = (typeof DRAWER_TABS)[number]; + +/** The floating card in the grid column that pushes the thread left. Each + * tab's body arrives as a prop keyed by tab name; a tab without one shows a + * short empty state until its own ticket lands. */ +export function BenchDrawer({ + open, + title, + subtitle, + onClose, + tabs, +}: { + readonly open: boolean; + readonly title: string; + readonly subtitle: string; + readonly onClose: () => void; + readonly tabs: Partial>; +}) { + const [tab, setTab] = useState("Information"); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + // preventScroll: focusing must never shift the thread's scroll position. + ref.current?.focus({ preventScroll: true }); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + const body = tabs[tab]; + return ( +
+ + ); +} diff --git a/apps/web/src/bench/bench-pill.tsx b/apps/web/src/bench/bench-pill.tsx new file mode 100644 index 000000000..4096b7b18 --- /dev/null +++ b/apps/web/src/bench/bench-pill.tsx @@ -0,0 +1,39 @@ +import { CaretDown } from "@/lib/icons"; +import { IdentityAvatar } from "@/chat/avatar"; + +/** Frosted pill centered over the thread: the bench's worker, its name and + * live status. Toggles the bench drawer. */ +export function BenchPill({ + benchName, + worker, + status, + open, + onToggle, +}: { + readonly benchName: string; + readonly worker: { readonly id: string; readonly name: string } | undefined; + readonly status: string; + readonly open: boolean; + readonly onToggle: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/bench/information-tab.tsx b/apps/web/src/bench/information-tab.tsx new file mode 100644 index 000000000..b4b88d076 --- /dev/null +++ b/apps/web/src/bench/information-tab.tsx @@ -0,0 +1,103 @@ +import { Skeleton, formatRelativeTime } from "@corbits/react-ui"; + +import { ApprovalRow } from "@/chat/approval-row"; +import { IdentityAvatar } from "@/chat/avatar"; +import { + resolveParticipantName, + type WorkbenchMessage, + type WorkbenchParticipant, +} from "@/chat/threads-api"; +import { WorkbenchSchedulesPanel } from "../pages/workbench-schedules-panel"; +import { usePendingApprovals } from "../pending-approvals"; + +/** Overview of the bench: latest activity, what's waiting on the person, + * running schedules, and who is in it. */ +export function InformationTab({ + workbenchTenantId, + latestMessage, + participants, +}: { + readonly workbenchTenantId: string; + readonly latestMessage: WorkbenchMessage | undefined; + readonly participants: readonly WorkbenchParticipant[]; +}) { + const anyAgentStarting = participants.some( + (p) => p.kind === "agent" && p.address === "", + ); + const approvalsQuery = usePendingApprovals(workbenchTenantId, { + refetchInterval: anyAgentStarting ? 3000 : false, + }); + const pendingApprovals = + approvalsQuery.kind === "ready" ? approvalsQuery.data : null; + + return ( + <> +
+
+

Latest activity

+
+ {latestMessage === undefined ? ( +

+ Nothing yet — say something to get started. +

+ ) : ( +

+ {resolveParticipantName(latestMessage, participants)} ·{" "} + {formatRelativeTime(latestMessage.at)} +

+ )} +
+ +
+
+

Approvals

+
+ {approvalsQuery.kind === "loading" ? ( + + ) : null} + {approvalsQuery.kind === "error" ? ( +

{approvalsQuery.message}

+ ) : null} + {pendingApprovals !== null && pendingApprovals.length === 0 ? ( +

Nothing waiting on you.

+ ) : null} + {pendingApprovals !== null && pendingApprovals.length > 0 ? ( +
    + {pendingApprovals.map((item) => ( + + ))} +
+ ) : null} +
+ + + +
+
+

Participants

+
+
    + {participants.map((participant) => ( +
  • + + + + {participant.name} +
  • + ))} +
+
+ + ); +} diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index 5369f1a17..41f5e4417 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -1,19 +1,11 @@ -// See docs/chat-mail-threading.md. The left info column folds in what -// Mission Control used to show, scoped to this one workbench. +// See docs/chat-mail-threading.md. The bench pill opens a drawer that pushes +// the thread left; the drawer's Information tab carries the bench overview. -import { - Button, - EmptyState, - PageShell, - Skeleton, - formatRelativeTime, - toast, -} from "@corbits/react-ui"; +import { Button, EmptyState, PageShell, toast } from "@corbits/react-ui"; import { WarningCircle } from "@/lib/icons"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; -import { ApprovalRow } from "@/chat/approval-row"; import { IdentityAvatar } from "@/chat/avatar"; import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; @@ -25,23 +17,21 @@ import { listWorkbenchParticipants, readWorkbench, resolveAvatarName, - resolveParticipantName, sameAddress, sendToWorkbench, subscribeToInbox, type WorkbenchMessage, type WorkbenchParticipant, } from "@/chat/threads-api"; -import { ArtifactListPageSchema, useAPIQuery } from "../api"; +import { BenchDrawer } from "../bench/bench-drawer"; +import { BenchPill } from "../bench/bench-pill"; +import { InformationTab } from "../bench/information-tab"; import { useBench } from "../bench-context"; import { createFetchStockHub } from "../needs-converge"; -import { usePendingApprovals } from "../pending-approvals"; import { workbenchKeys } from "../chat-path"; import { tenantKeys } from "../query-client"; import { recordLastWorkbenchId } from "../last-workbench"; -import { StageTopBar } from "../shell/stage-top-bar"; import { redeployWorkbenchAgent } from "../workbench-create"; -import { WorkbenchSchedulesPanel } from "./workbench-schedules-panel"; import { workbenchIdFromPath } from "../workbench-path"; function errorText(cause: unknown): string { @@ -67,12 +57,17 @@ function WorkbenchMessageRow({ const matched = participants.find((participant) => sameAddress(participant.address, message.address), ); - const kind = message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; + const kind = + message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; // The person's own send carries a trailing roster block so agents in the // workbench can hand off to each other; it's never something a person should // see echoed back at them. - const body = message.author === "me" ? stripRoster(message.body) : message.body; - const { pkg, renderedBody } = resolveMessagePackage(message.attachments, body); + const body = + message.author === "me" ? stripRoster(message.body) : message.body; + const { pkg, renderedBody } = resolveMessagePackage( + message.attachments, + body, + ); return (
@@ -90,7 +85,11 @@ function WorkbenchMessageRow({ pkg={pkg} /> {onReply === undefined ? null : ( - )} @@ -99,125 +98,6 @@ function WorkbenchMessageRow({ ); } -function ParticipantList({ - participants, -}: { - readonly participants: readonly WorkbenchParticipant[]; -}) { - return ( -
    - {participants.map((participant) => ( -
  • - - - - {participant.name} -
  • - ))} -
- ); -} - -/** 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 workbench. */ -function WorkbenchInfoColumn({ - workbenchTenantId, - latestMessage, - participants, -}: { - readonly workbenchTenantId: string; - readonly latestMessage: WorkbenchMessage | undefined; - readonly participants: readonly WorkbenchParticipant[]; -}) { - // Poll only while an agent is still starting; once live, the inbox - // subscription's invalidation is the only trigger. - const anyAgentStarting = participants.some((p) => p.kind === "agent" && p.address === ""); - const approvalsQuery = usePendingApprovals(workbenchTenantId, { - refetchInterval: anyAgentStarting ? 3000 : false, - }); - const artifactsQuery = useAPIQuery( - `/api/tenants/${workbenchTenantId}/artifacts`, - ArtifactListPageSchema, - ); - const pendingApprovals = approvalsQuery.kind === "ready" ? approvalsQuery.data : null; - - return ( - - ); -} - // Renders nothing; a mount's own ref plus the mutation's `isPending`/ // `isSuccess` keep StrictMode's double render from firing it twice. function AgentRedeployer({ @@ -225,14 +105,20 @@ function AgentRedeployer({ agent, }: { readonly workbenchTenantId: string; - readonly agent: { readonly id: string; readonly name: string; readonly assetName: string }; + readonly agent: { + readonly id: string; + readonly name: string; + readonly assetName: string; + }; }) { const queryClient = useQueryClient(); const started = useRef(false); const redeploy = useMutation({ mutationFn: () => redeployWorkbenchAgent(workbenchTenantId, agent), onSuccess: () => - queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) }), + queryClient.invalidateQueries({ + queryKey: workbenchKeys.scope(workbenchTenantId), + }), onError: (cause) => toast(errorText(cause)), }); if (!started.current && !redeploy.isPending && !redeploy.isSuccess) { @@ -242,9 +128,15 @@ function AgentRedeployer({ return null; } -function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }) { +function Workbench({ + workbenchTenantId, +}: { + readonly workbenchTenantId: string; +}) { const queryClient = useQueryClient(); const [openThread, setOpenThread] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + const closeDrawer = useCallback(() => setDrawerOpen(false), []); const tenant = useQuery({ queryKey: workbenchKeys.tenant(workbenchTenantId), @@ -252,14 +144,19 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } }); const participants = useQuery({ queryKey: workbenchKeys.participants(workbenchTenantId), - queryFn: () => listWorkbenchParticipants(workbenchTenantId, tenant.data?.domain ?? ""), + queryFn: () => + listWorkbenchParticipants(workbenchTenantId, tenant.data?.domain ?? ""), // The workbench tenant's domain is read first; a person's mailbox address // depends on it, so participants wait for it rather than racing it. enabled: tenant.data !== undefined, // Poll while any agent has no live run yet, so the workbench notices its own // redeploy finishing without a manual refresh. refetchInterval: (query) => - (query.state.data ?? []).some((p) => p.kind === "agent" && p.address === "") ? 3000 : false, + (query.state.data ?? []).some( + (p) => p.kind === "agent" && p.address === "", + ) + ? 3000 + : false, }); const timeline = useQuery({ queryKey: workbenchKeys.timeline(workbenchTenantId), @@ -273,16 +170,22 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } useEffect( () => subscribeToInbox(workbenchTenantId, () => { - void queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) }); + void queryClient.invalidateQueries({ + queryKey: workbenchKeys.scope(workbenchTenantId), + }); void queryClient.invalidateQueries({ queryKey: tenantKeys.pendingApprovals(workbenchTenantId), }); - void queryClient.invalidateQueries({ queryKey: tenantKeys.artifacts(workbenchTenantId) }); + void queryClient.invalidateQueries({ + queryKey: tenantKeys.artifacts(workbenchTenantId), + }); }), [workbenchTenantId, queryClient], ); - const agents = (participants.data ?? []).filter((participant) => participant.kind === "agent"); + const agents = (participants.data ?? []).filter( + (participant) => participant.kind === "agent", + ); // Released by a hub restart: the asset is still here but nothing is live. const releasedAgents = agents.filter( (agent): agent is WorkbenchParticipant & { assetName: string } => @@ -308,16 +211,25 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } ...(inReplyTo !== undefined ? { inReplyTo } : {}), }), onSuccess: () => - queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) }), + queryClient.invalidateQueries({ + queryKey: workbenchKeys.scope(workbenchTenantId), + }), }); const messages = timeline.data ?? []; - const latestMessage = [...messages].sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0]; - const openedChain = openThread === null ? [] : ancestorChain(messages, openThread); + const latestMessage = [...messages].sort( + (a, b) => Date.parse(b.at) - Date.parse(a.at), + )[0]; + const openedChain = + openThread === null ? [] : ancestorChain(messages, openThread); const opened = openedChain.at(-1); const failure: unknown = timeline.error ?? participants.error; - if (failure !== null && failure !== undefined && timeline.data === undefined) { + if ( + failure !== null && + failure !== undefined && + timeline.data === undefined + ) { return ( ))} - -
- -
-
- +
+
+ setDrawerOpen((open) => !open)} + /> +
+
+ +
+ {messages.map((message) => ( + setOpenThread(target.messageId)} + /> + ))} +
+ {send.error === null ? null : ( +

{errorText(send.error)}

+ )} +
+
+
+ + send.mutate({ content: text })} + /> + +
+
+ {opened === undefined ? null : ( +
-
- send.mutate({ content: text })} + onSend={(text) => + send.mutate({ content: text, inReplyTo: opened.messageId }) + } /> - -
+ + )}
- {opened === undefined ? null : ( - - )} + + ), + }} + />
); From ecb0df9f6deadf9f205c53580d6e92afeba6ba80 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 22:17:08 -0700 Subject: [PATCH 2/2] style(web): format bench drawer files (CL-9564) --- apps/web/src/app.css | 61 +++++++------------------- apps/web/src/bench/bench-drawer.tsx | 11 +---- apps/web/src/bench/bench-pill.tsx | 6 +-- apps/web/src/bench/information-tab.tsx | 26 +++-------- apps/web/src/pages/workbench-page.tsx | 59 ++++++------------------- 5 files changed, 37 insertions(+), 126 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 94d390bf8..deddd481a 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -18,8 +18,7 @@ (injected at runtime, unrelated to this stylesheet); this just stops react-ui's override from fighting it. Higher specificity than that rule wins the cascade on its own, so this is safe regardless of load order. */ -[data-sonner-toaster][data-x-position="right"] - [data-sonner-toast].corbits-toast { +[data-sonner-toaster][data-x-position="right"] [data-sonner-toast].corbits-toast { left: auto !important; transform: none !important; } @@ -40,8 +39,7 @@ html { /* Inputs in side drawers already have a visible control boundary. Color that edge on focus so the focus treatment reads as one control, not a second outline outside it. */ -[data-slot="dialog-content"][data-side="right"] - [data-slot="input"]:focus-visible { +[data-slot="dialog-content"][data-side="right"] [data-slot="input"]:focus-visible { outline: none; border-color: var(--accent-rail, var(--primary)); } @@ -114,9 +112,7 @@ select:disabled, Scoped to the sidebar so stage content keeps library defaults. */ .shell-sidebar button:not(:disabled):not([aria-disabled="true"]), .shell-sidebar [data-slot="button"]:not(:disabled):not([aria-disabled="true"]), -.shell-sidebar - [data-slot="sidebar-item-row"] - button:not(:disabled):not([aria-disabled="true"]), +.shell-sidebar [data-slot="sidebar-item-row"] button:not(:disabled):not([aria-disabled="true"]), .shell-sidebar .shell-ch-row:not(:disabled):not([aria-disabled="true"]) { cursor: pointer; } @@ -199,10 +195,7 @@ select:disabled, /* Shared dialog craft — layout/scroll/chrome on react-ui data slots only. Feature forms and dialog body content keep their own styles. */ [data-slot="dialog-content"][data-side="center"] { - width: min( - calc(100vw - (var(--dialog-gutter) * 2)), - var(--dialog-content-max-width) - ); + width: min(calc(100vw - (var(--dialog-gutter) * 2)), var(--dialog-content-max-width)); max-height: var(--dialog-content-max-height); overflow: hidden; } @@ -861,8 +854,7 @@ select:disabled, align-items: center; gap: 0.5rem; padding: 0.45rem 0.7rem; - border-bottom: 1px solid - color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); } .shell-routine-new { @@ -1036,8 +1028,7 @@ select:disabled, justify-content: space-between; gap: 0.5rem; padding-bottom: 0.55rem; - border-bottom: 1px solid - color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); } .shell-canvas-pane-heading { @@ -1665,9 +1656,7 @@ select:disabled, } .shell-sidebar .shell-panel-search [data-slot="input"]::placeholder, -.shell-sidebar - [data-slot="sidebar-panel-body"] - [data-slot="input"]::placeholder { +.shell-sidebar [data-slot="sidebar-panel-body"] [data-slot="input"]::placeholder { color: var(--shell-panel-faint); } @@ -1683,9 +1672,7 @@ select:disabled, box-shadow: none; } -.shell-sidebar - [data-slot="sidebar-panel-body"] - [data-slot="input"]:focus-visible { +.shell-sidebar [data-slot="sidebar-panel-body"] [data-slot="input"]:focus-visible { outline: none; border-color: var(--foreground); box-shadow: 0 0 0 1px var(--foreground); @@ -1716,19 +1703,14 @@ select:disabled, left edge. Selector doubles as the kill for react-ui's bg-primary/10 selected wash. */ .shell-sidebar [data-slot="sidebar-item-row"] > div.bg-primary\/10, -.shell-sidebar - [data-slot="sidebar-item-row"] - > div:has(button[aria-current="true"]) { +.shell-sidebar [data-slot="sidebar-item-row"] > div:has(button[aria-current="true"]) { background-color: var(--background); color: var(--shell-accent); box-shadow: inset 2px 0 0 0 var(--shell-accent); } .shell-sidebar [data-slot="sidebar-item-row"] > div.bg-primary\/10 > button, -.shell-sidebar - [data-slot="sidebar-item-row"] - > div:has(button[aria-current="true"]) - > button { +.shell-sidebar [data-slot="sidebar-item-row"] > div:has(button[aria-current="true"]) > button { font-weight: 600; } @@ -1766,12 +1748,7 @@ select:disabled, } /* Mock .count: the trailing meta slot when it is plain text. */ -.shell-sidebar - [data-slot="sidebar-item-row"] - > div - > button - > span - + span:last-child { +.shell-sidebar [data-slot="sidebar-item-row"] > div > button > span + span:last-child { font-size: 0.72rem; color: var(--shell-panel-faint); } @@ -2277,8 +2254,7 @@ select:disabled, attention-seeking animation, fully off under reduced motion. DESIGN.md's motion ceiling is 300ms — stays at 280ms. */ .onboarding-phase { - animation: onboarding-phase-in var(--duration-large, 300ms) - var(--ease-out, ease) both; + animation: onboarding-phase-in var(--duration-large, 300ms) var(--ease-out, ease) both; } /* The wait phases drop the usual title/subtitle/form stack for the shared @@ -2560,8 +2536,7 @@ select:disabled, .insights-grid > .insights-panel { border: 0; - border-bottom: 1px solid - color-mix(in srgb, var(--foreground) 10%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); } .insights-grid > .insights-panel:last-child { @@ -2859,11 +2834,7 @@ tr.insights-row-clickable:hover { } /* The current row's copy follows the row's accent ink. */ -.shell-sidebar - [data-slot="sidebar-item-row"] - button[aria-current="true"] - .panel-row-copy - strong { +.shell-sidebar [data-slot="sidebar-item-row"] button[aria-current="true"] .panel-row-copy strong { color: inherit; } @@ -3748,9 +3719,7 @@ tr.insights-row-clickable:hover { list-style: none; } /* A stopped schedule stays listed, dimmed, since only a new one revives it. */ -.workbench-info-schedule-list - > li[data-stopped="true"] - .workbench-info-cell-primary { +.workbench-info-schedule-list > li[data-stopped="true"] .workbench-info-cell-primary { color: var(--muted-foreground); text-decoration: line-through; } diff --git a/apps/web/src/bench/bench-drawer.tsx b/apps/web/src/bench/bench-drawer.tsx index c525ee24d..6356963f5 100644 --- a/apps/web/src/bench/bench-drawer.tsx +++ b/apps/web/src/bench/bench-drawer.tsx @@ -63,12 +63,7 @@ export function BenchDrawer({

{title}

{subtitle}

-
@@ -88,9 +83,7 @@ export function BenchDrawer({
{body ?? ( -

- {tab} is coming to this drawer soon. -

+

{tab} is coming to this drawer soon.

)}
diff --git a/apps/web/src/bench/bench-pill.tsx b/apps/web/src/bench/bench-pill.tsx index 4096b7b18..b84232c15 100644 --- a/apps/web/src/bench/bench-pill.tsx +++ b/apps/web/src/bench/bench-pill.tsx @@ -25,11 +25,7 @@ export function BenchPill({ onClick={onToggle} > {worker === undefined ? null : ( - + )} {benchName} {status} diff --git a/apps/web/src/bench/information-tab.tsx b/apps/web/src/bench/information-tab.tsx index b4b88d076..337f8a67f 100644 --- a/apps/web/src/bench/information-tab.tsx +++ b/apps/web/src/bench/information-tab.tsx @@ -21,14 +21,11 @@ export function InformationTab({ readonly latestMessage: WorkbenchMessage | undefined; readonly participants: readonly WorkbenchParticipant[]; }) { - const anyAgentStarting = participants.some( - (p) => p.kind === "agent" && p.address === "", - ); + const anyAgentStarting = participants.some((p) => p.kind === "agent" && p.address === ""); const approvalsQuery = usePendingApprovals(workbenchTenantId, { refetchInterval: anyAgentStarting ? 3000 : false, }); - const pendingApprovals = - approvalsQuery.kind === "ready" ? approvalsQuery.data : null; + const pendingApprovals = approvalsQuery.kind === "ready" ? approvalsQuery.data : null; return ( <> @@ -37,9 +34,7 @@ export function InformationTab({

Latest activity

{latestMessage === undefined ? ( -

- Nothing yet — say something to get started. -

+

Nothing yet — say something to get started.

) : (

{resolveParticipantName(latestMessage, participants)} ·{" "} @@ -52,9 +47,7 @@ export function InformationTab({

Approvals

- {approvalsQuery.kind === "loading" ? ( - - ) : null} + {approvalsQuery.kind === "loading" ? : null} {approvalsQuery.kind === "error" ? (

{approvalsQuery.message}

) : null} @@ -64,20 +57,13 @@ export function InformationTab({ {pendingApprovals !== null && pendingApprovals.length > 0 ? (
    {pendingApprovals.map((item) => ( - + ))}
) : null} - +
diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index 41f5e4417..d3a493660 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -57,17 +57,12 @@ function WorkbenchMessageRow({ const matched = participants.find((participant) => sameAddress(participant.address, message.address), ); - const kind = - message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; + const kind = message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; // The person's own send carries a trailing roster block so agents in the // workbench can hand off to each other; it's never something a person should // see echoed back at them. - const body = - message.author === "me" ? stripRoster(message.body) : message.body; - const { pkg, renderedBody } = resolveMessagePackage( - message.attachments, - body, - ); + const body = message.author === "me" ? stripRoster(message.body) : message.body; + const { pkg, renderedBody } = resolveMessagePackage(message.attachments, body); return (
@@ -85,11 +80,7 @@ function WorkbenchMessageRow({ pkg={pkg} /> {onReply === undefined ? null : ( - )} @@ -128,11 +119,7 @@ function AgentRedeployer({ return null; } -function Workbench({ - workbenchTenantId, -}: { - readonly workbenchTenantId: string; -}) { +function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }) { const queryClient = useQueryClient(); const [openThread, setOpenThread] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); @@ -144,19 +131,14 @@ function Workbench({ }); const participants = useQuery({ queryKey: workbenchKeys.participants(workbenchTenantId), - queryFn: () => - listWorkbenchParticipants(workbenchTenantId, tenant.data?.domain ?? ""), + queryFn: () => listWorkbenchParticipants(workbenchTenantId, tenant.data?.domain ?? ""), // The workbench tenant's domain is read first; a person's mailbox address // depends on it, so participants wait for it rather than racing it. enabled: tenant.data !== undefined, // Poll while any agent has no live run yet, so the workbench notices its own // redeploy finishing without a manual refresh. refetchInterval: (query) => - (query.state.data ?? []).some( - (p) => p.kind === "agent" && p.address === "", - ) - ? 3000 - : false, + (query.state.data ?? []).some((p) => p.kind === "agent" && p.address === "") ? 3000 : false, }); const timeline = useQuery({ queryKey: workbenchKeys.timeline(workbenchTenantId), @@ -183,9 +165,7 @@ function Workbench({ [workbenchTenantId, queryClient], ); - const agents = (participants.data ?? []).filter( - (participant) => participant.kind === "agent", - ); + const agents = (participants.data ?? []).filter((participant) => participant.kind === "agent"); // Released by a hub restart: the asset is still here but nothing is live. const releasedAgents = agents.filter( (agent): agent is WorkbenchParticipant & { assetName: string } => @@ -217,19 +197,12 @@ function Workbench({ }); const messages = timeline.data ?? []; - const latestMessage = [...messages].sort( - (a, b) => Date.parse(b.at) - Date.parse(a.at), - )[0]; - const openedChain = - openThread === null ? [] : ancestorChain(messages, openThread); + const latestMessage = [...messages].sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0]; + const openedChain = openThread === null ? [] : ancestorChain(messages, openThread); const opened = openedChain.at(-1); const failure: unknown = timeline.error ?? participants.error; - if ( - failure !== null && - failure !== undefined && - timeline.data === undefined - ) { + if (failure !== null && failure !== undefined && timeline.data === undefined) { return (

Replies

-
@@ -325,9 +294,7 @@ function Workbench({ busy={send.isPending} disabled={startingAgent !== undefined} mentionables={mentionables} - onSend={(text) => - send.mutate({ content: text, inReplyTo: opened.messageId }) - } + onSend={(text) => send.mutate({ content: text, inReplyTo: opened.messageId })} /> )}