From 4bc29fa435c7fb29d29ede854f2ab51a3ab74159 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Tue, 29 Sep 2026 23:00:00 -0700 Subject: [PATCH] feat(web): a workbench has an editable description shown in the drawer (CL-9596) --- apps/web/src/bench/description.css | 32 ++++++++++++++++ apps/web/src/bench/description.ts | 51 ++++++++++++++++++++++++++ apps/web/src/bench/drawer-narrow.css | 6 +++ apps/web/src/bench/information-tab.tsx | 47 +++++++++++++++++++++++- apps/web/src/pages/workbench-page.tsx | 4 +- 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/bench/description.css create mode 100644 apps/web/src/bench/description.ts diff --git a/apps/web/src/bench/description.css b/apps/web/src/bench/description.css new file mode 100644 index 000000000..435a5302e --- /dev/null +++ b/apps/web/src/bench/description.css @@ -0,0 +1,32 @@ +.bench-description-text { + margin: 0; + font-size: 0.85rem; + white-space: pre-wrap; +} +.bench-description-form { + display: flex; + flex-direction: column; + gap: 8px; +} +.bench-description-form textarea { + width: 100%; + min-height: 72px; + resize: vertical; + padding: 8px 10px; + font: inherit; + font-size: 0.85rem; + color: var(--ink); + background: transparent; + border: 1px solid var(--line); + border-radius: 8px; +} +.bench-description-actions { + display: flex; + align-items: center; + gap: 8px; +} +.bench-description-count { + margin-left: auto; + font-size: 0.75rem; + color: var(--muted-foreground); +} diff --git a/apps/web/src/bench/description.ts b/apps/web/src/bench/description.ts new file mode 100644 index 000000000..fdb8a9135 --- /dev/null +++ b/apps/web/src/bench/description.ts @@ -0,0 +1,51 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { type } from "arktype"; + +import { ApiQueryError } from "@/lib/api-query"; +import { TenantDetailSchema, fetchTenantDetail, type TenantDetail } from "../api"; + +export const DESCRIPTION_MAX = 280; + +const DescriptionConfig = type({ "workbench.description?": `string <= ${DESCRIPTION_MAX}` }); + +/** The description lives in the stock tenant config; anything malformed reads + * as no description. */ +export function readDescription(config: TenantDetail["config"]): string { + const parsed = DescriptionConfig(config ?? {}); + return parsed instanceof type.errors ? "" : (parsed["workbench.description"] ?? ""); +} + +const descriptionKey = (tenantId: string) => ["workbench", tenantId, "description"] as const; + +export function useBenchDescription(tenantId: string) { + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey: descriptionKey(tenantId), + queryFn: () => fetchTenantDetail(tenantId), + }); + const save = useMutation({ + mutationFn: async (text: string) => { + // Read-modify-write on fresh config so other keys are never dropped. + const current = await fetchTenantDetail(tenantId); + const path = `/api/tenants/${encodeURIComponent(tenantId)}`; + const response = await fetch(path, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ config: { ...current.config, "workbench.description": text } }), + }); + if (!response.ok) { + throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path); + } + const parsed = TenantDetailSchema(await response.json()); + if (parsed instanceof type.errors) { + throw new ApiQueryError(`Unexpected tenant response shape: ${parsed.summary}`); + } + return parsed; + }, + onSuccess: (tenant) => queryClient.setQueryData(descriptionKey(tenantId), tenant), + }); + return { + description: query.data === undefined ? "" : readDescription(query.data.config), + save, + }; +} diff --git a/apps/web/src/bench/drawer-narrow.css b/apps/web/src/bench/drawer-narrow.css index e66d0a645..f1f440b97 100644 --- a/apps/web/src/bench/drawer-narrow.css +++ b/apps/web/src/bench/drawer-narrow.css @@ -18,3 +18,9 @@ visibility 0s 300ms; } } + +.drawer-head p { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/apps/web/src/bench/information-tab.tsx b/apps/web/src/bench/information-tab.tsx index 337f8a67f..aaadd18ab 100644 --- a/apps/web/src/bench/information-tab.tsx +++ b/apps/web/src/bench/information-tab.tsx @@ -1,4 +1,5 @@ -import { Skeleton, formatRelativeTime } from "@corbits/react-ui"; +import { Button, Skeleton, formatRelativeTime, toast } from "@corbits/react-ui"; +import { useState } from "react"; import { ApprovalRow } from "@/chat/approval-row"; import { IdentityAvatar } from "@/chat/avatar"; @@ -9,6 +10,48 @@ import { } from "@/chat/threads-api"; import { WorkbenchSchedulesPanel } from "../pages/workbench-schedules-panel"; import { usePendingApprovals } from "../pending-approvals"; +import "./description.css"; +import { DESCRIPTION_MAX, useBenchDescription } from "./description"; + +function AboutSection({ workbenchTenantId }: { readonly workbenchTenantId: string }) { + const { description, save } = useBenchDescription(workbenchTenantId); + const [draft, setDraft] = useState(null); + const value = draft ?? description; + const dirty = draft !== null && draft.trim() !== description; + return ( +
+
+

About

+
+
{ + event.preventDefault(); + save.mutate(value.trim(), { + onSuccess: () => setDraft(null), + onError: (cause) => toast(cause instanceof Error ? cause.message : String(cause)), + }); + }} + > +