diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 7180f16d5..deddd481a 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3909,21 +3909,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..6356963f5 --- /dev/null +++ b/apps/web/src/bench/bench-drawer.tsx @@ -0,0 +1,92 @@ +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..b84232c15 --- /dev/null +++ b/apps/web/src/bench/bench-pill.tsx @@ -0,0 +1,35 @@ +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..337f8a67f --- /dev/null +++ b/apps/web/src/bench/information-tab.tsx @@ -0,0 +1,89 @@ +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..d3a493660 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 { @@ -99,125 +89,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 +96,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) { @@ -245,6 +122,8 @@ function AgentRedeployer({ 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), @@ -273,11 +152,15 @@ 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], ); @@ -308,7 +191,9 @@ 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 ?? []; @@ -338,79 +223,97 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string } agent={{ id: agent.id, name: agent.name, assetName: agent.assetName }} /> ))} - -
- -
-
- +
+
+ 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 : ( - - )} + + ), + }} + />
);