diff --git a/apps/web/src/bench/artifacts-tab.tsx b/apps/web/src/bench/artifacts-tab.tsx index e16e29b07..efcbb98ad 100644 --- a/apps/web/src/bench/artifacts-tab.tsx +++ b/apps/web/src/bench/artifacts-tab.tsx @@ -19,7 +19,7 @@ export function ArtifactsTab({ workbenchTenantId }: { readonly workbenchTenantId

Files saved here

- See all + Open all
{page.kind === "loading" ? : null} {page.kind === "error" ?

{page.message}

: null} diff --git a/apps/web/src/bench/bench-drawer.tsx b/apps/web/src/bench/bench-drawer.tsx index 770bc279f..930559765 100644 --- a/apps/web/src/bench/bench-drawer.tsx +++ b/apps/web/src/bench/bench-drawer.tsx @@ -2,7 +2,7 @@ import { Button } from "@corbits/react-ui"; import "./drawer-narrow.css"; import { X } from "@/lib/icons"; import "./drawer.css"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { createContext, useEffect, useRef, useState, type ReactNode } from "react"; export const DRAWER_TABS = [ "Information", @@ -16,6 +16,9 @@ export const DRAWER_TABS = [ export type DrawerTab = (typeof DRAWER_TABS)[number]; +/** Lets a tab body switch the drawer to another tab. */ +export const DrawerTabContext = createContext<(tab: DrawerTab) => void>(() => undefined); + /** 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. */ @@ -99,7 +102,13 @@ export function BenchDrawer({

{title}

{subtitle === undefined || subtitle === "" ? null :

{subtitle}

} - @@ -118,9 +127,11 @@ export function BenchDrawer({ ))}
- {body ?? ( -

{tab} is coming to this drawer soon.

- )} + + {body ?? ( +

{tab} is coming to this drawer soon.

+ )} +
diff --git a/apps/web/src/bench/drawer.css b/apps/web/src/bench/drawer.css index 8bf1ab329..e8733aeb0 100644 --- a/apps/web/src/bench/drawer.css +++ b/apps/web/src/bench/drawer.css @@ -74,9 +74,74 @@ .drawer-grant-mode[data-effect="deny"] { color: var(--ink-2); } +.drawer .bi-stats { + grid-template-columns: 1fr 1fr; +} .drawer .bi-stat { + padding: 12px 14px; +} +.drawer .bi-stat + .bi-stat { + border-left: 0; +} +.drawer .bi-stat:nth-child(even) { + border-left: 1px solid var(--line); +} +.drawer .bi-stat:nth-child(n + 3) { + border-top: 1px solid var(--line); +} +.drawer .bi-stat-v { + font-size: 20px; + letter-spacing: -0.01em; +} +.drawer .bi-chart { + margin-top: 0; +} + +.drawer .drawer-close { + width: 32px; + height: 32px; + padding: 0; + flex: 0 0 auto; +} +.drawer-stack { + display: flex; +} +.drawer-stack > * + * { + margin-left: -6px; +} +.drawer-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + white-space: nowrap; + color: var(--ink-2); + background: color-mix(in srgb, var(--action) 14%, transparent); +} +.drawer-pill::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--action); +} +.drawer-sec-head button { padding: 0; border: 0; - border-radius: 0; background: none; + cursor: pointer; + font-size: 12.5px; + color: var(--ink-3); +} +.drawer-sec-head button:hover { + color: var(--ink); +} +.drawer .workbench-info-panel-header button { + height: auto; + padding: 0; + font-size: 12.5px; + color: var(--ink-3); } diff --git a/apps/web/src/bench/information-tab.tsx b/apps/web/src/bench/information-tab.tsx index 5be8cb8af..6b6a0c018 100644 --- a/apps/web/src/bench/information-tab.tsx +++ b/apps/web/src/bench/information-tab.tsx @@ -1,10 +1,9 @@ import { Button, Skeleton, formatRelativeTime } from "@corbits/react-ui"; import { toast } from "@corbits/react-ui/ui/toast"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useContext, useState } from "react"; import { ArtifactListPageSchema, useAPIQuery } from "@/api"; -import { ApprovalRow } from "@/chat/approval-row"; import { IdentityAvatar } from "@/chat/avatar"; import type { WorkbenchParticipant } from "@/chat/threads-api"; import { libraryArtifactPath } from "@/library"; @@ -17,10 +16,13 @@ import { scheduleSentence } from "@/pages/routines-page"; import { listScheduledWorkflows } from "@/routines-api"; import { skillDisplayName } from "@/skill-display-name"; import { listSkills } from "@/skills-api"; +import { principalLabel } from "../settings/identity"; +import { listPrincipals } from "../settings/tenancy-api"; import { WorkbenchSchedulesPanel } from "../pages/workbench-schedules-panel"; import { usePendingApprovals } from "../pending-approvals"; import { benchLink } from "../shell/page-crumbs"; import type { WorkerStatus } from "../worker-status"; +import { DrawerTabContext } from "./bench-drawer"; import "./description.css"; import { DESCRIPTION_MAX, useBenchDescription } from "./description"; import { DEFAULT_WORKER_NAME, WORKER_NAME_MAX, useWorkerName } from "./worker-name"; @@ -28,10 +30,13 @@ import { DEFAULT_WORKER_NAME, WORKER_NAME_MAX, useWorkerName } from "./worker-na function Section({ title, action, + onAction, children, }: { readonly title: string; readonly action?: { readonly to: string; readonly label: string }; + /** A head button, for an action that stays in the drawer. */ + readonly onAction?: { readonly onClick: () => void; readonly label: string }; readonly children: React.ReactNode; }) { return ( @@ -39,6 +44,11 @@ function Section({

{title}

{action === undefined ? null : {action.label}} + {onAction === undefined ? null : ( + + )}
{children}
@@ -48,15 +58,28 @@ function Section({ function AboutSection({ workbenchTenantId }: { readonly workbenchTenantId: string }) { const { description, save } = useBenchDescription(workbenchTenantId); const [draft, setDraft] = useState(null); - const value = draft ?? description; + const editing = draft !== null; const dirty = draft !== null && draft.trim() !== description; + if (!editing) { + return ( +
setDraft(description), + }} + > +

{description === "" ? "No description yet." : description}

+
+ ); + } return ( -
+
setDraft(null) }}>
{ event.preventDefault(); - save.mutate(value.trim(), { + save.mutate(draft.trim(), { onSuccess: () => setDraft(null), onError: (cause) => toast(cause instanceof Error ? cause.message : String(cause)), }); @@ -66,7 +89,8 @@ function AboutSection({ workbenchTenantId }: { readonly workbenchTenantId: strin aria-label="Description" placeholder="Add a description" maxLength={DESCRIPTION_MAX} - value={value} + value={draft} + autoFocus onChange={(event) => setDraft(event.target.value)} />
@@ -74,7 +98,7 @@ function AboutSection({ workbenchTenantId }: { readonly workbenchTenantId: strin Save - {value.length}/{DESCRIPTION_MAX} + {draft.length}/{DESCRIPTION_MAX}
@@ -82,6 +106,49 @@ function AboutSection({ workbenchTenantId }: { readonly workbenchTenantId: strin ); } +function MembersSummary({ + workbenchTenantId, + participants, +}: { + readonly workbenchTenantId: string; + readonly participants: readonly WorkbenchParticipant[]; +}) { + const setTab = useContext(DrawerTabContext); + // Same key as the Members tab so the two share one fetch. + const people = useQuery({ + queryKey: [...tenantKeys.principals(workbenchTenantId), "members"], + queryFn: async () => (await listPrincipals(workbenchTenantId)).filter((p) => p.kind === "user"), + }); + const workers = participants.filter((p) => p.kind === "agent"); + const humans = people.data ?? []; + const workersText = `${workers.length} worker${workers.length === 1 ? "" : "s"}`; + const peopleText = humans.length === 1 ? "1 person" : `${humans.length} people`; + return ( +
setTab("Members") }}> +
+ + {workers.map((w) => ( + + ))} + {humans.map((p) => ( + + ))} + + + + {workersText}, {peopleText} + + +
+
+ ); +} + function WorkerNameSection({ workbenchTenantId }: { readonly workbenchTenantId: string }) { const { name, save } = useWorkerName(workbenchTenantId); const current = name ?? DEFAULT_WORKER_NAME; @@ -188,11 +255,25 @@ export function InformationTab({

Nothing is waiting on you.

) : null} {pendingApprovals !== null && pendingApprovals.length > 0 ? ( -
    +
    {pendingApprovals.map((item) => ( - +
    + + + {item.headline} + + {item.agentName} + {item.toolName === undefined ? "" : ` · ${item.toolName}`} + + + Waiting +
    ))} -
+ ) : null}
@@ -247,6 +328,8 @@ export function InformationTab({
+ +
+ apiQueryOptions(insightsRunEventsPath(workbenchTenantId, run.id), RunEventsSchema), + ), + }); if (runs.kind === "loading") return ; if (runs.kind !== "ready") { @@ -31,12 +50,11 @@ export function InsightsTab({ workbenchTenantId }: { readonly workbenchTenantId: } const stats = computeBenchInsights(runs.data.data, 7); - const finished = stats.ok + stats.fail; - const tiles: readonly (readonly [string, string])[] = [ - ["Runs", formatCount(stats.total)], - ["Succeeded", finished === 0 ? "—" : `${Math.round((stats.ok / finished) * 100)}%`], - ["Median run", stats.medianMs === null ? "—" : durationLabel(stats.medianMs)], - ]; + const rangeStart = stats.days[0]?.date.getTime() ?? 0; + const saved = + artifacts.kind === "ready" + ? formatCount(countSavedArtifacts(artifacts.data.artifacts, rangeStart)) + : "—"; return (
@@ -45,7 +63,7 @@ export function InsightsTab({ workbenchTenantId }: { readonly workbenchTenantId:

Last 7 days

- {tiles.map(([label, value]) => ( + {benchInsightTiles(stats, saved).map(([label, value]) => (
{value}
{label}
@@ -56,24 +74,39 @@ export function InsightsTab({ workbenchTenantId }: { readonly workbenchTenantId:

Runs per day

+ + Open insights +
- -
-
-
-

Approvals

-
- +
- - Open Insights - + {failed.length === 0 ? null : ( +
+
+

Needs a look

+
+
+ {failed.map((run, i) => { + const events = failureEvents[i]?.data?.events; + const why = events === undefined ? null : runFailureMessage(events); + return ( + +
+
+ )}
); } diff --git a/apps/web/src/bench/workflows-tab.tsx b/apps/web/src/bench/workflows-tab.tsx index 0ee8e1133..b8ff6dacf 100644 --- a/apps/web/src/bench/workflows-tab.tsx +++ b/apps/web/src/bench/workflows-tab.tsx @@ -24,7 +24,7 @@ export function WorkflowsTab({ workbenchTenantId }: { readonly workbenchTenantId

Scheduled here

- See all + Open all
{flows.isLoading ? : null} {flows.isError ? ( diff --git a/apps/web/src/insights-stats.ts b/apps/web/src/insights-stats.ts index ec4332bb1..8d4a6b302 100644 --- a/apps/web/src/insights-stats.ts +++ b/apps/web/src/insights-stats.ts @@ -215,6 +215,29 @@ export function computeBenchInsights( }; } +/** Non-archived artifacts created on or after `sinceMs`. */ +export function countSavedArtifacts( + artifacts: readonly { readonly archivedAt: string | null; readonly createdAt: string }[], + sinceMs: number, +): number { + return artifacts.filter((a) => a.archivedAt === null && Date.parse(a.createdAt) >= sinceMs) + .length; +} + +/** The four headline tiles shared by the Insights page and the drawer tab. */ +export function benchInsightTiles( + stats: BenchInsights, + saved: string, +): readonly (readonly [string, string])[] { + const finished = stats.ok + stats.fail; + return [ + ["Runs", formatCount(stats.total)], + ["Succeeded", finished === 0 ? "—" : `${Math.round((stats.ok / finished) * 100)}%`], + ["Median run", stats.medianMs === null ? "—" : durationLabel(stats.medianMs)], + ["Artifacts saved", saved], + ]; +} + /** Most recent in-range runs whose approvals the Insights cards fetch. */ export const APPROVAL_RUN_CAP = 50; diff --git a/apps/web/src/pages/bench-insights.tsx b/apps/web/src/pages/bench-insights.tsx index 2e498d7c9..95c711ff7 100644 --- a/apps/web/src/pages/bench-insights.tsx +++ b/apps/web/src/pages/bench-insights.tsx @@ -19,7 +19,9 @@ import { import { approvalRunsInRange, BENCH_RANGES, + benchInsightTiles, computeBenchInsights, + countSavedArtifacts, durationLabel, formatCount, runDisplayName, @@ -57,7 +59,14 @@ function dayLabel(date: Date): string { }); } -export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { +export function OutcomeChart({ + days, + weekdayAxis = false, +}: { + readonly days: readonly BenchDay[]; + /** Weekday initials on the axis, for the drawer's 7-day chart. */ + readonly weekdayAxis?: boolean; +}) { const [active, setActive] = useState(null); const max = Math.max(...days.map((d) => d.ok + d.fail + d.other), 1); const every = days.length > 30 ? 14 : days.length > 7 ? 5 : 1; @@ -115,7 +124,11 @@ export function OutcomeChart({ days }: { readonly days: readonly BenchDay[] }) { @@ -244,7 +257,6 @@ function InsightsBody({ }) { const stats = computeBenchInsights(runs.data, range); const approvalRuns = approvalRunsInRange(runs.data, range); - const finished = stats.ok + stats.fail; const artifacts = useAPIQuery(`/api/tenants/${tenantId}/artifacts`, ArtifactListPageSchema); const schedules = useQuery({ queryKey: tenantKeys.schedules(tenantId), @@ -263,18 +275,9 @@ function InsightsBody({ const rangeStart = stats.days[0]?.date.getTime() ?? 0; const savedCount = artifacts.kind === "ready" - ? formatCount( - artifacts.data.artifacts.filter( - (a) => a.archivedAt === null && Date.parse(a.createdAt) >= rangeStart, - ).length, - ) + ? formatCount(countSavedArtifacts(artifacts.data.artifacts, rangeStart)) : "—"; - const tiles: readonly (readonly [string, string])[] = [ - ["Runs", formatCount(stats.total)], - ["Succeeded", finished === 0 ? "—" : `${Math.round((stats.ok / finished) * 100)}%`], - ["Median run", stats.medianMs === null ? "—" : durationLabel(stats.medianMs)], - ["Artifacts saved", savedCount], - ]; + const tiles = benchInsightTiles(stats, savedCount); return (