diff --git a/apps/web/src/agent-chat-launch.test.ts b/apps/web/src/agent-chat-launch.test.ts deleted file mode 100644 index 4ed0d39c3..000000000 --- a/apps/web/src/agent-chat-launch.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { launchAgentChat } from "./agent-chat-launch"; - -describe("launchAgentChat", () => { - const realFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = realFetch; - }); - - type RecordedCall = { readonly path: string; readonly init?: RequestInit }; - - function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : new URL(String(input)).pathname; - calls.push(init === undefined ? { path } : { path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; - } - - const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - - test("creates a chat for the given definitionId with no reuseExisting flag, and navigates to it", async () => { - const navigated: string[] = []; - const calls = stubFetch((path) => { - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-1", - title: "Echo", - kind: "chat", - pinned: false, - participants: [], - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - await launchAgentChat("tnt_1", "wfd_echo", (to) => navigated.push(to)); - - const call = calls.find((c) => c.path.endsWith("/chat/workbenches")); - expect(JSON.parse(String(call?.init?.body))).toEqual({ - kind: "chat", - definitionId: "wfd_echo", - }); - expect(navigated).toEqual(["/w/chan-1"]); - }); - - test("passes an explicit name through when given one", async () => { - const calls = stubFetch((path) => { - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-2", - title: "New Workbench", - kind: "chat", - pinned: false, - participants: [], - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - await launchAgentChat("tnt_1", "wfd_assistant", () => {}, "New Workbench"); - - const call = calls.find((c) => c.path.endsWith("/chat/workbenches")); - expect(JSON.parse(String(call?.init?.body))).toEqual({ - kind: "chat", - definitionId: "wfd_assistant", - name: "New Workbench", - }); - }); -}); diff --git a/apps/web/src/agent-chat-launch.ts b/apps/web/src/agent-chat-launch.ts deleted file mode 100644 index ba5772fd2..000000000 --- a/apps/web/src/agent-chat-launch.ts +++ /dev/null @@ -1,26 +0,0 @@ -// The one path from "an agent's definitionId" to "the person is in a -// fresh chat with it" — the same `POST /workbenches` call this app's every -// create path uses. `CreateAgentPanel`'s Settings → Agents entry point -// calls this on success so an explicitly-defined new agent never ends -// nowhere, and `instant-agent-create.ts` — THE one creation verb — -// calls it against the account's default setup template. -// Always creates — never the `reuseExisting` land-hop path, -// which is `default-agent-workbench.ts`'s own call, not this one. - -import { createWorkbench } from "@/chat"; - -import { workbenchPath } from "./workbench-path"; - -export async function launchAgentChat( - tenantId: string, - definitionId: string, - navigate: (to: string) => void, - name?: string, -): Promise { - const workbench = await createWorkbench(tenantId, { - kind: "chat", - definitionId, - ...(name !== undefined ? { name } : {}), - }); - navigate(workbenchPath(workbench.id)); -} diff --git a/apps/web/src/agent-dm-launch.test.ts b/apps/web/src/agent-dm-launch.test.ts deleted file mode 100644 index d5dff8bfc..000000000 --- a/apps/web/src/agent-dm-launch.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { openAgentDmChat } from "./agent-dm-launch"; - -describe("openAgentDmChat", () => { - const realFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = realFetch; - }); - - type RecordedCall = { readonly path: string; readonly init?: RequestInit }; - - function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : new URL(String(input)).pathname; - calls.push(init === undefined ? { path } : { path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; - } - - const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - - test("opens the agent's DM with reuseExisting and navigates to it", async () => { - const navigated: string[] = []; - const calls = stubFetch((path) => { - if (path.endsWith("/chat/workbenches")) { - return json({ - id: "chan-dm-1", - title: "Outreach", - kind: "chat", - pinned: false, - participants: [], - }); - } - throw new Error(`unexpected fetch: ${path}`); - }); - - await openAgentDmChat("tnt_root", "wfd_outreach", (to) => navigated.push(to)); - - const call = calls.find((c) => c.path.endsWith("/chat/workbenches")); - expect(call?.path).toBe("/api/tenants/tnt_root/chat/workbenches"); - expect(JSON.parse(String(call?.init?.body))).toEqual({ - kind: "chat", - definitionId: "wfd_outreach", - reuseExisting: true, - }); - expect(navigated).toEqual(["/w/chan-dm-1"]); - }); - - test("mints in the definition's owning tenant, not necessarily the caller's own", async () => { - const calls = stubFetch(() => - json({ - id: "chan-dm-2", - title: "Outreach", - kind: "chat", - pinned: false, - participants: [], - }), - ); - - await openAgentDmChat("tnt_ancestor", "wfd_outreach", () => {}); - - expect(calls[0]?.path).toBe("/api/tenants/tnt_ancestor/chat/workbenches"); - }); -}); diff --git a/apps/web/src/agent-dm-launch.ts b/apps/web/src/agent-dm-launch.ts deleted file mode 100644 index 3f5c89258..000000000 --- a/apps/web/src/agent-dm-launch.ts +++ /dev/null @@ -1,21 +0,0 @@ -// The one path from an agent definition's id to "the person is in their -// direct chat with it" — the sidebar's agent rows are the one -// caller. Mirrors `agent-chat-launch.ts`'s shape exactly, but through -// `openAgentDm` (`kind: "chat"`, `reuseExisting: true`) rather than -// `createWorkbench` directly: the first click mints the DM, every later -// click finds the same workbench by `chat/definitionId` -// (`findExistingAgentChat` in `packages/chat/src/routes.ts`) instead of -// spawning a new one each time. - -import { openAgentDm } from "@/chat"; - -import { workbenchPath } from "./workbench-path"; - -export async function openAgentDmChat( - tenantId: string, - definitionId: string, - navigate: (to: string) => void, -): Promise { - const workbench = await openAgentDm(tenantId, definitionId); - navigate(workbenchPath(workbench.id)); -} diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 263bc3eb4..7f48c1759 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -289,3 +289,18 @@ export function useDeployAgentMutation(tenantId: string) { }, }); } + +// The guided capability-add surface: only what this tenant actually has. +const CapabilityInventoryWire = type({ + toolPackages: type({ name: "string" }).array(), + skills: type({ name: "string" }).array(), + models: type({ canonicalName: "string" }).array(), +}); +export type CapabilityInventory = typeof CapabilityInventoryWire.infer; + +export function listCapabilityInventory(tenantId: string): Promise { + return getJSON( + `/api/tenants/${tenantId}/agent-definitions/capabilities/inventory`, + CapabilityInventoryWire, + ); +} diff --git a/apps/web/src/app.css b/apps/web/src/app.css index d921efe24..1a5001d9e 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3860,3 +3860,58 @@ button.mission-control-jump-row:hover { .chat-composer > textarea { width: 100%; } + +/* A workbench room: timeline in the stage, sub-thread replies beside it. */ +.room-layout { + display: flex; + min-height: 0; + flex: 1; +} +.room-participants { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 1rem; + padding: 0; + list-style: none; + font-size: 0.82rem; + color: var(--muted-foreground); +} +.room-participants > li { + display: flex; + align-items: center; + gap: 0.35rem; +} +.room-replies-link { + display: block; + margin-top: 0.35rem; + padding: 0; + border: 0; + background: none; + font-size: 0.78rem; + color: var(--muted-foreground); + cursor: pointer; +} +.room-replies-link:hover { + color: var(--foreground); +} +.room-subthread { + display: flex; + flex-direction: column; + width: 22rem; + min-height: 0; + overflow-y: auto; + padding: 1rem; + border-left: 1px solid var(--border); +} +.room-subthread-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} +.room-subthread-head > h2 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; +} diff --git a/apps/web/src/chat-path.ts b/apps/web/src/chat-path.ts index 8a3c46d64..0c9faf4a3 100644 --- a/apps/web/src/chat-path.ts +++ b/apps/web/src/chat-path.ts @@ -12,6 +12,15 @@ export const chatKeys = { childTenants: (tenantId: string) => ["tenant", tenantId, "child-tenants"] as const, }; +/** One key factory per room (a workbench child tenant), so a send + * invalidates the room's timeline and roster together. */ +export const roomKeys = { + scope: (tenantId: string) => ["room", tenantId] as const, + tenant: (tenantId: string) => ["room", tenantId, "tenant"] as const, + participants: (tenantId: string) => ["room", tenantId, "participants"] as const, + timeline: (tenantId: string) => ["room", tenantId, "timeline"] as const, +}; + export const CHATS_PATH_PREFIX = "/chats"; export const NEW_CHAT_PATH = `${CHATS_PATH_PREFIX}/new`; diff --git a/apps/web/src/chat/agent-display-names.test.ts b/apps/web/src/chat/agent-display-names.test.ts deleted file mode 100644 index f9bb8c5bc..000000000 --- a/apps/web/src/chat/agent-display-names.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { agentDisplayNamesFromAgents, displayNameForAddress } from "./agent-display-names"; - -describe("agentDisplayNamesFromAgents", () => { - test("keys each agent's display name by its participant address", () => { - const names = agentDisplayNamesFromAgents([ - { address: "ins_echo@acme.example", displayName: "Myra" }, - { address: "ins_review@acme.example", displayName: "Reviewer" }, - ]); - expect(names.get("ins_echo@acme.example")).toBe("Myra"); - expect(names.get("ins_review@acme.example")).toBe("Reviewer"); - }); - - test("an empty snapshot builds an empty lookup", () => { - expect(agentDisplayNamesFromAgents([]).size).toBe(0); - }); -}); - -describe("displayNameForAddress", () => { - test("returns the known display name, never the handle slug", () => { - const names = agentDisplayNamesFromAgents([ - { address: "ins_echo@acme.example", displayName: "Myra" }, - ]); - expect(displayNameForAddress("ins_echo@acme.example", names)).toBe("Myra"); - }); - - test("is undefined for an unknown address so the caller falls back", () => { - const names = agentDisplayNamesFromAgents([ - { address: "ins_echo@acme.example", displayName: "Myra" }, - ]); - expect(displayNameForAddress("ins_other@acme.example", names)).toBeUndefined(); - }); - - test("is undefined without a snapshot so the caller falls back", () => { - expect(displayNameForAddress("ins_echo@acme.example", undefined)).toBeUndefined(); - }); -}); diff --git a/apps/web/src/chat/agent-display-names.ts b/apps/web/src/chat/agent-display-names.ts deleted file mode 100644 index 66185409c..000000000 --- a/apps/web/src/chat/agent-display-names.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Person-facing display names for a workbench's agent participants -// — resolved once per workbench off -// `GET /workbenches/:id/agents`' `displayName`, keyed by participant -// address. Every surface that names an agent (timeline headers, mention -// picker, typing pulse, presence stack, empty states, join lines) reads -// through here so a reader sees the definition's display name — never a -// raw handle slug. A missing entry falls back to the caller's own -// slug-derived label: the agents query can still be in flight (or an -// agent's definition gone) while the timeline already renders. - -/** Agent display names by participant address. */ -export type AgentDisplayNames = ReadonlyMap; - -/** - * Builds the lookup from a `listWorkbenchAgents` snapshot — one entry per - * agent, keyed by the same address every participant record carries. - */ -export function agentDisplayNamesFromAgents( - agents: readonly { readonly address: string; readonly displayName: string }[], -): AgentDisplayNames { - return new Map(agents.map((agent) => [agent.address, agent.displayName] as const)); -} - -/** - * The display name for an agent address when one is known — `undefined` - * when the snapshot hasn't loaded or names no such agent, so the caller - * falls back to its own slug-derived label rather than rendering a blank. - */ -export function displayNameForAddress( - address: string, - displayNames: AgentDisplayNames | undefined, -): string | undefined { - return displayNames?.get(address); -} diff --git a/apps/web/src/chat/api.ts b/apps/web/src/chat/api.ts deleted file mode 100644 index 5bda41cf5..000000000 --- a/apps/web/src/chat/api.ts +++ /dev/null @@ -1,1040 +0,0 @@ -// The chat surface's one seam to the hub's chat HTTP routes (see -// packages/chat/src/routes.ts, still the routes' implementation). Every -// fetch the chat/* components make goes through a function here, and every -// response is parsed with an arktype schema at the boundary — a route -// shape change is a one-file fix. -// -// The wire-level `Part` and participant schemas live in this package's own -// `./wire` (mirrored off the hub's wire contract) rather than being -// imported from `@corbits/chat` directly, so this browser bundle never -// carries a server-only dependency. - -import { type } from "arktype"; -import type { ArkErrors } from "arktype"; -import { Part } from "./wire/parts"; -import { parseParticipants } from "./wire/participants"; -import type { ParticipantRecord } from "./wire/participants"; -import { UnauthenticatedError } from "@/lib/api-query"; -import { InferenceSettingsApiError } from "@/settings/inference"; -import { CHAT_STRINGS } from "./strings"; - -export { - TextPart, - ReasoningPart, - ToolTracePart, - BlockPart, - FilePart, - EventPart, - Part, -} from "./wire/parts"; -export type { ParticipantRecord } from "./wire/participants"; -export const WorkbenchKind = type("'workbench' | 'chat'"); -export type WorkbenchKind = typeof WorkbenchKind.infer; - -/** Every workbench kind this UI has bespoke handling for. Any other value on - * the wire is a workbench kind the server knows about that this UI doesn't — - * it renders through the neutral, kind-agnostic path rather than being - * rejected at parse time. */ -export function isKnownWorkbenchKind(kind: string): kind is WorkbenchKind { - return kind === "workbench" || kind === "chat"; -} - -const WorkbenchWire = type({ - id: "string", - title: "string", - kind: "string", - pinned: "boolean", - "definitionId?": "string | null", - participants: "unknown[]", - "legacy?": "boolean", - // Row signals `GET /workbenches` annotates when it can resolve a - // workbench's mailbox (see `packages/chat/src/routes.ts`): absent, - // never a fabricated zero, for a workbench whose session isn't - // resolvable yet. `unreadCount` is the one exception — 0 is itself - // the honest "nothing unread" answer once a mailbox is resolved. - "unreadCount?": "number", - "lastActivityAt?": "string", - "live?": "'idle' | 'working' | 'reply-ready'", - // A bounded, text-only snippet of the newest message (see - // `packages/chat/src/codec.ts`'s `extractTextPreview`) — absent, never - // an empty string, when there is no message yet or it carries no text - // part. - "preview?": "string", - // `GET /workbenches` sets this server-side (see - // `packages/chat/src/routes.ts`) only for a workbench projected into - // this tenant via the shared-workbench machinery: "shared via - // parent · " for true siblings, "shared · " otherwise. Absent for every ordinary, non-projected workbench. - "sharedLabel?": "string", - // The workbench's own workbench tenant (every workbench minted through - // POST /workbenches carries a tenancy link; null only on true legacy - // rows) — what per-workbench surfaces like Insights scope on, since - // the WORKBENCH id is a run id, never a tenant id. - "tenancy?": type({ tenantId: "string" }).or("null"), -}); - -const Workbench = WorkbenchWire.pipe((wire) => ({ - ...wire, - participants: parseParticipants(wire.participants), -})); -export type Workbench = Omit & { - readonly participants: readonly ParticipantRecord[]; -}; - -const WorkbenchesResponse = type({ items: WorkbenchWire.array() }).pipe((response) => ({ - items: response.items.map((wire) => ({ - ...wire, - participants: parseParticipants(wire.participants), - })), -})); - -// `tenantId`/`tenantName`/`tenantMonogram` are set server-side only for a -// message sent by a shared workbench's "other side" participant — a share -// member of a tenant this workbench was projected into (see -// `resolveMessageSenderTenant` in `packages/chat/src/routes.ts`). Absent -// for every ordinary same-tenant sender. -export const MessageSender = type({ - name: "string | null", - address: "string", - "tenantId?": "string", - "tenantName?": "string", - "tenantMonogram?": "string", -}); -export type MessageSender = typeof MessageSender.infer; - -// `POST .../reactions/toggle`'s response shape, and the per-emoji entry -// `GET /messages` batches onto every item's `reactions` array — see -// `packages/chat/src/reactions.ts`'s `ReactionSummary`. `reactedByMe` is -// this signed-in principal's own membership in the emoji's reactor set, -// never another principal's. -const ReactionSummaryWire = type({ - emoji: "string", - count: "number", - reactedByMe: "boolean", -}); -export type ReactionSummary = typeof ReactionSummaryWire.infer; - -const MessageItem = type({ - id: "string", - createdAt: "string", - parts: Part.array(), - sender: MessageSender, - // Both fields are simply absent from the wire when the host never - // injected the corresponding store (see `CreateChatRoutesDeps` in - // `packages/chat/src/routes.ts`) — never a fabricated empty array or - // `false`, mirroring how `unreadCount` on `Workbench` works. - "reactions?": ReactionSummaryWire.array(), - "pinned?": "boolean", - // The client-generated send identity this message's own - // sender's composer submitted it with, echoed back once the server - // records it — see `sendMessage`'s `clientId` option and - // `packages/chat/src/client-ids.ts`. Absent for every message not - // sent with one (anything from before this feature, or from a peer - // whose own client never set it) — never a fabricated id. - "clientId?": "string", - // The thread this message belongs to, resolved server-side - // against the same "root feed by default" contract the per-thread - // feed filters on. Carrying it here is what lets one query serve the - // root feed and every open thread — see `./thread-feed.ts`. Absent - // on a host that mounts no thread store, matching `rootThreadId: ""`. - "threadId?": "string", - // The RFC 5322 `Message-ID` this row's mail carries — absent - // for a row with no mail original. This is what a reply's own - // `inReplyTo` threads onto, never this item's `id`, which for a - // mail-derived row is the mailbox's own local uid (see - // `mailbox-timeline.ts`'s `threadTreeToTimeline`). - "messageId?": "string", -}); -export type MessageItem = typeof MessageItem.infer; - -const MessagesResponse = type({ - items: MessageItem.array(), - "nextCursor?": "string", -}); -export type MessagesResponse = typeof MessagesResponse.infer; - -const ReadState = type({ - "lastSeenCreatedAt?": "string | null", - "lastSeenId?": "string | null", -}); - -// The shape `GET /api/tenants/:t/workflows/deployments` returns: a run, one row -// per definition executing in the bench. It carries no display name — only -// the id and the asset id its definition was hydrated from — so the mention -// popover derives a readable label from `definitionAssetId` (see -// `runDisplayName` below). -const Run = type({ - id: "string", - tenantId: "string", - definitionAssetId: "string", - status: "string", - createdAt: "string", -}); -export type Run = typeof Run.infer; - -const RunsResponse = Run.array(); - -// `GET /workbenches/:id/invitable` (see packages/chat/src/routes.ts): the -// tenant's deployed, launchable workflow definitions this workbench can -// invite an agent from — never including the workbench's own host. -const InvitableDefinition = type({ - id: "string", - name: "string", - "description?": "string", -}); -export type InvitableDefinition = typeof InvitableDefinition.infer; - -const InvitableDefinitionsResponse = type({ - items: InvitableDefinition.array(), -}); - -const InvitedAgent = type({ address: "string", definitionId: "string" }); -export type InvitedAgent = typeof InvitedAgent.infer; - -const RemovedParticipant = type({ address: "string" }); - -export class ChatApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); - } -} - -/** - * Plain-language copy for a failed chat request — never `error.message` - * or `String(error)` verbatim, which for a `ChatApiError` embeds the raw - * request path (see `request()` below). `InferenceSettingsApiError` - * carries the envelope `userMessage` (safe to show) rather than a path, - * so a 500 from `getResolvedCatalog` surfaces that sentence instead of - * the generic fallback. Every chat-ui/chat-adjacent catch block that - * surfaces an error to the user should call this rather than format one - * of its own, so the class of leak (a raw `/api/...` URL or bare status - * code in user-facing copy) has exactly one place to fix. - */ -export function describeChatError(cause: unknown, fallback: string): string { - const statused = - cause instanceof ChatApiError || cause instanceof InferenceSettingsApiError ? cause : null; - if (statused === null) return fallback; - switch (statused.status) { - case 401: - return "You're signed out. Sign in again to continue."; - case 403: - return "You don't have access to this."; - case undefined: - return "Couldn't reach the server. Check your connection and try again."; - default: { - if (cause instanceof InferenceSettingsApiError && statused.message.trim() !== "") { - return statused.message; - } - return statused.status >= 500 - ? "Something went wrong on our end. Try again in a moment." - : fallback; - } - } -} - -type Validator = (data: unknown) => T | ArkErrors; - -async function request(path: string, schema: Validator, init?: RequestInit): Promise { - let response: Response; - try { - response = await fetch(path, { - ...init, - headers: { "content-type": "application/json", ...init?.headers }, - }); - } catch (cause) { - throw new ChatApiError(cause instanceof Error ? cause.message : String(cause)); - } - if (response.status === 401) { - throw new UnauthenticatedError(); - } - if (!response.ok) { - throw new ChatApiError(`The server answered ${response.status} for ${path}.`, response.status); - } - const body: unknown = await response.json().catch(() => undefined); - const parsed = schema(body); - if (parsed instanceof type.errors) { - throw new ChatApiError(`Unexpected response shape from ${path}: ${parsed.summary}`); - } - return parsed; -} - -function workbenchesPath(tenantId: string, kind: WorkbenchKind): string { - return `/api/tenants/${tenantId}/chat/workbenches?kind=${kind}`; -} - -/** - * The shared TanStack Query key for `listWorkbenches(tenantId, kind)` — - * defined here (not in the app's own key module) because this package owns - * both the endpoint and `WorkbenchKind`. Every surface that lists workbenches of - * a given kind (the shell's bench-activity, the command palette, the - * Routines picker, this package's own `ChatWorkspace` sidebar) keys its - * query with this function so they all subscribe to the one cached fetch - * per (tenantId, kind) instead of each firing its own. - */ -export function workbenchesQueryKey( - tenantId: string, - kind: WorkbenchKind, -): readonly [string, string, string, WorkbenchKind] { - return ["tenant", tenantId, "workbenches", kind] as const; -} - -/** Prefix covering every `workbenchesQueryKey` kind for a tenant — invalidate - * this after a mutation (create, rename, pin) to refetch both kinds. */ -export function workbenchesQueryKeyPrefix(tenantId: string): readonly [string, string, string] { - return ["tenant", tenantId, "workbenches"] as const; -} - -export function listWorkbenches( - tenantId: string, - kind: WorkbenchKind, -): Promise { - return request(workbenchesPath(tenantId, kind), WorkbenchesResponse).then((page) => page.items); -} - -/** - * Every workbench a tenant holds, of any kind — `kind` is optional - * server-side (`packages/chat/src/routes.ts`'s `GET /workbenches`), and - * workbench kinds are open-ended (`packages/chat/src/kinds.ts`), so this - * omits the query param entirely rather than hardcoding the two kinds - * this UI has bespoke handling for. Used where the caller needs the - * complete workbench-host/participant surface regardless of kind — e.g. - * the shell's second column splitting the result into its workbenches and - * chats sections (see `apps/web/src/shell/bench-activity.ts`). - */ -export function listAllWorkbenches(tenantId: string): Promise { - return request(`/api/tenants/${tenantId}/chat/workbenches`, WorkbenchesResponse).then( - (page) => page.items, - ); -} - -// A chat is a direct thread with exactly one counterpart, picked at -// creation and fixed for its lifetime: either an agent (`definitionId`) -// or a bench member (`principalId`) — never both. The name is optional -// either way (the server titles it by the counterpart's handle when -// omitted). A workbench is the pinned, multiplayer kind: name-only, no -// counterpart attached at creation. See `packages/chat/src/routes.ts` -// `POST /workbenches` for the server side of this union. -// -// `kind: "chat"` + `definitionId` always find-or-reopens the one DM -// for that agent. `reuseExisting` is still accepted on the -// wire and ignored. `kind: "workbench"` mints an empty channel; a room's -// onboarding walkthrough is posted separately through -// `postWorkbenchOnboardingStep`, never as a side effect of create. -export type CreateWorkbenchInput = - | { - readonly kind: "workbench"; - readonly name: string; - } - | { - readonly kind: "chat"; - readonly definitionId: string; - readonly name?: string; - readonly reuseExisting?: boolean; - } - | { - readonly kind: "chat"; - readonly principalId: string; - readonly name?: string; - }; - -/** Fired on `window` after every successful `createWorkbench`, carrying - * `{tenantId}` in `detail`. The host shell's sidebar list caches its - * workbench listings outside this package (see - * `apps/web/src/shell/bench-activity.ts`), and creation happens at many - * call sites (the picker dialog, agent launch, the land-hop) — one - * signal here reaches them all, so a freshly minted workbench appears - * in the sidebar without waiting for an unrelated refetch. */ -export const WORKBENCHES_MUTATED_EVENT = "workbench:chat:workbenches-mutated"; - -/** SSE `event.type` the chat service publishes onto a workbench stream when - * the tenant's workbench list changed (a specialist minted in the - * background, a create from another tab). The host sidebar already - * invalidates on `WORKBENCHES_MUTATED_EVENT`; `applyStreamWorkbenchesMutated` - * is the bridge from this stream payload onto that same CustomEvent. */ -export const WORKBENCHES_MUTATED_STREAM_TYPE = "chat.workbenches-mutated"; - -const WorkbenchesMutatedStreamData = type({ - tenantId: "string", - "+": "ignore", -}); - -/** Parses a `chat.workbenches-mutated` SSE payload and, on success, fires - * `WORKBENCHES_MUTATED_EVENT` with `{tenantId}` so the shell sidebar - * refetches. Parse failure is a no-op — a malformed stream event must - * never throw into the EventSource handler. Extra keys are ignored so - * the server can grow the payload without breaking older clients. */ -export function applyStreamWorkbenchesMutated(data: unknown): void { - const parsed = WorkbenchesMutatedStreamData(data); - if (parsed instanceof type.errors) return; - if (typeof window === "undefined") return; - window.dispatchEvent( - new CustomEvent(WORKBENCHES_MUTATED_EVENT, { - detail: { tenantId: parsed.tenantId }, - }), - ); -} - -export function createWorkbench(tenantId: string, input: CreateWorkbenchInput): Promise { - return request(`/api/tenants/${tenantId}/chat/workbenches`, Workbench, { - method: "POST", - body: JSON.stringify(input), - }).then((workbench) => { - if (typeof window !== "undefined") { - window.dispatchEvent(new CustomEvent(WORKBENCHES_MUTATED_EVENT, { detail: { tenantId } })); - } - return workbench; - }); -} - -export function listMessages( - tenantId: string, - workbenchId: string, - cursor?: string, -): Promise { - const query = cursor !== undefined ? `?cursor=${cursor}` : ""; - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages${query}`, - MessagesResponse, - ); -} - -const BlobResponse = type({ contentBase64: "string" }); - -/** - * A `FilePart`'s bytes, base64-encoded (`GET /workbenches/:id/blobs/:blobId`). - * There is no stored link from a chat blob to a Library artifact today — - * this is the fallback read path a host uses to open a chat attachment - * without one (see `chat-artifact-open.ts` in the web app). - */ -export function fetchWorkbenchBlob( - tenantId: string, - workbenchId: string, - blobId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/blobs/${encodeURIComponent(blobId)}`, - BlobResponse, - ).then((body) => body.contentBase64); -} - -// The wire response of `@corbits/mailbox`'s `POST /me/inbox/send` -// The Sent-folder copy's own RFC 5322 `Message-ID` and its -// mailbox uid, the same uid `mailbox-timeline.ts`'s `threadTreeToTimeline` -// keys every timeline row by. -const SentInboxMessage = type({ - messageId: "string", - uid: "number", -}); -export type SentInboxMessage = typeof SentInboxMessage.infer; - -/** - * A human composer send, straight onto the tenant's own mailbox - * (`POST /me/inbox/send`) — no chat route in between. `to` is - * every other recipient address this send goes to; `inReplyTo` is the - * RFC `Message-ID` of the message this one threads under (the thread - * root when replying inside an open thread, or the specific message being - * replied to when starting a new one) — never a mailbox uid, which is a - * local, per-mailbox integer the wire format has no room for. - */ -export function sendInboxMessage( - tenantId: string, - to: readonly string[], - body: string, - options?: { readonly inReplyTo?: string }, -): Promise { - const requestBody: Record = { to, body }; - if (options?.inReplyTo !== undefined) requestBody["inReplyTo"] = options.inReplyTo; - return request(`/api/tenants/${tenantId}/mailbox/me/inbox/send`, SentInboxMessage, { - method: "POST", - body: JSON.stringify(requestBody), - }); -} - -// `parentThreadId` is the thread this one hangs directly off: null for the -// root thread, the root thread's id for a depth-1 thread, a depth-1 -// thread's id for a depth-2 sub-thread. Two levels, stop — see -// `resolveThreadAnchor` in `packages/chat/src/threads.ts`. -export const WorkbenchThread = type({ - id: "string", - kind: "'root' | 'reply' | 'delivery'", - parentMessageId: "string | null", - parentThreadId: "string | null", - runRef: "string | null", - title: "string | null", - createdAt: "string", -}); -export type WorkbenchThread = typeof WorkbenchThread.infer; - -// A listed thread carries its own reply activity — the -// affordance on a parent message shows "N replies" and a last-activity -// stamp, and computing those client-side meant a `GET -// /threads/:id/messages` per thread on every timeline refresh. Only the -// list response has these; `forkThread` and an open thread's own -// `thread` field return the bare thread. -export const WorkbenchThreadRow = WorkbenchThread.and({ - replyCount: "number", - /** Null, never the thread's creation time, for a thread with no - * messages yet — an empty thread has had no activity. */ - lastActivityAt: "string | null", -}); -export type WorkbenchThreadRow = typeof WorkbenchThreadRow.infer; - -const ThreadsResponse = type({ - rootThreadId: "string", - items: WorkbenchThreadRow.array(), -}); - -export function listThreads( - tenantId: string, - workbenchId: string, -): Promise<{ - readonly rootThreadId: string; - readonly items: readonly WorkbenchThreadRow[]; -}> { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/threads`, - ThreadsResponse, - ); -} - -/** - * A first-class fork: spawn a sub-thread rooted at any message inside a - * thread — something Slack doesn't have. Idempotent per origin - * message, and honors the two-level cap server-side: forking a message - * already inside a sub-thread creates a sibling sub-thread under that - * sub-thread's parent, never a third level (see `resolveThreadAnchor` in - * `packages/chat/src/threads.ts`). - */ -export function forkThread( - tenantId: string, - workbenchId: string, - parentMessageId: string, - title?: string, -): Promise { - const body: Record = { parentMessageId }; - if (title !== undefined) body["title"] = title; - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/threads/fork`, - WorkbenchThread, - { method: "POST", body: JSON.stringify(body) }, - ); -} - -export function putReadState( - tenantId: string, - workbenchId: string, - input: { readonly lastSeenCreatedAt: string; readonly lastSeenId: string }, -): Promise { - return request(`/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/read-state`, ReadState, { - method: "PUT", - body: JSON.stringify(input), - }).then(() => undefined); -} - -export function listRuns(tenantId: string): Promise { - return request(`/api/tenants/${tenantId}/workflows/deployments`, RunsResponse); -} - -export function listInvitableDefinitions( - tenantId: string, - workbenchId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/invitable`, - InvitableDefinitionsResponse, - ).then((page) => page.items); -} - -/** - * The tenant-wide invitable listing (`GET /invitable-definitions`) the - * new-chat dialog reads before any workbench exists — the per-workbench - * variant above 404s on a workbench id that isn't real. - */ -export function listTenantInvitableDefinitions( - tenantId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/invitable-definitions`, - InvitableDefinitionsResponse, - ).then((page) => page.items); -} - -export function inviteAgent( - tenantId: string, - workbenchId: string, - definitionId: string, -): Promise { - return request(`/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/invite`, InvitedAgent, { - method: "POST", - body: JSON.stringify({ definitionId }), - }); -} - -// `DELETE /workbenches/:id/participants/:address` (see -// `packages/chat/src/routes.ts`): the removal counterpart to -// `inviteAgent`/workbench creation's own join — drops the participant and, -// for an invited agent, releases its launched instance server-side. The -// Members section calls this per row and refetches `getWorkbenchSettings` -// on success rather than trusting an optimistic local edit. -export function removeWorkbenchParticipant( - tenantId: string, - workbenchId: string, - address: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/participants/${encodeURIComponent(address)}`, - RemovedParticipant, - { method: "DELETE" }, - ).then(() => undefined); -} - -// `GET /workbenches/:id/agents` (see `packages/chat/src/routes.ts`): every -// one of the workbench's agent participants, each resolved to the -// definition id its name/instructions are read from and saved to via -// `@corbits/agent-directory`'s own routes (see `getAgentInstructions`/ -// `updateAgentInstructions` below). A workbench with several invited -// agents lists all of them, not just the first. -const WorkbenchAgentWire = type({ - address: "string", - handle: "string", - definitionId: "string", - definitionAssetId: "string", - displayName: "string", -}); -export type WorkbenchAgent = typeof WorkbenchAgentWire.infer; - -const WorkbenchAgentsResponse = type({ items: WorkbenchAgentWire.array() }); - -export function listWorkbenchAgents( - tenantId: string, - workbenchId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/agents`, - WorkbenchAgentsResponse, - ).then((page) => page.items); -} - -// `POST /workbenches/:id/agents/refresh` (see -// `packages/chat/src/routes.ts`): recomputes the given agent's running -// instance from its definition's CURRENT instructions — a wake replays -// whatever the workbench's launch record holds verbatim, so a definition -// edit reaches a running instance only after this call. The Assistant -// section calls it right after `updateAgentInstructions` succeeds, so -// the change is live for this workbench's agent from its next reply. -export function refreshWorkbenchAgent( - tenantId: string, - workbenchId: string, - address: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/agents/refresh`, - type({ ok: "boolean" }), - { method: "POST", body: JSON.stringify({ address }) }, - ).then(() => undefined); -} - -// `GET`/`PUT /api/tenants/:t/agent-definitions/:id` (see -// `packages/agent-directory/src/routes.ts`): an agent's editable -// persona — its display name and system prompt (surfaced to a person as -// "instructions"). `name` here is the display name, matching the create -// form's own "name" field (see `CreateAgentDefinitionInput`), never the -// definition's immutable handle. -const AgentCapabilitiesWire = type({ - toolPackagePins: type({ name: "string", version: "string" }).array(), - skills: "string[]", - "model?": "string", -}); -export type AgentCapabilities = typeof AgentCapabilitiesWire.infer; - -const AgentInstructionsWire = type({ - name: "string", - systemPrompt: "string", -}); -export type AgentInstructions = typeof AgentInstructionsWire.infer; - -/** `GET`/`POST .../restore`'s fuller shape: the editable persona plus its - * current capability snapshot, in one read — the settings surface's - * "Capabilities" list never needs a second round trip to show what an - * agent already carries. */ -const AgentDetailWire = type({ - name: "string", - systemPrompt: "string", - toolPackagePins: type({ name: "string", version: "string" }).array(), - skills: "string[]", - "model?": "string", -}); -export type AgentDetail = typeof AgentDetailWire.infer; - -function agentInstructionsPath(tenantId: string, definitionId: string) { - return `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}`; -} - -// `GET /agent-definitions/visible` (see -// `packages/agent-directory/src/visible-definitions.ts`): every agent -// definition this tenant can open a direct chat with — its own, plus -// every ancestor tenant's, a child's same-name definition shadowing an -// ancestor's. `tenantId` here is the definition's OWNING tenant, which -// is where its DM workbench actually lives — never necessarily the -// caller's own tenant. -const VisibleAgentDefinitionWire = type({ - id: "string", - name: "string", - tenantId: "string", - tenantName: "string", - createdAt: "string", -}); -export type VisibleAgentDefinition = typeof VisibleAgentDefinitionWire.infer; - -const VisibleAgentDefinitionsResponse = type({ - definitions: VisibleAgentDefinitionWire.array(), -}); - -export function listVisibleAgentDefinitions( - tenantId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/agent-definitions/visible`, - VisibleAgentDefinitionsResponse, - ).then((page) => page.definitions); -} - -/** - * Opens a direct chat with an agent, minting it on first open and - * reusing the same workbench on every later open — `packages/chat/src/ - * routes.ts`'s `POST /workbenches` with `reuseExisting: true` already - * finds-or-creates by `chat/definitionId` (`findExistingAgentChat`), the - * same seam the home-workbench land-hop uses. `tenantId` must be the - * definition's OWNING tenant (see `VisibleAgentDefinition.tenantId`), - * never the caller's own tenant when the agent was reached through - * ancestor inheritance — the DM workbench lives where the agent lives. - */ -export function openAgentDm(tenantId: string, definitionId: string): Promise { - return createWorkbench(tenantId, { - kind: "chat", - definitionId, - reuseExisting: true, - }); -} - -export function getAgentInstructions(tenantId: string, definitionId: string): Promise { - return request(agentInstructionsPath(tenantId, definitionId), AgentDetailWire); -} - -export function updateAgentInstructions( - tenantId: string, - definitionId: string, - input: AgentInstructions, -): Promise { - return request(agentInstructionsPath(tenantId, definitionId), AgentInstructionsWire, { - method: "PUT", - body: JSON.stringify(input), - }); -} - -// `GET /:definitionId/versions` / `POST /:definitionId/restore` (see -// `packages/agent-directory/src/routes.ts`): the agent's own instructions/ -// capabilities history, mirroring `@corbits/skills`' version-history shape -// exactly (`commitSha`/`message`/`author`/`committedAtIso`/`current`) — the -// sha only ever appears in a tooltip, never in the label a person reads. -const AgentVersionWire = type({ - commitSha: "string", - message: "string", - author: "string", - committedAtIso: "string", - current: "boolean", -}); -export type AgentVersion = typeof AgentVersionWire.infer; - -export function listAgentVersions( - tenantId: string, - definitionId: string, -): Promise { - return request( - `${agentInstructionsPath(tenantId, definitionId)}/versions`, - type({ versions: AgentVersionWire.array() }), - ).then((page) => page.versions); -} - -export function restoreAgentVersion( - tenantId: string, - definitionId: string, - commitSha: string, -): Promise { - return request(`${agentInstructionsPath(tenantId, definitionId)}/restore`, AgentDetailWire, { - method: "POST", - body: JSON.stringify({ commitSha }), - }); -} - -// `GET /agent-definitions/capabilities/inventory` / -// `POST /:definitionId/capabilities` (see `packages/agent-directory/src/ -// routes.ts`): the guided capability-add surface. The inventory call feeds -// the add picker with only what this tenant actually has — a tool package, -// skill, or model this call doesn't list can never be added, since the -// server re-checks the same inventory fail-closed on the add itself. -const CapabilityInventoryWire = type({ - toolPackages: type({ name: "string" }).array(), - skills: type({ name: "string" }).array(), - models: type({ canonicalName: "string" }).array(), -}); -export type CapabilityInventory = typeof CapabilityInventoryWire.infer; - -export function listCapabilityInventory(tenantId: string): Promise { - return request( - `/api/tenants/${tenantId}/agent-definitions/capabilities/inventory`, - CapabilityInventoryWire, - ); -} - -export type CapabilityAddition = - | { readonly kind: "toolPackage"; readonly name: string } - | { readonly kind: "skill"; readonly name: string } - | { readonly kind: "model"; readonly canonicalName: string }; - -export function addAgentCapability( - tenantId: string, - definitionId: string, - addition: CapabilityAddition, -): Promise { - return request( - `${agentInstructionsPath(tenantId, definitionId)}/capabilities`, - AgentCapabilitiesWire, - { method: "POST", body: JSON.stringify(addition) }, - ); -} - -export function workbenchStreamUrl(tenantId: string, workbenchId: string): string { - return `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/stream`; -} - -/** - * `POST .../presence` (see `packages/chat/src/routes.ts`): keeps - * this principal's `lastActiveAt` fresh on the who's-here roster while its - * stream connection sits open — "here at all" already comes for free from - * the open connection itself, so this is called on real activity, never - * on a polling interval. Best-effort: a dropped ping just means the next - * one (or the eventual `"offline"` on disconnect) catches up. - */ -export function pingWorkbenchPresence(tenantId: string, workbenchId: string): Promise { - return fetch(`/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/presence`, { - method: "POST", - }) - .then(() => undefined) - .catch(() => undefined); -} - -// `chat/contextWindow`'s two-way "inherit vs override" resolution — see -// `resolveContextWindow` in `packages/chat/src/workbench-settings.ts`, whose -// server-side output this wire shape mirrors. `source` is what the settings -// panel's "Use bench default (N)" vs override control reads to decide which -// state it renders. -export const ResolvedContextWindow = type({ - value: "number", - source: "'inherit' | 'override'", -}); -export type ResolvedContextWindow = typeof ResolvedContextWindow.infer; - -const WorkbenchSettingsResponse = WorkbenchWire.and({ - settings: type("Record"), - contextWindow: ResolvedContextWindow, -}).pipe((wire) => ({ - ...wire, - participants: parseParticipants(wire.participants), -})); -export type WorkbenchSettings = Omit & { - readonly participants: readonly ParticipantRecord[]; -}; - -export function getWorkbenchSettings( - tenantId: string, - workbenchId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/settings`, - WorkbenchSettingsResponse, - ); -} - -/** - * A `chat/*`-namespaced settings patch: name, purpose, pinned, and - * context-window edits all go through this one function, matching the - * single `PATCH /workbenches/:id/settings` route in - * `packages/chat/src/routes.ts` that accepts any subset of them in one - * body. `chat/contextWindow: null` clears - * a workbench's override back to inheriting the bench default. - */ -export type WorkbenchSettingsPatch = { - readonly "chat/kind"?: string; - readonly "chat/name"?: string; - readonly "chat/purpose"?: string; - readonly "chat/pinned"?: boolean; - readonly "chat/contextWindow"?: number | null; - /** - * `template/*` keys: the room's own record of which template minted - * it and what it still needs connected, per `@workbench/templates`'s - * own schema for this namespace. `chat`'s settings route validates - * only its own `chat/*` keys and passes any other namespace through - * opaquely (see `packages/chat/src/workbench-settings.ts`) — a - * `template/*` patch is validated by the caller - * (`apps/web/src/instant-agent-create.ts`) against that schema before - * it ever reaches this function. - */ - readonly "template/id"?: string; - readonly "template/pendingConnections"?: readonly string[]; -}; - -export function patchWorkbenchSettings( - tenantId: string, - workbenchId: string, - patch: WorkbenchSettingsPatch, -): Promise { - return request( - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/settings`, - WorkbenchSettingsResponse, - { method: "PATCH", body: JSON.stringify(patch) }, - ); -} - -// Hub-zero T3: the room card's live state read and -// start-reviewing write lived here, backed by the hub's now-deleted -// workbench-scoped GitHub mount. GitHub connect/disconnect -// itself stays native `connections/*` (`@/settings`); the card's -// state/start rebind is a connections follow-up. - -// `GET`/`PATCH /bench/settings` (see `packages/chat/src/routes.ts`): the -// bench-wide chat defaults every workbench inherits unless it sets its own -// override. Currently just the default context window. -const BenchChatSettingsResponse = type({ - settings: "Record", - contextWindow: "number", -}); -export type BenchChatSettings = typeof BenchChatSettingsResponse.infer; - -export function getBenchChatSettings(tenantId: string): Promise { - return request(`/api/tenants/${tenantId}/chat/bench/settings`, BenchChatSettingsResponse); -} - -export type BenchChatSettingsPatch = { - readonly "chat/contextWindow": number; -}; - -export function patchBenchChatSettings( - tenantId: string, - patch: BenchChatSettingsPatch, -): Promise { - return request(`/api/tenants/${tenantId}/chat/bench/settings`, BenchChatSettingsResponse, { - method: "PATCH", - body: JSON.stringify(patch), - }); -} - -// The turn projection's read surface: what a client -// reattaching to a workbench (page navigation, tab refocus, a dropped SSE -// connection) uses to find whether a turn is still running and, if so, -// replay whatever text it has already committed before the live stream's -// tail resumes — see `GET /workbenches/:id/turns[/:turnId]` in -// `packages/chat/src/routes.ts`. -const AgentTurnWire = type({ - id: "string", - workbenchId: "string", - agentAddress: "string", - childRunId: "string", - status: "'running' | 'completed' | 'failed' | 'cancelled'", - "replyMessageId?": "string | null", -}); -export type AgentTurnSummary = typeof AgentTurnWire.infer; - -const AgentTurnsListWire = type({ items: AgentTurnWire.array() }); - -function turnsPath(tenantId: string, workbenchId: string): string { - return `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/turns`; -} - -export function listWorkbenchTurns( - tenantId: string, - workbenchId: string, -): Promise { - return request(turnsPath(tenantId, workbenchId), AgentTurnsListWire).then((body) => body.items); -} - -const AgentTurnDetailWire = AgentTurnWire.and({ - "textSnapshot?": "string | null", -}); -export type AgentTurnDetail = typeof AgentTurnDetailWire.infer; - -export function getWorkbenchTurn( - tenantId: string, - workbenchId: string, - turnId: string, -): Promise { - return request(`${turnsPath(tenantId, workbenchId)}/${turnId}`, AgentTurnDetailWire); -} - -const CancelWorkbenchTurnWire = type({ cancelledCount: "number" }); -export type CancelWorkbenchTurnResult = typeof CancelWorkbenchTurnWire.infer; - -/** - * Stops a workbench's in-flight turn(s) — `POST - * .../turns/cancel` in `packages/chat/src/routes.ts`. `cancelledCount` - * is the honest count of turns actually settled `cancelled`, not a - * promise that the underlying agent process stopped: the - * composer's own Stop affordance treats any non-throwing response as - * "asked," and relies on the timeline's cancelled-turn notice — not this - * response — to clear the typing indicator. - */ -export function cancelWorkbenchTurn( - tenantId: string, - workbenchId: string, -): Promise { - return request(`${turnsPath(tenantId, workbenchId)}/cancel`, CancelWorkbenchTurnWire, { - method: "POST", - }); -} - -/** - * The newest still-`running` turn for `agentAddress`, or `null` if none — - * what a remounting workbench asks on mount to know whether to hydrate its - * streaming indicator immediately rather than wait for the next live event. - * A 404 (no turn store injected on this deployment) reads the same as "no - * running turn": the feature is simply unavailable, never an error the - * caller needs to handle. - */ -export async function fetchRunningTurn( - tenantId: string, - workbenchId: string, - agentAddress: string, -): Promise { - let turns: readonly AgentTurnSummary[]; - try { - turns = await listWorkbenchTurns(tenantId, workbenchId); - } catch (cause) { - if (cause instanceof ChatApiError && cause.status === 404) return null; - throw cause; - } - const running = turns.find( - (turn) => turn.status === "running" && turn.agentAddress === agentAddress, - ); - if (running === undefined) return null; - const detail = await getWorkbenchTurn(tenantId, workbenchId, running.id); - return { ...detail, textSnapshot: detail.textSnapshot ?? null }; -} - -/** - * A readable name for a run, since the runs listing carries no name field: - * the asset id's final path segment with any extension stripped, e.g. - * `researcher/workflow.json` → "workflow". An asset id with no path shape - * at all carries no readable segment to extract, so it renders friendly - * placeholder copy — never the raw asset id. - */ -export function runDisplayName(run: Run): string { - const slash = run.definitionAssetId.lastIndexOf("/"); - if (slash < 0) return CHAT_STRINGS.unnamedRun; - const segment = run.definitionAssetId.slice(slash + 1); - if (segment.length === 0) return CHAT_STRINGS.unnamedRun; - const dot = segment.lastIndexOf("."); - return dot > 0 ? segment.slice(0, dot) : segment; -} diff --git a/apps/web/src/chat/artifact-chip.tsx b/apps/web/src/chat/artifact-chip.tsx deleted file mode 100644 index 0a9073404..000000000 --- a/apps/web/src/chat/artifact-chip.tsx +++ /dev/null @@ -1,64 +0,0 @@ -// A message's `file` part is the wire shape for an artifact reference: it -// always carries a name and media type, and carries `blobId` once the -// platform has persisted the bytes, or `artifactId` when it also links back -// to a Library row (`packages/chat/src/parts.ts`). Either one gives -// the host a stable id to open — a still-in-flight `data`-only attachment -// with neither renders the same chip, inert. -// -// Opening a chip is a callback the host supplies (mirrors `onOpenProfile` -// and `onOpenThread` in `timeline.tsx`): this package owns no router. A -// second, artifactId-only affordance — "Open in Artifacts" — hands the host a -// separate callback so it can navigate there directly; it only -// ever renders when `artifactId` is present, since a blob-only part has no -// Library row to deep-link to. - -import { FileText, FolderOpen } from "@/lib/icons"; - -import type { Part } from "./api"; -import { CHAT_STRINGS } from "./strings"; - -export function ArtifactChip({ - part, - onOpen, - onOpenInLibrary, -}: { - readonly part: Part & { kind: "file" }; - readonly onOpen?: (part: Part & { kind: "file" }) => void; - readonly onOpenInLibrary?: (part: Part & { kind: "file" }) => void; -}) { - const openable = - (part.blobId !== undefined || part.artifactId !== undefined) && onOpen !== undefined; - const libraryOpenable = part.artifactId !== undefined && onOpenInLibrary !== undefined; - - return ( -
- - {libraryOpenable && onOpenInLibrary !== undefined ? ( - - ) : null} -
- ); -} diff --git a/apps/web/src/chat/blocks/approve-block.test.tsx b/apps/web/src/chat/blocks/approve-block.test.tsx deleted file mode 100644 index d8f54d3b3..000000000 --- a/apps/web/src/chat/blocks/approve-block.test.tsx +++ /dev/null @@ -1,383 +0,0 @@ -// DOM tests for the approve card's live round-trip. The host port -// (`ApprovalActions`) is the mock boundary — a fake implementation stands in -// for the host's fetch-backed reads/writes, the same way the block never -// talks to `fetch` itself. Covers: pending+actable, pending+spectator, -// approve success (through to the invalidation callback the host's real -// port would run), approve failure (no fake resolution), an -// already-resolved render, the platform-truth panel (never agent framing -// alone next to live buttons), and the read-then-act conflict race. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import type { - ApprovalActions, - ApprovalDecisionResult, - ApprovalLiveStatus, - ApprovalStatusQuery, - PlatformApprovalDetail, -} from "./approval-actions"; -import type { MessageItem } from "../api"; -import { WorkbenchTimeline } from "../timeline"; - -const PLATFORM_DETAIL: PlatformApprovalDetail = { - agentName: "Payments Bot", - headline: "Wire $50,000 to acct_9182", - arguments: { destination: "acct_9182", amountUsd: "50000" }, -}; - -function messageWithApproveBlock(approvalId: string): MessageItem[] { - return [ - { - id: "m1", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [ - { - kind: "block", - block: { - type: "approve", - data: { - approvalId, - title: "Refresh cache", - risk: "low", - body: "Just a routine refresh, nothing to worry about!", - }, - }, - }, - ], - sender: { name: "Researcher", address: "researcher@agents.example" }, - }, - ]; -} - -function fakeActions(overrides: Partial): ApprovalActions { - return { - getStatus: async () => ({ kind: "loading" }) as ApprovalStatusQuery, - approve: async () => ({ kind: "resolved", status: "approved" }) as ApprovalDecisionResult, - reject: async () => ({ kind: "resolved", status: "rejected" }) as ApprovalDecisionResult, - ...overrides, - }; -} - -/** A stateful fake standing in for the host's read/write round-trip: a - * decision actually changes what the next `getStatus` returns, the same - * way the real platform's approve/reject + re-read behaves. Lets tests - * exercise "the card re-syncs after any decision outcome" honestly instead - * of asserting against a decision response the card is no longer supposed - * to trust on its own. */ -function fakeBackend( - initialStatus: ApprovalLiveStatus, - canAct: boolean, - detail: PlatformApprovalDetail = PLATFORM_DETAIL, -) { - let status = initialStatus; - const approveCalls: string[] = []; - - const actions: ApprovalActions = { - getStatus: async () => ({ kind: "ready", status, canAct, detail }), - approve: async (id) => { - approveCalls.push(id); - status = "approved"; - return { kind: "resolved", status: "approved" }; - }, - reject: async () => { - status = "rejected"; - return { kind: "resolved", status: "rejected" }; - }, - }; - - return { actions, approveCalls }; -} - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - if (root !== null) act(() => root?.unmount()); - container?.remove(); - container = null; - root = null; -}); - -async function mount(actions: ApprovalActions, approvalId = "apv_1") { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render( - , - ); - }); - return container; -} - -describe("approve card round-trip", () => { - test("binds approvalId from the gate-blocked approve block onto getStatus and decide", async () => { - // The orchestrator posts `{ approvalId: approval.id }` from the row - // `findByCorrelationId` resolved for the gate-blocked event. The card - // must pass that id through — never invent one, never drop it. - const approvalId = "apr_from_gate_blocked"; - const statusIds: string[] = []; - const decideIds: string[] = []; - const actions = fakeActions({ - getStatus: async (id) => { - statusIds.push(id); - return { - kind: "ready", - status: "pending", - canAct: true, - detail: PLATFORM_DETAIL, - }; - }, - approve: async (id) => { - decideIds.push(id); - return { kind: "resolved", status: "approved" }; - }, - }); - const el = await mount(actions, approvalId); - - expect(statusIds).toEqual([approvalId]); - - const approveButton = el.querySelector(".chat-block-actions button") as HTMLButtonElement; - await act(async () => { - approveButton.click(); - }); - expect(decideIds).toEqual([approvalId]); - // Re-sync after decide also keys off the same bound id. - expect(statusIds).toEqual([approvalId, approvalId]); - }); - - test("pending + actable: buttons render enabled and call the port", async () => { - const backend = fakeBackend("pending", true); - const el = await mount(backend.actions); - - const buttons = el.querySelectorAll(".chat-block-actions button"); - expect(buttons).toHaveLength(2); - expect((buttons[0] as HTMLButtonElement).disabled).toBe(false); - expect((buttons[1] as HTMLButtonElement).disabled).toBe(false); - - await act(async () => { - (buttons[0] as HTMLButtonElement).click(); - }); - - expect(backend.approveCalls).toEqual(["apv_1"]); - expect(el.textContent).toContain("Approved"); - }); - - test("pending + spectator: status and platform detail shown, no buttons", async () => { - const el = await mount(fakeBackend("pending", false).actions); - - expect(el.querySelectorAll(".chat-block-actions button")).toHaveLength(0); - expect(el.textContent).toContain("This one isn't yours to decide."); - expect(el.textContent).toContain("Wire $50,000 to acct_9182"); - }); - - test("the platform's own headline and arguments render, always ahead of the agent's framing", async () => { - const el = await mount(fakeBackend("pending", true).actions); - - expect(el.textContent).toContain("Payments Bot is asking to"); - expect(el.textContent).toContain("Wire $50,000 to acct_9182"); - expect(el.textContent).toContain("acct_9182"); - expect(el.textContent).toContain("50000"); - - // Both the platform truth and the agent's own (demoted) framing are on - // the page, but the platform detail must appear first in document - // order — never the agent's framing standing in ahead of it. - const platformIndex = el.innerHTML.indexOf("Wire $50,000 to acct_9182"); - const agentIndex = el.innerHTML.indexOf("Just a routine refresh, nothing to worry about!"); - expect(platformIndex).toBeGreaterThan(-1); - expect(agentIndex).toBeGreaterThan(-1); - expect(platformIndex).toBeLessThan(agentIndex); - }); - - test("a forbidden status read renders live buttons with NO description at all — never the agent's framing standing in for the platform's", async () => { - const el = await mount( - fakeActions({ - getStatus: async () => ({ kind: "forbidden" }), - approve: async () => ({ kind: "forbidden", message: "nope" }), - }), - ); - - const buttons = el.querySelectorAll(".chat-block-actions button"); - expect(buttons).toHaveLength(2); - expect((buttons[0] as HTMLButtonElement).disabled).toBe(false); - - // The agent's own body text — the exact confused-deputy risk (an - // innocuous "Refresh cache" framing over a real wire transfer) — must - // never render as the description sitting next to live buttons. - expect(el.textContent).not.toContain("Just a routine refresh, nothing to worry about!"); - - await act(async () => { - (buttons[0] as HTMLButtonElement).click(); - }); - expect(el.textContent).toContain("You do not have permission to act on this."); - }); - - test("approve success re-renders resolved state from a re-read and invalidates", async () => { - let invalidated = false; - const backend = fakeBackend("pending", true); - const originalApprove = backend.actions.approve; - const actions: ApprovalActions = { - ...backend.actions, - approve: async (id) => { - invalidated = true; // stands in for the host's queryClient.invalidateQueries - return originalApprove(id); - }, - }; - const el = await mount(actions); - - const approveButton = el.querySelector(".chat-block-actions button") as HTMLButtonElement; - await act(async () => { - approveButton.click(); - }); - - expect(invalidated).toBe(true); - expect(el.querySelectorAll(".chat-block-actions button")).toHaveLength(0); - expect(el.textContent).toContain("Approved"); - }); - - test("approve failure re-syncs from a read and never fakes resolution", async () => { - const el = await mount( - fakeActions({ - getStatus: async () => ({ - kind: "ready", - status: "pending", - canAct: true, - detail: PLATFORM_DETAIL, - }), - approve: async () => ({ kind: "error", message: "network down" }), - }), - ); - - const approveButton = el.querySelector(".chat-block-actions button") as HTMLButtonElement; - await act(async () => { - approveButton.click(); - }); - - expect(el.textContent).toContain("Couldn't reach the approval"); - expect(el.textContent).not.toContain("Approved"); - // Buttons remain — the re-read still says pending, not silently resolved. - expect(el.querySelectorAll(".chat-block-actions button")).toHaveLength(2); - }); - - test("read-then-act race: a conflict re-fetches and renders the resolved state, buttons gone", async () => { - let reads = 0; - const actions: ApprovalActions = { - getStatus: async () => { - reads += 1; - // First read (initial mount): still pending, actionable. Second - // read (after the conflicting decision): someone else got there - // first and it's now approved. - return reads === 1 - ? { - kind: "ready", - status: "pending", - canAct: true, - detail: PLATFORM_DETAIL, - } - : { - kind: "ready", - status: "approved", - canAct: false, - detail: PLATFORM_DETAIL, - }; - }, - approve: async () => ({ kind: "conflict", message: "already resolved" }), - reject: async () => ({ kind: "resolved", status: "rejected" }), - }; - const el = await mount(actions); - - const approveButton = el.querySelector(".chat-block-actions button") as HTMLButtonElement; - await act(async () => { - approveButton.click(); - }); - - expect(reads).toBe(2); - expect(el.querySelectorAll(".chat-block-actions button")).toHaveLength(0); - expect(el.textContent).toContain("Approved"); - expect(el.textContent).toContain("Someone else already resolved this while you were deciding."); - }); - - test("an already-resolved approval renders calmly with no buttons", async () => { - const el = await mount(fakeBackend("rejected", true).actions); - - expect(el.querySelectorAll(".chat-block-actions button")).toHaveLength(0); - expect(el.textContent).toContain("Denied"); - }); - - test("the primary button carries the platform detail's own verb, and deny is generic", async () => { - const el = await mount( - fakeBackend("pending", true, { - ...PLATFORM_DETAIL, - actionVerb: "Merge it", - }).actions, - ); - - const buttons = el.querySelectorAll(".chat-block-actions button"); - expect(buttons.item(0).textContent).toBe("Merge it"); - expect(buttons.item(1).textContent).toBe("Not now"); - }); - - test("with no platform-supplied verb, the primary button falls back to the generic label", async () => { - const el = await mount(fakeBackend("pending", true).actions); - - const buttons = el.querySelectorAll(".chat-block-actions button"); - expect(buttons.item(0).textContent).toBe("Approve"); - }); - - test("the platform's consequence sentence renders in place of a risk badge", async () => { - const el = await mount( - fakeBackend("pending", true, { - ...PLATFORM_DETAIL, - consequence: "Merging goes further than posting a review — it puts the change live.", - }).actions, - ); - - expect(el.textContent).toContain( - "Merging goes further than posting a review — it puts the change live.", - ); - expect(el.querySelector(".chat-block-risk")).toBeNull(); - }); - - test("the standing-consent link only shows when the host offers both the capability and the detail", async () => { - const withoutCapability = await mount( - fakeBackend("pending", true, { - ...PLATFORM_DETAIL, - standingConsent: { verb: "merging", resource: "acme/checkout" }, - }).actions, - ); - expect(withoutCapability.textContent).not.toContain("Allow merging for"); - - const backend = fakeBackend("pending", true, { - ...PLATFORM_DETAIL, - standingConsent: { verb: "merging", resource: "acme/checkout" }, - }); - const allowStandingCalls: string[] = []; - const el = await mount( - { - ...backend.actions, - allowStanding: async (id) => { - allowStandingCalls.push(id); - return { kind: "resolved", status: "approved" }; - }, - }, - "apv_2", - ); - - expect(el.textContent).toContain("Allow merging for acme/checkout"); - const link = Array.from(el.querySelectorAll("button")).find( - (button) => button.textContent === "Allow merging for acme/checkout", - ) as HTMLButtonElement; - await act(async () => { - link.click(); - }); - expect(allowStandingCalls).toEqual(["apv_2"]); - }); - - test("no capability, no detail field: the standing-consent link never appears", async () => { - const el = await mount(fakeBackend("pending", true).actions); - expect(el.textContent).not.toContain("Allow "); - }); -}); diff --git a/apps/web/src/chat/chat-workspace.test.ts b/apps/web/src/chat/chat-workspace.test.ts deleted file mode 100644 index 082eeecd7..000000000 --- a/apps/web/src/chat/chat-workspace.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { buildMemberAvatarStack } from "./chat-workspace"; -import type { ParticipantRecord } from "./api"; -import { avatarClassForPrincipal } from "./avatar"; - -describe("buildMemberAvatarStack", () => { - test("identifies agent participants with agent tone for Corbit rendering", () => { - const participants: readonly ParticipantRecord[] = [ - { address: "run_myra@dana.localhost", handle: "myra" }, - { address: "run_scout@dana.localhost", handle: "scout" }, - ]; - - const stack = buildMemberAvatarStack(participants); - - expect(stack).toHaveLength(2); - expect(stack.map((entry) => entry.label)).toEqual(["Myra", "Scout"]); - expect(stack.every((entry) => entry.tone === "agent")).toBe(true); - }); - - test("is the static roster only — live presence is a separate stack", () => { - const participants: readonly ParticipantRecord[] = [ - { address: "run_myra@dana.localhost", handle: "myra" }, - { address: "run_scout@dana.localhost", handle: "scout" }, - ]; - - const stack = buildMemberAvatarStack(participants); - - expect(stack.map((entry) => entry.label)).toEqual(["Myra", "Scout"]); - expect(stack.every((entry) => entry.tone === "agent")).toBe(true); - }); - - test("includes the signed-in human from the roster even with empty presence", () => { - // Onboarding/template rooms list the human as a participant before any - // presence snapshot arrives — the stack must not be agent-only. - const participants: readonly ParticipantRecord[] = [ - { address: "run_myra@dana.localhost", handle: "myra" }, - { address: "prn_dana", handle: "Dana" }, - ]; - - const stack = buildMemberAvatarStack(participants); - - expect(stack.map((entry) => entry.label)).toEqual(["Myra", "Dana"]); - expect(stack.map((entry) => entry.tone)).toEqual(["agent", "neutral"]); - const human = stack[1]; - expect(human?.key).toBe("prn_dana"); - expect(human?.initials).toBe("D"); - expect(human?.avatarClassName).toBe(avatarClassForPrincipal("prn_dana")); - }); - - test("prefers the signed-in display name over a raw participant handle", () => { - const participants: readonly ParticipantRecord[] = [ - { address: "prn_self", handle: "ada-handle" }, - ]; - - const stack = buildMemberAvatarStack(participants, undefined, { - principalId: "prn_self", - name: "Ada Lovelace", - }); - - expect(stack.map((entry) => entry.label)).toEqual(["Ada Lovelace"]); - expect(stack.map((entry) => entry.initials)).toEqual(["A"]); - }); - - test("prefers resolved agent display names over handle slugs", () => { - const participants: readonly ParticipantRecord[] = [ - { address: "run_myra@dana.localhost", handle: "myra" }, - ]; - - const stack = buildMemberAvatarStack( - participants, - new Map([["run_myra@dana.localhost", "Myra the Helper"]]), - ); - - expect(stack.map((entry) => entry.label)).toEqual(["Myra the Helper"]); - }); -}); diff --git a/apps/web/src/chat/chat-workspace.tsx b/apps/web/src/chat/chat-workspace.tsx deleted file mode 100644 index 311f495c0..000000000 --- a/apps/web/src/chat/chat-workspace.tsx +++ /dev/null @@ -1,1619 +0,0 @@ -// Chat workspace: the host resolves which bench the signed-in -// account chats in, loads its workbenches and deployed agents, and wires the -// timeline and composer together for whichever workbench is -// selected. Workbench list lives in the shell contextual panel — this -// surface is the active conversation only. -// -// Resolving *which* bench that is is host-specific (it rides on -// whatever session/query plumbing the embedding app already has — in -// `@workbench/web` that is the same `/api/me/principals` call the Home -// and Settings pages use), so `ChatWorkspace` takes a small -// `TenantResolution` value rather than importing app code: the same -// narrow-port shape the hub's chat routes uses for `ChatPlatform`. - -import { isAgentAddress } from "./wire/mentions"; -import { Button, EmptyState, toast } from "@corbits/react-ui"; -import { reportError } from "@corbits/error-sink"; -import { getResolvedCatalog } from "@/settings/inference"; -import { CaretDown, ChatCircle, SlidersHorizontal, WarningCircle } from "@/lib/icons"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { ReactNode } from "react"; - -import { - workbenchesQueryKey, - workbenchesQueryKeyPrefix, - cancelWorkbenchTurn, - describeChatError, - fetchRunningTurn, - listWorkbenches, - listWorkbenchAgents, - addAgentCapability, - refreshWorkbenchAgent, - pingWorkbenchPresence, - workbenchStreamUrl, - isKnownWorkbenchKind, - WORKBENCHES_MUTATED_STREAM_TYPE, - applyStreamWorkbenchesMutated, -} from "./api"; -import type { Workbench, ParticipantRecord, Part } from "./api"; -import { WorkbenchSettingsSurface } from "./workbench-settings"; -import type { WorkbenchSettingsSectionId } from "./workbench-settings"; -import { Composer } from "./composer"; -import type { ComposerHandle } from "./composer"; -import { WorkbenchLoadingState } from "./loading-state"; -import { mentionCandidatesFromParticipants } from "./mentions"; -import { failedTurnModelChoices, failedTurnToolCapableModelChoices } from "./failed-turn-models"; -import { CHAT_STRINGS } from "./strings"; -import { displayWorkbenchTitle } from "./workbench-display-title"; -import { - useStreamingReply, - isAwaitingReply, - lastHumanMessageParts, - typingAgentNames, -} from "./streaming-reply"; -import { useTurnActivity, TurnActivityStrip } from "./turn-activity"; -import type { StreamingReplyState } from "./streaming-reply"; -import { - AgentBadge, - WorkbenchTimeline, - displayNameFromHandle, - localPartOf, - messageText, -} from "./timeline"; -import type { FailedTurnRecovery } from "./timeline"; -import { - agentDisplayNamesFromAgents, - displayNameForAddress, - type AgentDisplayNames, -} from "./agent-display-names"; -import { NoUsableModelBanner } from "./no-usable-model-banner"; -import { ResumeFailedBanner } from "./resume-failed-banner"; -import type { CurrentUser, ScrollSnapshot, TimelineMessageItem } from "./timeline"; -import type { ApprovalActions } from "./blocks/approval-actions"; -import type { ConnectServiceActions } from "./blocks/connect-service-actions"; -import { - typingLabel, - TypingIndicator, - AgentTypingIndicator, - useTypingIndicator, -} from "./typing-indicator"; -import type { ProfileSubject } from "./profile-subject"; -import { useWorkbenchStream } from "./use-workbench-stream"; -import { useWorkbenchFeed } from "./use-workbench-feed"; -import { CorbitAvatar, avatarClassForPrincipal } from "./avatar"; -import { useWorkbenchPresenceRoster } from "./workbench-presence"; -import { type } from "arktype"; -import { ChatMessageEventData, ChatSettingsEventData } from "./wire/stream-events"; -import { useThreadNavigation } from "./use-thread-navigation"; -import { mergePendingSends, useOptimisticSends } from "./use-optimistic-sends"; -export { mergePendingSends, pendingSenderAddress } from "./use-optimistic-sends"; -export type { PendingSend } from "./use-optimistic-sends"; -import { useWorkbenchTimelineView } from "./workbench-timeline-view"; -export { chatFeedQueryKeyPrefix, chatThreadsQueryKey } from "./use-workbench-feed"; -export type { MessagesState } from "./workbench-timeline-view"; - -/** - * The host's answer to "which bench does this account chat in": mirrors - * the loading/unauthenticated/error/ready shape every hub-backed query - * in the embedding app already uses, plus `"empty"` for an - * authenticated account with no bench membership at all. - */ -export type TenantResolution = - | { readonly kind: "loading" } - | { readonly kind: "unauthenticated" } - | { readonly kind: "error"; readonly message: string } - | { readonly kind: "empty" } - | { readonly kind: "ready"; readonly tenantId: string }; - -/** - * One live presence entry for the workbench's who's-here stack — - * derived from this workbench's own `/stream` connection - * (`useWorkbenchPresenceRoster`), never a second connection or an HTTP - * heartbeat poll. The roster carries only ids, so display names and avatar - * classes resolve client-side against the workbench's participants. - */ -export interface PresenceMember { - readonly principalId: string; - readonly displayName: string; - readonly avatarClassName: string; -} - -/** One entry in the header's static member stack — an agent or a roster - * human, normalized to the one shape the square stack renders. Live - * presence uses `PresenceMember` in a separate round stack. */ -export interface TeamAvatarEntry { - readonly key: string; - readonly initials: string; - readonly label: string; - readonly tone: "agent" | "neutral"; - readonly avatarClassName?: string; -} - -/** How many avatars the header shows before collapsing the rest into a - * "+N" chip. Shared by the static member stack and the live presence - * stack so neither overflows the 3rem bar. */ -export const TEAM_AVATAR_STACK_LIMIT = 6; - -/** A crumb the host's `StageTopBar` can render — label plus an optional - * parent href. The last crumb is the current page. */ -export type ChatHeaderCrumb = { - readonly label: string; - readonly href?: string; -}; - -/** Chrome the host lifts into `StageTopBar` (`crumbs` / `subtitle` / - * `actions`) so `/w` does not keep a second identity row. */ -export type ChatHeaderChrome = { - readonly crumbs: readonly ChatHeaderCrumb[]; - readonly subtitle?: ReactNode; - readonly actions?: ReactNode; -}; - -const WORKBENCHES_LIST_CHROME: ChatHeaderChrome = { - crumbs: [{ label: CHAT_STRINGS.chatsSectionLabel }], -}; - -/** - * The workbench's static member stack: every agent participant plus every - * human on the roster. Agents first (they have no presence concept of - * their own); humans follow. Roster humans are included even when live - * presence is empty — onboarding/template rooms often list the - * signed-in human as a participant before any `chat.presence.snapshot` - * arrives. Live who's-here is a separate round stack, not mixed in here. - * - * Each person gets a stable generated color keyed by address. Human labels - * prefer `currentUser.name` when the roster entry - * is the signed-in reader — never a raw handle when a display name exists. - */ -export function buildMemberAvatarStack( - participants: readonly ParticipantRecord[], - displayNames?: AgentDisplayNames, - currentUser?: CurrentUser, -): readonly TeamAvatarEntry[] { - const agents = participants - .filter((participant) => isAgentAddress(participant.address)) - .map((participant) => { - const label = - displayNameForAddress(participant.address, displayNames) ?? - displayNameFromHandle(participant.handle); - return { - key: participant.address, - initials: "", - label, - tone: "agent" as const, - }; - }); - - const humans = participants - .filter((participant) => !isAgentAddress(participant.address)) - .map((participant) => { - const label = typingLabel(localPartOf(participant.address), participants, currentUser); - return { - key: participant.address, - initials: label.slice(0, 1).toUpperCase(), - label, - tone: "neutral" as const, - avatarClassName: avatarClassForPrincipal(participant.address), - }; - }); - - return [...agents, ...humans]; -} - -type WorkbenchesState = - | { readonly kind: "loading" } - | { readonly kind: "error"; readonly message: string } - | { - readonly kind: "ready"; - readonly workbenches: readonly Workbench[]; - readonly chats: readonly Workbench[]; - }; - -/** - * Recovery controls for a gone / non-workbench id. Prefer the - * Mission Control + New workbench pair; fall back to the legacy single - * "Back to workbenches" action when a host has not wired the new props. - */ -export function workbenchNotFoundRecoveryAction(args: { - readonly onGoToMissionControl?: () => void; - readonly onNewWorkbench?: () => void; - readonly onBackToWorkbenchList?: () => void; -}): ReactNode | undefined { - const { onGoToMissionControl, onNewWorkbench, onBackToWorkbenchList } = args; - const hasModernRecovery = onGoToMissionControl !== undefined || onNewWorkbench !== undefined; - if (hasModernRecovery) { - return ( - <> - {onGoToMissionControl !== undefined ? ( - - ) : null} - {onNewWorkbench !== undefined ? ( - - ) : null} - - ); - } - if (onBackToWorkbenchList !== undefined) { - return ( - - ); - } - return undefined; -} - -/** - * The composer's placeholder reads as a direct message once the active - * surface is a chat, naming its one counterpart — a chat's title always - * defaults to that counterpart's name at creation (see `routes.ts`'s - * `POST /workbenches`), so it's always the right word here even when the - * counterpart is a person, not an agent. A workbench (or a surface that - * hasn't resolved yet) keeps the generic, mention-driven copy. - */ -export function composerPlaceholderFor( - workbench: - | { - readonly kind: string; - readonly title: string; - } - | undefined, -): string { - if (workbench === undefined || workbench.kind !== "chat") { - return CHAT_STRINGS.composerPlaceholder; - } - const counterpart = - workbench.title.trim().length > 0 ? workbench.title : CHAT_STRINGS.unnamedWorkbench; - return CHAT_STRINGS.composerPlaceholderChat(counterpart); -} - -/** - * A composer submit this workspace has optimistically added to the - * timeline before the server confirms it — see `TimelineMessageItem`'s - * `pendingStatus`. `nonce` is this workspace's own client-side key, - * independent of any server-issued message id (which does not exist yet - * while `status` is `"sending"`, and never will if it ends up discarded). - */ - -/** The one client-side id `mergeStreamingReply` gives its synthetic - * timeline item — stable across renders (React's reconciliation key) and - * never mistaken for a server-issued message id (those come back from - * `POST`/`GET` routes with a different shape). */ -const STREAMING_REPLY_ITEM_ID = "streaming_reply"; - -/** The workbench's first agent participant — the best available - * attribution for a synthetic timeline item that has no real sender of - * its own (`chat.agent` events carry none, and a client-side timeout - * notice never had a server-issued sender to begin with). Workbenches - * with more than one invited agent are a known approximation here, not a - * regression — today's non-streaming refetch has the same "which agent - * replied" gap until the persisted message's real sender lands. */ -function firstAgentParticipant( - participants: readonly ParticipantRecord[], -): ParticipantRecord | undefined { - return participants.find((participant) => isAgentAddress(participant.address)); -} - -/** - * Folds the active turn's in-progress reply onto the end of the timeline, - * exactly the way `mergePendingSends` folds this reader's own optimistic - * sends — except this synthetic item is the *other* side's message, so it - * needs a sender to attribute it to (see `firstAgentParticipant`). - */ -export function mergeStreamingReply( - items: readonly TimelineMessageItem[], - streamingReply: StreamingReplyState, - participants: readonly ParticipantRecord[], -): readonly TimelineMessageItem[] { - // A pending reply with no tokens yet stays off the timeline — an - // empty bubble with no timestamp reads as broken; the typing pulse - // in the incoming-message slot owns that phase until the first delta - // lands. A `"replied"` turn renders nothing: its reply is already a - // persisted message. - if (streamingReply === null || streamingReply.phase === "replied" || streamingReply.text === "") { - return items; - } - const agent = firstAgentParticipant(participants); - if (agent === undefined) return items; - return [ - ...items, - { - id: STREAMING_REPLY_ITEM_ID, - createdAt: new Date().toISOString(), - parts: [{ kind: "text", text: streamingReply.text }], - sender: { name: null, address: agent.address }, - streaming: true, - }, - ]; -} - -/** The client-side id the reply-timeout notice renders under — same - * "never a server-issued id" contract as `STREAMING_REPLY_ITEM_ID`. */ -const REPLY_TIMED_OUT_ITEM_ID = "reply_timed_out_notice"; - -/** - * Appends an honest inline notice once `useStreamingReply`'s own backstop - * (`PENDING_REPLY_CLEAR_MS`) has fired — a turn that opened but never got a - * single token and never closed out either, so the reader was left staring - * at a typing indicator that just vanished with no explanation. This is - * the same class of failure `postUndeliveredNotice` (the hub's chat routes) - * already gives an honest, actionable backstop to when the dispatch fails - * loud enough for the server to see it — a cold-waking agent that never - * streams a token back fails silently instead, so this synthetic notice - * carries a `turnFailed` text part exactly like the - * server's own notice (`replyTimedOutRefId` is minted by `reportError` at - * the moment `useStreamingReply`'s timer fires), so it renders through - * `FailedTurnStrip` — same ref-quotable copy, same Retry action — instead - * of a second, weaker backstop living beside the real one. - */ -export function appendReplyTimedOutNotice( - items: readonly TimelineMessageItem[], - replyTimedOutRefId: string | null, - participants: readonly ParticipantRecord[], -): readonly TimelineMessageItem[] { - if (replyTimedOutRefId === null) return items; - const agent = firstAgentParticipant(participants); - return [ - ...items, - { - id: REPLY_TIMED_OUT_ITEM_ID, - createdAt: new Date().toISOString(), - parts: [ - { - kind: "text", - text: `${CHAT_STRINGS.replyTimedOutNotice} (ref ${replyTimedOutRefId})`, - turnFailed: true, - }, - ], - sender: { name: null, address: agent?.address ?? "" }, - }, - ]; -} - -/** - * Records one workbench's scroll snapshot into the map, pure — a fresh `Map` - * copy rather than a mutation, so `scrollSnapshotsRef.current` always holds - * exactly the value this function returned, never a same-reference object - * mutated out from under a caller still holding the old one. - */ -export function withScrollSnapshot( - snapshots: ReadonlyMap, - workbenchId: string, - snapshot: ScrollSnapshot, -): ReadonlyMap { - const next = new Map(snapshots); - next.set(workbenchId, snapshot); - return next; -} - -/** - * Workbenches and chats via TanStack Query, keyed with `workbenchesQueryKey` — - * the same key `apps/web`'s shell bands and command palette use, so this - * sidebar shares one in-flight fetch per (tenantId, kind) with the rest of - * the shell rather than firing its own independent request on every mount. - */ -function useWorkbenchLists(tenantId: string) { - const workbenches = useQuery({ - queryKey: workbenchesQueryKey(tenantId, "workbench"), - queryFn: () => listWorkbenches(tenantId, "workbench"), - }); - const chats = useQuery({ - queryKey: workbenchesQueryKey(tenantId, "chat"), - queryFn: () => listWorkbenches(tenantId, "chat"), - }); - - const reload = useCallback(async () => { - await Promise.all([workbenches.refetch(), chats.refetch()]); - }, [workbenches.refetch, chats.refetch]); - - // Referentially stable across renders that don't actually change the - // underlying data — a fresh object literal here every render would make - // `workbenchesState` look "changed" to every effect that depends on it - // (the auto-select-first-workbench effect below included), firing them on - // every unrelated re-render rather than only when workbenches/chats data - // itself moves. - const state: WorkbenchesState = useMemo(() => { - if (workbenches.isError) { - return { - kind: "error", - message: describeChatError(workbenches.error, "Couldn't load workbenches."), - }; - } - if (chats.isError) { - return { - kind: "error", - message: describeChatError(chats.error, "Couldn't load workbenches."), - }; - } - if (workbenches.data === undefined || chats.data === undefined) { - return { kind: "loading" }; - } - return { kind: "ready", workbenches: workbenches.data, chats: chats.data }; - }, [ - workbenches.isError, - workbenches.error, - workbenches.data, - chats.isError, - chats.error, - chats.data, - ]); - - return { state, reload }; -} - -function ChatWorkspaceInner({ - tenantId, - workbenchId: controlledWorkbenchId, - onWorkbenchChange, - currentUser, - onOpenProfile, - settingsOpen = false, - onSettingsOpenChange, - settingsSection = "general", - onSettingsSectionChange, - settingsEntityId = null, - onSettingsEntityIdChange, - onOpenArtifact, - onOpenArtifactInLibrary, - onFixConnection, - approvalActions, - connectServiceActions, - headerLeading, - headerSlot, - registerComposerInsert, - onWorkbenchNotFound, - onBackToWorkbenchList, - onGoToMissionControl, - onNewWorkbench, - onSignIn, - hasUsableModel, - onConnectModel, -}: { - readonly tenantId: string; - readonly workbenchId?: string | null; - readonly onWorkbenchChange?: (workbenchId: string) => void; - readonly currentUser?: CurrentUser; - readonly onOpenProfile?: (subject: ProfileSubject) => void; - /** Whether the routed workbench's settings surface should replace the - * conversation stage (mock § Workbench settings — a full surface, never a - * dialog). Host-controlled the same way `workbenchId` is: driven from the - * URL (`/w/:id/settings`). */ - readonly settingsOpen?: boolean; - /** Fired when the settings surface should open or close. `section` is - * only passed on open — the section the opener meant to land on (the - * gear button's General, or the composer's `/agents` shortcut) — so the - * host can navigate straight to that URL without a second, separate - * navigation for the section. */ - readonly onSettingsOpenChange?: ( - open: boolean, - section?: WorkbenchSettingsSectionId, - entityId?: string, - ) => void; - /** Which workbench settings tab is active while the surface is open — - * host-controlled the same way `settingsOpen` is, driven from the URL - * (`/w/:id/settings/:section`). */ - readonly settingsSection?: WorkbenchSettingsSectionId; - /** Fired when the user switches tabs while the settings surface is - * already open, so the host can reflect it in the URL. */ - readonly onSettingsSectionChange?: (section: WorkbenchSettingsSectionId) => void; - /** Section sub-selection while settings are open — host-controlled from - * the URL (`/w/:id/settings/:section/:entityId`). `null` means the - * section's own list (or a section with no list). */ - readonly settingsEntityId?: string | null; - /** Fired when a settings section opens or closes its own detail, so the - * host can deepen or clear the entity segment in the URL. */ - readonly onSettingsEntityIdChange?: (entityId: string | null) => void; - /** Open a message's artifact chip — see `WorkbenchTimeline`'s `onOpenArtifact`. */ - readonly onOpenArtifact?: (part: Part & { kind: "file" }) => void; - /** The chip's "Open in Library" affordance — see `WorkbenchTimeline`'s - * `onOpenArtifactInLibrary`. */ - readonly onOpenArtifactInLibrary?: (part: Part & { kind: "file" }) => void; - /** The classified-inference-failure text bubble's "Fix this connection" - * action — see `WorkbenchTimeline`'s `onFixConnection`. */ - readonly onFixConnection?: () => void; - /** The approve block's live round-trip — see `WorkbenchTimeline`'s - * `approvalActions`. */ - readonly approvalActions?: ApprovalActions; - /** Host round-trip for the generic "connect-service" card. Undefined - * renders every connect-service card in its disconnected framing. */ - readonly connectServiceActions?: ConnectServiceActions; - /** Host-supplied control rendered first in the workbench header — the - * shell's single col2 toggle, so chat carries the same top-bar chrome as - * every other stage surface. Unused when `headerSlot` owns the bar. */ - readonly headerLeading?: ReactNode; - /** Host-owned stage bar. When set, identity and primary actions render - * through this slot (`StageTopBar`'s crumbs / subtitle / actions) instead - * of `.chat-workbench-header`. */ - readonly headerSlot?: (chrome: ChatHeaderChrome) => ReactNode; - /** - * Lets the host (command palette, canvas "insert into composer", …) push - * text into the live composer. Called with the insert fn when a composer - * mounts, and with `null` when it unmounts so the host never holds a - * stale handle. Optional: hosts that don't need the insert path omit it. - */ - readonly registerComposerInsert?: (insert: ((text: string) => void) | null) => void; - /** Fired when the routed workbench 404s — a deleted workbench, or a stale - * Recents entry that outlived it. The host owns Recents (this package - * never touches localStorage), so it's told rather than reaching out. */ - readonly onWorkbenchNotFound?: (workbenchId: string) => void; - /** Legacy single recovery for a gone workbench — prefer - * `onGoToMissionControl` / `onNewWorkbench`. */ - readonly onBackToWorkbenchList?: () => void; - /** Not-found empty state's Mission Control recovery. */ - readonly onGoToMissionControl?: () => void; - /** Not-found empty state's New workbench recovery. */ - readonly onNewWorkbench?: () => void; - /** The 401 messages-error state's way out — sign back in instead of a - * retry that can only ever hit the same 401. Omitted, that state falls - * back to no action at all (never "Try again" for a session that's gone). */ - readonly onSignIn?: () => void; - /** Whether this tenant can actually run inference right now — the - * host's read of `hasUsableModel` (`@/settings/inference`) - * against its resolved catalog, never mere `model_provider` row - * presence. `undefined` while that read is still in flight: - * the banner stays hidden rather than flashing "no model" before the - * real answer lands. */ - readonly hasUsableModel?: boolean; - /** The pre-send banner's "Connect a model" action — the host's own - * navigation into Settings → AI providers. Undefined still renders the - * banner, just with an inert button, matching every other optional - * action this file wires. */ - readonly onConnectModel?: () => void; -}) { - const queryClient = useQueryClient(); - const refreshWorkbenchLists = useCallback(() => { - void queryClient.invalidateQueries({ - queryKey: workbenchesQueryKeyPrefix(tenantId), - }); - }, [queryClient, tenantId]); - const { state: workbenchesState, reload: reloadWorkbenches } = useWorkbenchLists(tenantId); - const [selectedWorkbenchId, setSelectedWorkbenchId] = useState(null); - const activeWorkbenchId = controlledWorkbenchId ?? selectedWorkbenchId; - const setActiveWorkbenchId = (id: string) => { - setSelectedWorkbenchId(id); - onWorkbenchChange?.(id); - }; - // Catch-up `fetchRunningTurn` failure must surface a banner with - // Retry — never look idle. `resumeAttempt` re-arms the effect on Retry. - const [resumeFailedRefId, setResumeFailedRefId] = useState(null); - const [resumeAttempt, setResumeAttempt] = useState(0); - // null = workbench root feed. A concrete id opens that thread in the same - // geometry (timeline + composer). pendingParentMessageId is set when the - // user opens a reply on a message that has no thread yet. - // Thread navigation is resolved after the feed below, which it reads. - // This composer's own optimistic sends — see `mergePendingSends`. A - // workbench switch drops whatever was pending in the previous workbench: - // its composer submit targeted that workbench, not wherever the reader - // navigated to next. - - const composerRef = useRef(null); - - const feed = useWorkbenchFeed({ - tenantId, - activeWorkbenchId, - ...(onWorkbenchNotFound !== undefined ? { onWorkbenchNotFound } : {}), - }); - const { threads, rootThreadId, refreshFeed } = feed; - - const navigation = useThreadNavigation({ - tenantId, - activeWorkbenchId, - threads, - rootThreadId, - threadsLoaded: feed.threadsLoaded, - refetchThreads: feed.refetchThreads, - }); - const { - openThreadId, - pendingParentMessageId, - inThreadView, - openThreadParent, - threadTitle, - depth1Threads, - subThreadsByParentId, - openThreadForMessage, - forkMessage, - openThreadById, - closeThread, - } = navigation; - - const { messagesState, threadMetaByMessageId } = useWorkbenchTimelineView({ - tenantId, - activeWorkbenchId, - feed, - navigation, - }); - - // Picking a default workbench is this component's own fallback for "no - // workbench named in the URL yet". - useEffect(() => { - if (workbenchesState.kind !== "ready") return; - if (activeWorkbenchId !== null) return; - const first = workbenchesState.workbenches[0] ?? workbenchesState.chats[0]; - if (first !== undefined) setActiveWorkbenchId(first.id); - }, [workbenchesState, activeWorkbenchId]); - - // This rethrows after - // toasting — the composer's own `onStop` awaits the returned promise - // and re-enables its Stop button on rejection, so a genuinely failed - // request (network, a denied grant) never leaves the button stuck - // disabled for the rest of the turn. The timeline's cancelled-turn - // notice, not this response, is what actually clears the typing - // indicator once (or if) the turn settles. - const handleStopTurn = useCallback(() => { - if (activeWorkbenchId === null) return Promise.resolve(); - return cancelWorkbenchTurn(tenantId, activeWorkbenchId).catch((err) => { - toast(CHAT_STRINGS.turnCancelError); - throw err; - }); - }, [tenantId, activeWorkbenchId]); - - const composerMounted = - !settingsOpen && activeWorkbenchId !== null && messagesState.kind === "ready"; - - useEffect(() => { - if (registerComposerInsert === undefined) return; - if (!composerMounted) { - registerComposerInsert(null); - return; - } - registerComposerInsert((text) => composerRef.current?.insertText(text)); - return () => registerComposerInsert(null); - }, [registerComposerInsert, composerMounted]); - - const { typingState, handleStreamEvent: handleTypingEvent } = useTypingIndicator( - currentUser?.principalId, - activeWorkbenchId, - ); - const { - streamingReply, - replyTimedOutRefId, - handleStreamEvent: handleStreamingReplyEvent, - noteAwaitingReply, - resumeFromTurn, - } = useStreamingReply(activeWorkbenchId); - const { activity: turnActivity, handleStreamEvent: handleTurnActivityEvent } = - useTurnActivity(activeWorkbenchId); - const { roster: presenceRoster, handleStreamEvent: handlePresenceEvent } = - useWorkbenchPresenceRoster(activeWorkbenchId); - - // "Here at all" comes for free from the open `/stream` connection itself - // (see `packages/chat/src/workbench-presence.ts`) — this ping only - // refreshes `lastActiveAt` for a tab that's been backgrounded a while, - // fired on the reader actually coming back rather than on an interval. - useEffect(() => { - if (activeWorkbenchId === null) return; - const onVisibility = () => { - if (document.visibilityState !== "visible") return; - void pingWorkbenchPresence(tenantId, activeWorkbenchId); - }; - document.addEventListener("visibilitychange", onVisibility); - return () => document.removeEventListener("visibilitychange", onVisibility); - }, [tenantId, activeWorkbenchId]); - - // Opening Settings swaps `WorkbenchTimeline` out for `WorkbenchSettingsSurface` - // entirely (see the early `settingsOpen` return below) — closing it - // remounts a fresh `WorkbenchTimeline` with no memory of where the reader - // was. A ref (not state) holds each workbench's last snapshot: recording it - // never needs to trigger a re-render, only be there the next time this - // workbench's `WorkbenchTimeline` mounts. - const scrollSnapshotsRef = useRef>(new Map()); - const restoredScrollSnapshot = - activeWorkbenchId !== null ? scrollSnapshotsRef.current.get(activeWorkbenchId) : undefined; - const handleScrollSnapshot = useCallback( - (snapshot: ScrollSnapshot) => { - if (activeWorkbenchId === null) return; - scrollSnapshotsRef.current = withScrollSnapshot( - scrollSnapshotsRef.current, - activeWorkbenchId, - snapshot, - ); - }, - [activeWorkbenchId], - ); - - // Every event applies straight into the query cache it describes rather - // than triggering a refetch: each payload already - // carries what a subscriber needs, so there is no "invalidate, then - // fetch" fallback left beside this. `chat.agent` needs no cache - // application of its own — it's fully owned by - // `handleStreamingReplyEvent`/`handleTurnActivityEvent`, and the real - // message it eventually produces arrives as its own `chat.message`. - useWorkbenchStream( - activeWorkbenchId !== null ? workbenchStreamUrl(tenantId, activeWorkbenchId) : "", - (eventType, data) => { - handleTypingEvent(eventType, data); - handleStreamingReplyEvent(eventType, data); - handleTurnActivityEvent(eventType, data); - handlePresenceEvent(eventType, data); - if (activeWorkbenchId === null) return; - switch (eventType) { - case "chat.message": { - // The feed now reads from the mailbox, not this stream's own - // `chat.message` payload — the mailbox's own - // `/me/inbox/events` subscription (see `useWorkbenchFeed`) is - // what refreshes the timeline once the fan-out lands. A parse - // failure here still means something arrived this connection - // couldn't read, so still worth a refresh. - const parsed = ChatMessageEventData(data); - if (parsed instanceof type.errors) { - toast(CHAT_STRINGS.streamMessageDropped); - refreshFeed(); - } - break; - } - case "chat.settings": { - const parsed = ChatSettingsEventData(data); - if (!(parsed instanceof type.errors)) { - connectServiceActions?.notifySettingsChanged().catch((cause) => { - reportError(cause, { - operation: "chat.notifyServiceSettingsChanged", - tenantId, - roomId: activeWorkbenchId, - }); - }); - } - break; - } - case WORKBENCHES_MUTATED_STREAM_TYPE: { - applyStreamWorkbenchesMutated(data); - break; - } - } - }, - refreshFeed, - {}, - (eventType) => { - if (eventType !== "chat.message") return; - toast(CHAT_STRINGS.streamMessageDropped); - refreshFeed(); - }, - ); - - /** The one door into the workbench settings surface — the gear button and - * the composer's `/agents` command both go through this so the section - * that lands is always the one the caller meant to open. */ - function openWorkbenchSettings( - section: WorkbenchSettingsSectionId = "general", - entityId?: string, - ) { - onSettingsOpenChange?.(true, section, entityId); - } - - /** - * The optimistic core both a fresh composer submit and a bubble's own - * Retry button drive: adds (or resets) a pending entry before the - * request goes out, so the sender sees their message land in the - * timeline immediately rather than waiting on the round-trip. Once the - * POST resolves, the confirmed item (built straight from its response — - * no extra round-trip) replaces the pending entry in the very same - * state update: there is never a render where the message has vanished - * from both `pendingSends` and `messagesState.items` while a fresh - * `GET` is still in flight to reintroduce it, and never a render where - * both the pending and confirmed copies show at once. The follow-up - * background `loadMessages` still runs to pick up server-only detail - * (real sender record, reactions, thread meta) — it settles into that - * data under the same `clientId` key, so it never re-triggers the - * mount/unmount swap this replaces. A rejected send flips the pending - * entry to `"failed"` in place instead — never a status line - * disconnected from the message it describes. - */ - const activeWorkbench = - workbenchesState.kind === "ready" - ? [...workbenchesState.workbenches, ...workbenchesState.chats].find( - (workbench) => workbench.id === activeWorkbenchId, - ) - : undefined; - const activeWorkbenchDisplayTitle = - activeWorkbench !== undefined - ? displayWorkbenchTitle(activeWorkbench.title, activeWorkbench.id) - : undefined; - const isActiveChat = - activeWorkbench !== undefined && - isKnownWorkbenchKind(activeWorkbench.kind) && - activeWorkbench.kind === "chat"; - const activeChatAgent = isActiveChat - ? activeWorkbench?.participants.find((participant) => isAgentAddress(participant.address)) - : undefined; - - const hasAgentParticipant = (activeWorkbench?.participants ?? []).some((participant) => - isAgentAddress(participant.address), - ); - - const resumeAgentAddress = (activeWorkbench?.participants ?? []).find((participant) => - isAgentAddress(participant.address), - )?.address; - - // A turn runs entirely server-side — this component mounting or - // unmounting never starts or stops it (see `useWorkbenchStream`'s own - // header: unmount only closes the `EventSource`, nothing server-side). - // So a fresh mount (first visit, or a return after navigating away while - // a reply was still streaming) asks once whether the agent has a turn - // still running and, if so, replays its committed text immediately - // rather than showing nothing until the next live token arrives. Any - // live event that beats this fetch back always wins — see - // `resumeFromTurn`'s own guard. - // A failed catch-up must not swallow into idle — report a ref - // and keep `ResumeFailedBanner` visible until Retry succeeds (or the - // workbench changes). - useEffect(() => { - if (activeWorkbenchId === null || resumeAgentAddress === undefined) { - setResumeFailedRefId(null); - return; - } - let cancelled = false; - fetchRunningTurn(tenantId, activeWorkbenchId, resumeAgentAddress) - .then((runningTurn) => { - if (cancelled) return; - setResumeFailedRefId(null); - resumeFromTurn(runningTurn); - }) - .catch((cause) => { - if (cancelled) return; - const refId = reportError(cause, { - operation: "chat.resumeRunningTurn", - tenantId, - roomId: activeWorkbenchId, - agentId: resumeAgentAddress, - }); - setResumeFailedRefId(refId); - }); - return () => { - cancelled = true; - }; - }, [tenantId, activeWorkbenchId, resumeAgentAddress, resumeAttempt]); - - const handleRetryResume = useCallback(() => { - setResumeAttempt((attempt) => attempt + 1); - }, []); - - // Every other participant's address — the signed-in sender's - // own entry (matched by local-part against `principalId`, the same - // convention `typingLabel` above reads) never belongs in its own `to`. - const recipientAddresses = (activeWorkbench?.participants ?? []) - .map((participant) => participant.address) - .filter((address) => localPartOf(address) !== currentUser?.principalId); - - const { pendingSends, handleSend, retryPendingSend, discardPendingSend } = useOptimisticSends({ - tenantId, - activeWorkbenchId, - currentUserPrincipalId: currentUser?.principalId, - openThreadId, - pendingParentMessageId, - openThreadById, - recipientAddresses, - messages: feed.loadedMessages, - noteAwaitingReply, - hasAgentParticipant, - restoreDraft: (text) => composerRef.current?.insertText(text), - }); - - /** Retry on a failed-turn strip: sends the recovered text - * (`findRetryText`) straight back through the normal send path — same - * as the person typing it and hitting Enter — rather than parking it - * in the composer for them to resend by hand. */ - const handleRetryFailedTurn = useCallback( - async (_item: TimelineMessageItem, retryText?: string) => { - if (retryText === undefined) return; - await handleSend({ text: retryText, attachments: [] }); - }, - [handleSend], - ); - - const catalogQuery = useQuery({ - queryKey: ["tenant", tenantId, "resolved-catalog"], - queryFn: () => getResolvedCatalog(tenantId), - }); - const workbenchAgentsQuery = useQuery({ - queryKey: ["tenant", tenantId, "chat", "workbench-agents", activeWorkbenchId], - queryFn: () => - activeWorkbenchId !== null - ? listWorkbenchAgents(tenantId, activeWorkbenchId) - : Promise.resolve([]), - enabled: activeWorkbenchId !== null, - }); - // Person-facing display names for this workbench's agents, - // keyed by participant address. Memoized so `MessageParts`'s memo guard - // keeps working: a fresh Map every render would read as new - // props on every row and re-render the whole timeline per token. - const agentDisplayNames: AgentDisplayNames = useMemo( - () => agentDisplayNamesFromAgents(workbenchAgentsQuery.data ?? []), - [workbenchAgentsQuery.data], - ); - const failedTurnRecovery = useMemo((): FailedTurnRecovery => { - const definitionIdByAddress: Record = {}; - for (const agent of workbenchAgentsQuery.data ?? []) { - definitionIdByAddress[agent.address] = agent.definitionId; - } - return { - models: failedTurnModelChoices(catalogQuery.data ?? []), - toolCapableModels: failedTurnToolCapableModelChoices(catalogQuery.data ?? []), - definitionIdByAddress, - onApplyModel: async ({ definitionId, address, canonicalName }) => { - if (activeWorkbenchId === null) return; - await addAgentCapability(tenantId, definitionId, { - kind: "model", - canonicalName, - }); - await refreshWorkbenchAgent(tenantId, activeWorkbenchId, address); - }, - onOpenAgentSettings: (definitionId) => { - openWorkbenchSettings("agents", definitionId); - }, - }; - }, [catalogQuery.data, workbenchAgentsQuery.data, tenantId, activeWorkbenchId]); - - const addressedMessageParts = useMemo( - () => - lastHumanMessageParts( - mergePendingSends( - messagesState.kind === "ready" ? messagesState.items : [], - pendingSends, - currentUser?.principalId, - ), - ), - [messagesState, pendingSends, currentUser?.principalId], - ); - - // A settings URL for a workbench id that resolved workbenches don't contain - // (deleted, mistyped, cross-tenant) would otherwise leave the surface - // silently showing the ordinary chat view under a lying /settings URL — - // correct the route instead of no-opping. - useEffect(() => { - if (!settingsOpen) return; - if (workbenchesState.kind !== "ready") return; - if (activeWorkbenchId === null) return; - if (activeWorkbench !== undefined) return; - onSettingsOpenChange?.(false); - }, [ - settingsOpen, - workbenchesState.kind, - activeWorkbenchId, - activeWorkbench, - onSettingsOpenChange, - ]); - - // A routed id missing from the ready workbench list is NOT proof - // the room is gone — create→navigate races the React Query list cache, so - // a freshly created id is absent until refetch. Only an authoritative - // messages/workbench fetch 404 marks the room gone. While the list miss - // coincides with messages still loading, hold the loading treatment so we - // neither flash not-found nor paint Invite / composer / Untitled chrome. - const workbenchMissingFromList = - workbenchesState.kind === "ready" && - activeWorkbenchId !== null && - activeWorkbench === undefined; - const workbenchGone = messagesState.kind === "error" && messagesState.workbenchNotFound; - const awaitingWorkbenchEvidence = - workbenchMissingFromList && !workbenchGone && messagesState.kind !== "ready"; - - // Who's live in this workbench right now, beyond the static participants - // list — derived from this workbench's own `chat.presence`/ - // `chat.presence.snapshot` stream events, never a second - // connection or an HTTP heartbeat poll. Display name and color are - // resolved client-side (the roster itself carries only ids) the same way - // `typingLabel` resolves a typing ping's principal. - const presenceMembers: readonly PresenceMember[] = useMemo( - () => - presenceRoster.map((member) => { - return { - principalId: member.principalId, - displayName: typingLabel( - member.principalId, - activeWorkbench?.participants ?? [], - currentUser, - ), - avatarClassName: avatarClassForPrincipal(member.principalId), - }; - }), - [presenceRoster, activeWorkbench?.participants, currentUser], - ); - - // Static member stack (square) vs live presence (round) — never one - // combined circular team stack. - const memberStack = buildMemberAvatarStack( - activeWorkbench?.participants ?? [], - agentDisplayNames, - currentUser, - ); - const visibleMemberStack = memberStack.slice(0, TEAM_AVATAR_STACK_LIMIT); - const memberStackOverflow = memberStack.length - visibleMemberStack.length; - const visiblePresenceStack = presenceMembers.slice(0, TEAM_AVATAR_STACK_LIMIT); - const presenceStackOverflow = presenceMembers.length - visiblePresenceStack.length; - - const showRoomChrome = - workbenchesState.kind === "ready" && - activeWorkbenchId !== null && - !workbenchGone && - !awaitingWorkbenchEvidence; - - const roomTitle = activeWorkbenchDisplayTitle || CHAT_STRINGS.unnamedWorkbench; - - const headerActions = ( - <> - {depth1Threads.length > 0 ? ( -
- - {CHAT_STRINGS.threadsMenuCount(depth1Threads.length)} - - -
- {depth1Threads.map((thread) => ( -
- - {(subThreadsByParentId.get(thread.id) ?? []).map((subThread) => ( - - ))} -
- ))} -
-
- ) : null} - {visibleMemberStack.length > 0 ? ( -
- {visibleMemberStack.map((entry) => - entry.tone === "agent" ? ( - - - - ) : ( - - {entry.initials} - - ), - )} - {memberStackOverflow > 0 ? ( - - +{memberStackOverflow} - - ) : null} -
- ) : null} - {presenceMembers.length > 0 ? ( -
- {visiblePresenceStack.map((member) => ( - - {member.displayName.slice(0, 1).toUpperCase()} - - ))} - {presenceStackOverflow > 0 ? ( - - +{presenceStackOverflow} - - ) : null} -
- ) : null} -
- -
- - ); - - const threadBreadcrumb = inThreadView ? ( - - ) : null; - - const roomChrome: ChatHeaderChrome = { - crumbs: [{ label: roomTitle }], - ...(inThreadView && threadBreadcrumb !== null - ? { subtitle: threadBreadcrumb } - : activeChatAgent !== undefined - ? { subtitle: } - : {}), - actions: headerActions, - }; - - // The workbench header only exists once a workbench is active; the loading, - // error, and no-workbench states still carry the host's leading control (the - // shell's col2 toggle) so the sidebar stays reachable. When `headerSlot` - // owns the bar, that chrome lifts out of `.chat-workbench-header`. - const bareLeadingHeader = - headerSlot === undefined && headerLeading !== undefined && !showRoomChrome ? ( -
{headerLeading}
- ) : null; - - const stageHeader = - headerSlot !== undefined - ? headerSlot(showRoomChrome ? roomChrome : WORKBENCHES_LIST_CHROME) - : bareLeadingHeader; - - if (settingsOpen && activeWorkbenchId !== null && activeWorkbench !== undefined) { - return ( - <> -
- onSettingsSectionChange?.(next)} - entityId={settingsEntityId} - {...(onSettingsEntityIdChange !== undefined - ? { onEntityIdChange: onSettingsEntityIdChange } - : {})} - onBack={() => onSettingsOpenChange?.(false)} - onSaved={refreshWorkbenchLists} - {...(currentUser !== undefined - ? { currentUserPrincipalId: currentUser.principalId } - : {})} - /> -
- - ); - } - - return ( - <> -
-
- {stageHeader} - {workbenchesState.kind === "loading" ? ( - - ) : workbenchesState.kind === "error" ? ( - } - title={`Couldn't load ${CHAT_STRINGS.couldNotLoadWorkbenches}`} - description={workbenchesState.message} - action={ - - } - /> - ) : activeWorkbenchId === null ? ( - } - title={CHAT_STRINGS.noChatSelectedTitle} - description={CHAT_STRINGS.noChatSelectedDescription} - /> - ) : workbenchGone ? ( - // Fail closed — never mount Invite / composer / room - // header over a missing or 404'd workbench. Recovery is the - // whole stage. Authoritative evidence only (messages 404), never - // a stale ready-list miss alone. - } - title={CHAT_STRINGS.workbenchNotFoundTitle} - description={CHAT_STRINGS.workbenchNotFoundDescription} - action={workbenchNotFoundRecoveryAction({ - ...(onGoToMissionControl !== undefined ? { onGoToMissionControl } : {}), - ...(onNewWorkbench !== undefined ? { onNewWorkbench } : {}), - ...(onBackToWorkbenchList !== undefined ? { onBackToWorkbenchList } : {}), - })} - /> - ) : awaitingWorkbenchEvidence ? ( - // List cache is ready but lacks this id — wait for messages to - // prove the room exists (create→navigate) or 404 (true miss) - // before painting room chrome or the not-found empty state. - - ) : ( - <> - {headerSlot === undefined ? ( -
- {headerLeading} - {inThreadView ? ( - threadBreadcrumb - ) : ( -
-

{roomTitle}

- {activeChatAgent !== undefined ? : null} -
- )} -
{headerActions}
-
- ) : null} - {messagesState.kind === "loading" ? ( - - ) : messagesState.kind === "error" ? ( - } - title={`Couldn't load ${CHAT_STRINGS.couldNotLoadMessages}`} - description={messagesState.message} - action={ - messagesState.isUnauthorized ? ( - onSignIn !== undefined ? ( - - ) : undefined - ) : ( - - ) - } - /> - ) : ( - <> - {openThreadParent !== undefined ? ( -
- {CHAT_STRINGS.forkThreadOriginBanner}{" "} - -
- ) : null} - - isAgentAddress(participant.address), - ) - } - items={appendReplyTimedOutNotice( - mergeStreamingReply( - mergePendingSends( - messagesState.items, - pendingSends, - currentUser?.principalId, - ), - streamingReply, - activeWorkbench?.participants ?? [], - ), - replyTimedOutRefId, - activeWorkbench?.participants ?? [], - )} - participants={activeWorkbench?.participants ?? []} - {...(currentUser !== undefined ? { currentUser } : {})} - agentDisplayNames={agentDisplayNames} - threadMetaByMessageId={threadMetaByMessageId} - threadAffordanceMode={inThreadView ? "fork" : "reply"} - onOpenThread={inThreadView ? forkMessage : openThreadForMessage} - onEditMessage={(messageId) => { - if (messagesState.kind !== "ready") return; - const item = messagesState.items.find((message) => message.id === messageId); - if (item === undefined) return; - composerRef.current?.setText(messageText(item)); - }} - {...(onOpenProfile !== undefined ? { onOpenProfile } : {})} - {...(onOpenArtifact !== undefined ? { onOpenArtifact } : {})} - {...(onOpenArtifactInLibrary !== undefined ? { onOpenArtifactInLibrary } : {})} - {...(onFixConnection !== undefined ? { onFixConnection } : {})} - {...(approvalActions !== undefined ? { approvalActions } : {})} - {...(connectServiceActions !== undefined ? { connectServiceActions } : {})} - onRetryFailedTurn={handleRetryFailedTurn} - failedTurnRecovery={failedTurnRecovery} - pendingActions={{ - onRetry: retryPendingSend, - onDiscard: discardPendingSend, - }} - {...(restoredScrollSnapshot !== undefined - ? { scrollRestore: restoredScrollSnapshot } - : {})} - onScrollSnapshot={handleScrollSnapshot} - footer={ - typingState !== null ? ( - - ) : ( - - ) - } - /> - -
- {resumeFailedRefId !== null ? ( - - ) : null} - {hasUsableModel === false && hasAgentParticipant ? ( - onConnectModel?.()} /> - ) : null} - -
- - )} - - )} -
-
- - ); -} - -function ChatWorkspaceFrame({ children }: { readonly children: ReactNode }) { - return
{children}
; -} - -function withListHeader( - headerSlot: ((chrome: ChatHeaderChrome) => ReactNode) | undefined, - children: ReactNode, -): ReactNode { - if (headerSlot === undefined) { - return {children}; - } - return ( - <> - {headerSlot(WORKBENCHES_LIST_CHROME)} - {children} - - ); -} - -export function ChatWorkspace({ - tenant, - workbenchId = null, - onWorkbenchChange, - currentUser, - onOpenProfile, - settingsOpen, - onSettingsOpenChange, - settingsSection, - onSettingsSectionChange, - settingsEntityId, - onSettingsEntityIdChange, - onOpenArtifact, - onOpenArtifactInLibrary, - onFixConnection, - approvalActions, - connectServiceActions, - headerLeading, - headerSlot, - registerComposerInsert, - onWorkbenchNotFound, - onBackToWorkbenchList, - onGoToMissionControl, - onNewWorkbench, - onSignIn, - hasUsableModel, - onConnectModel, -}: { - readonly tenant: TenantResolution; - /** Controlled active workbench (e.g. from the app's URL); null = pick the first. */ - readonly workbenchId?: string | null; - /** Fired when the user selects a workbench, so the app can reflect it in the URL. */ - readonly onWorkbenchChange?: (workbenchId: string) => void; - /** - * The signed-in account, so its own messages render as "You" (or its - * name) instead of matching no participant and falling back to - * "Member". Host-supplied, the same way `tenant` is — this package - * never resolves a session itself. - */ - readonly currentUser?: CurrentUser; - /** Open a member/agent ProfileCard in the host canvas (shell mock § Profile). */ - readonly onOpenProfile?: (subject: ProfileSubject) => void; - /** Whether the routed workbench's settings surface should replace the - * conversation stage — host-controlled from the URL (`/w/:id/settings`). */ - readonly settingsOpen?: boolean; - /** Fired when the settings surface should open or close, so the host can - * reflect it in the URL — see `ChatWorkspaceInner`'s prop of the same - * name for the `section` argument's contract. */ - readonly onSettingsOpenChange?: ( - open: boolean, - section?: WorkbenchSettingsSectionId, - entityId?: string, - ) => void; - /** Which workbench settings tab is active — host-controlled from the URL - * (`/w/:id/settings/:section`). */ - readonly settingsSection?: WorkbenchSettingsSectionId; - /** Fired when the user switches tabs while the settings surface is - * already open, so the host can reflect it in the URL. */ - readonly onSettingsSectionChange?: (section: WorkbenchSettingsSectionId) => void; - /** Section sub-selection — host-controlled from the URL - * (`/w/:id/settings/:section/:entityId`). */ - readonly settingsEntityId?: string | null; - /** Fired when a settings section opens or closes its own detail, so the - * host can deepen or clear the entity segment in the URL. */ - readonly onSettingsEntityIdChange?: (entityId: string | null) => void; - /** Open a message's artifact chip — see `WorkbenchTimeline`'s `onOpenArtifact`. */ - readonly onOpenArtifact?: (part: Part & { kind: "file" }) => void; - /** The chip's "Open in Library" affordance — see `WorkbenchTimeline`'s - * `onOpenArtifactInLibrary`. */ - readonly onOpenArtifactInLibrary?: (part: Part & { kind: "file" }) => void; - /** The classified-inference-failure text bubble's "Fix this connection" - * action — see `WorkbenchTimeline`'s `onFixConnection`. */ - readonly onFixConnection?: () => void; - /** The approve block's live round-trip — see `WorkbenchTimeline`'s - * `approvalActions`. */ - readonly approvalActions?: ApprovalActions; - /** Host round-trip for the generic "connect-service" card. Undefined - * renders every connect-service card in its disconnected framing. */ - readonly connectServiceActions?: ConnectServiceActions; - /** Host-supplied control rendered first in the workbench header — the - * shell's single col2 toggle, so chat carries the same top-bar chrome as - * every other stage surface. Unused when `headerSlot` owns the bar. */ - readonly headerLeading?: ReactNode; - /** Host-owned stage bar. When set, identity and primary actions render - * through this slot (`StageTopBar`'s crumbs / subtitle / actions) instead - * of `.chat-workbench-header`. */ - readonly headerSlot?: (chrome: ChatHeaderChrome) => ReactNode; - /** See `ChatWorkspaceInner`'s prop of the same name. */ - readonly registerComposerInsert?: (insert: ((text: string) => void) | null) => void; - /** See `ChatWorkspaceInner`'s prop of the same name. */ - readonly onWorkbenchNotFound?: (workbenchId: string) => void; - /** See `ChatWorkspaceInner`'s prop of the same name. */ - readonly onBackToWorkbenchList?: () => void; - /** See `ChatWorkspaceInner`'s prop of the same name. */ - readonly onGoToMissionControl?: () => void; - /** See `ChatWorkspaceInner`'s prop of the same name. */ - readonly onNewWorkbench?: () => void; - /** See `ChatWorkspaceInner`'s prop of the same name. */ - readonly onSignIn?: () => void; - /** See `ChatWorkspaceInner`'s prop of the same name. */ - readonly hasUsableModel?: boolean; - /** See `ChatWorkspaceInner`'s prop of the same name. */ - readonly onConnectModel?: () => void; -}) { - switch (tenant.kind) { - case "ready": - // Remount on tenant switch so prior-tenant state cannot leak. - return ( - - ); - case "empty": - return withListHeader( - headerSlot, - } - title="No workbench yet" - description="Create or join a workbench before chatting." - />, - ); - case "unauthenticated": - return withListHeader( - headerSlot, - } - title="Sign in to continue" - description="Your conversations live on a workbench — sign in to open them." - />, - ); - case "error": - return withListHeader( - headerSlot, - } - title="Couldn't open this workbench" - description={tenant.message} - />, - ); - case "loading": - return withListHeader(headerSlot, ); - } -} diff --git a/apps/web/src/chat/composer-synchronous-double-send.test.tsx b/apps/web/src/chat/composer-synchronous-double-send.test.tsx deleted file mode 100644 index 9355d33f4..000000000 --- a/apps/web/src/chat/composer-synchronous-double-send.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -// The composer's send guard used to test the `sending` *state* -// variable, which `performSend` only sets after the click/keydown handler -// that started it has already returned. Two triggers landing in the same -// synchronous tick (e.g. a stray double dispatch of the send action) both -// read `sending === false` and both post — this proves a second trigger in -// the same tick is turned away regardless of state timing. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act, createElement, createRef } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import { Composer } from "./composer"; -import type { ComposerHandle, ComposerSendPayload } from "./composer"; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - if (container !== null) { - container.remove(); - container = null; - } -}); - -const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); - -function mount(onSend: (payload: ComposerSendPayload) => Promise) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const ref = createRef(); - act(() => { - root?.render( - createElement(Composer, { - ref, - agents: [], - onSend, - }), - ); - }); - return container; -} - -function textarea(): HTMLTextAreaElement { - const element = container?.querySelector("textarea"); - if (element === null || element === undefined) { - throw new Error("composer textarea not found"); - } - return element; -} - -function typeInto(element: HTMLTextAreaElement, text: string) { - const setter = Object.getOwnPropertyDescriptor( - globalThis.HTMLTextAreaElement.prototype, - "value", - )?.set; - act(() => { - setter?.call(element, text); - element.dispatchEvent(new Event("input", { bubbles: true })); - }); -} - -function sendButton(): HTMLButtonElement { - const button = container?.querySelector('[aria-label^="Send"]'); - if (button === null || button === undefined) { - throw new Error("send button not found"); - } - return button; -} - -describe("Composer synchronous double-send guard", () => { - test("two clicks in the same tick post exactly one send", async () => { - let sendCount = 0; - const payloads: ComposerSendPayload[] = []; - mount((payload) => { - sendCount += 1; - payloads.push(payload); - return Promise.resolve(true); - }); - - typeInto(textarea(), "hello there"); - await settle(); - - act(() => { - sendButton().click(); - sendButton().click(); - }); - await settle(); - - expect(sendCount).toBe(1); - expect(payloads).toEqual([{ text: "hello there", attachments: [] }]); - }); -}); diff --git a/apps/web/src/chat/composer.test.tsx b/apps/web/src/chat/composer.test.tsx deleted file mode 100644 index 5a487308f..000000000 --- a/apps/web/src/chat/composer.test.tsx +++ /dev/null @@ -1,848 +0,0 @@ -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { act, createElement, createRef } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import * as errorSink from "@corbits/error-sink"; - -import { Composer } from "./composer"; -import type { ComposerHandle, ComposerSendPayload } from "./composer"; - -// The in-flight send contract survives the icon-only UI: its accessible name -// announces progress and the action remains unavailable until sending ends. -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - if (container !== null) { - container.remove(); - container = null; - } -}); - -const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 0))); - -function mount(onSend: () => Promise) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const ref = createRef(); - act(() => { - root?.render( - createElement(Composer, { - ref, - agents: [], - onSend, - }), - ); - }); - return container; -} - -function sendButton(): HTMLButtonElement { - const button = container?.querySelector('[aria-label^="Send"]'); - if (button === null || button === undefined) { - throw new Error("send button not found"); - } - return button; -} - -function keyboardHint(): Element | null { - return container?.querySelector(".chat-composer-keyboard-hint") ?? null; -} - -describe("Composer send button", () => { - test("updates its accessible label and stays disabled while sending", async () => { - let resolveSend: (value: boolean) => void = () => undefined; - const onSend = () => - new Promise((resolve) => { - resolveSend = resolve; - }); - mount(onSend); - - const textarea = container?.querySelector("textarea"); - if (textarea === null || textarea === undefined) { - throw new Error("composer textarea not found"); - } - act(() => { - const setter = Object.getOwnPropertyDescriptor( - globalThis.HTMLTextAreaElement.prototype, - "value", - )?.set; - setter?.call(textarea, "hello there"); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - }); - await settle(); - - expect(sendButton().getAttribute("aria-label")).toBe("Send"); - - act(() => { - sendButton().click(); - }); - await settle(); - - expect(sendButton().getAttribute("aria-label")).toBe("Sending…"); - expect(sendButton().hasAttribute("disabled")).toBe(true); - - await act(async () => { - resolveSend(true); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(sendButton().getAttribute("aria-label")).toBe("Send"); - }); -}); - -test("labels the icon-only attachment action in the composer rail", () => { - mount(() => Promise.resolve(true)); - - expect(container?.querySelector('[aria-label="Attach files"]')?.textContent).toBe(""); -}); - -function textarea(): HTMLTextAreaElement { - const element = container?.querySelector("textarea"); - if (element === null || element === undefined) { - throw new Error("composer textarea not found"); - } - return element; -} - -function typeInto(element: HTMLTextAreaElement, text: string) { - const setter = Object.getOwnPropertyDescriptor( - globalThis.HTMLTextAreaElement.prototype, - "value", - )?.set; - act(() => { - setter?.call(element, text); - element.dispatchEvent(new Event("input", { bubbles: true })); - }); -} - -function mountWithMentions(onSend: (payload: ComposerSendPayload) => Promise) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const ref = createRef(); - act(() => { - root?.render( - createElement(Composer, { - ref, - agents: [{ id: "researcher@agents.example", handle: "researcher", label: "Researcher" }], - onSend, - }), - ); - }); - return container; -} - -describe("Composer keyboard hint", () => { - test("uses the existing action rail and appears only for a focused non-empty draft", async () => { - mount(() => Promise.resolve(true)); - expect(keyboardHint()).not.toBeNull(); - expect(keyboardHint()?.getAttribute("data-visible")).toBe("false"); - expect(keyboardHint()?.getAttribute("aria-hidden")).toBe("true"); - - act(() => { - textarea().focus(); - }); - typeInto(textarea(), "hello"); - await settle(); - - expect(keyboardHint()?.getAttribute("data-visible")).toBe("true"); - expect(keyboardHint()?.getAttribute("aria-hidden")).toBe("false"); - expect(keyboardHint()?.textContent).toBe("Enter to send"); - expect(container?.querySelectorAll(".chat-composer-actions button").length).toBe(2); - expect(container?.querySelector(".chat-composer-row > textarea")).toBe(textarea()); - }); - - test("hides when the focused draft is cleared", async () => { - mount(() => Promise.resolve(true)); - act(() => { - textarea().focus(); - }); - typeInto(textarea(), "hello"); - await settle(); - expect(keyboardHint()?.getAttribute("data-visible")).toBe("true"); - - typeInto(textarea(), ""); - await settle(); - expect(keyboardHint()?.getAttribute("data-visible")).toBe("false"); - expect(keyboardHint()?.getAttribute("aria-hidden")).toBe("true"); - }); -}); - -describe("Composer growth containment", () => { - test("the textarea carries the max-height/overflow class and keeps applying its measured inline height", async () => { - mount(() => Promise.resolve(true)); - expect(textarea().className).toContain("chat-composer-input"); - - typeInto(textarea(), "line one\nline two\nline three"); - await settle(); - // The auto-grow effect still measures and writes an inline height on - // every change — the CSS transition only smooths that write, it does - // not replace it. - expect(textarea().style.height.endsWith("px")).toBe(true); - }); -}); - -describe("Composer popover entrance", () => { - test("the mention popover carries the entrance class", async () => { - mountWithMentions(() => Promise.resolve(true)); - typeInto(textarea(), "@"); - await settle(); - const popover = container?.querySelector(".chat-mention-popover"); - expect(popover?.classList.contains("chat-popover-enter")).toBe(true); - }); -}); - -describe("Composer hit targets", () => { - test("attach and send buttons carry the extended-hit-area class", () => { - mount(() => Promise.resolve(true)); - const buttons = container?.querySelectorAll(".chat-composer-icon-button"); - expect(buttons?.length).toBe(2); - }); -}); - -describe("ComposerHandle.setText", () => { - test("replaces the existing draft and focuses with the caret at the end", async () => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const ref = createRef(); - act(() => { - root?.render( - createElement(Composer, { - ref, - agents: [], - onSend: () => Promise.resolve(true), - }), - ); - }); - - typeInto(textarea(), "unsent draft"); - await settle(); - expect(textarea().value).toBe("unsent draft"); - - await act(async () => { - ref.current?.setText("previous prompt"); - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); - }); - - expect(textarea().value).toBe("previous prompt"); - expect(document.activeElement).toBe(textarea()); - expect(textarea().selectionStart).toBe("previous prompt".length); - expect(textarea().selectionEnd).toBe("previous prompt".length); - }); - - test("Enter after setText of an @handle prompt sends instead of opening mention", async () => { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - const ref = createRef(); - const sent: ComposerSendPayload[] = []; - act(() => { - root?.render( - createElement(Composer, { - ref, - agents: [{ id: "researcher@agents.example", handle: "researcher", label: "Researcher" }], - onSend: (payload) => { - sent.push(payload); - return Promise.resolve(true); - }, - }), - ); - }); - - await act(async () => { - ref.current?.setText("@researcher"); - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); - }); - await settle(); - - expect(container?.querySelector(".chat-mention-popover")).toBeNull(); - - act(() => { - textarea().dispatchEvent( - new KeyboardEvent("keydown", { - key: "Enter", - bubbles: true, - cancelable: true, - }), - ); - }); - await settle(); - - expect(sent).toEqual([{ text: "@researcher", attachments: [] }]); - }); - - test("Send after setText does not carry leftover attachments", async () => { - const sent: ComposerSendPayload[] = []; - const ref = createRef(); - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render( - createElement(Composer, { - ref, - agents: [], - onSend: (payload) => { - sent.push(payload); - return Promise.resolve(true); - }, - }), - ); - }); - - const fileInput = container.querySelector(".chat-composer-file-input"); - if (fileInput === null) throw new Error("file input not found"); - - const file = new File(["hello"], "notes.txt", { type: "text/plain" }); - Object.defineProperty(fileInput, "files", { - configurable: true, - value: { - 0: file, - length: 1, - item: (index: number) => (index === 0 ? file : null), - [Symbol.iterator]: function* () { - yield file; - }, - }, - }); - act(() => { - fileInput.dispatchEvent(new Event("change", { bubbles: true })); - }); - await settle(); - await settle(); - expect(container.querySelector(".chat-composer-attachments")).not.toBeNull(); - - await act(async () => { - ref.current?.setText("copied prompt"); - await new Promise((resolve) => { - requestAnimationFrame(() => resolve()); - }); - }); - await settle(); - - expect(container.querySelector(".chat-composer-attachments")).toBeNull(); - - act(() => { - sendButton().click(); - }); - await settle(); - - expect(sent).toEqual([{ text: "copied prompt", attachments: [] }]); - }); -}); - -// The composer's Stop affordance. `handleStop` guards against a -// double-click and re-enables itself once the host reports the turn is -// no longer running -- but a REJECTED stop request must also re-enable -// it, or a genuinely failed cancel (not a slow one) leaves the person -// with a permanently disabled button and no way to retry for the rest -// of that turn's life. -function stopButton(): HTMLButtonElement { - const button = container?.querySelector('[aria-label="Stop"]'); - if (button === null || button === undefined) { - throw new Error("stop button not found"); - } - return button; -} - -function mountStoppable( - running: boolean, - onStop: () => void | Promise, - onSend: (payload: ComposerSendPayload) => Promise = () => Promise.resolve(true), -) { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render( - createElement(Composer, { - agents: [], - onSend, - running, - onStop, - }), - ); - }); - return { - container, - rerender: (nextRunning: boolean) => { - act(() => { - root?.render( - createElement(Composer, { - agents: [], - onSend, - running: nextRunning, - onStop, - }), - ); - }); - }, - }; -} - -describe("Composer stop affordance", () => { - test("keeps Stop and Send together in the right-aligned action group", () => { - mountStoppable(true, () => undefined); - - const actions = container?.querySelector(".chat-composer-actions"); - const submitActions = stopButton().parentElement; - expect(actions?.children).toHaveLength(3); - expect(submitActions?.classList.contains("chat-composer-submit-actions")).toBe(true); - expect(submitActions?.children).toHaveLength(2); - expect(submitActions?.lastElementChild).toBe(sendButton()); - expect(actions?.lastElementChild).toBe(submitActions); - }); - - test("gives Stop and Send distinct accessible names while both are visible", () => { - mountStoppable(true, () => undefined); - - expect(stopButton().getAttribute("aria-label")).toBe("Stop"); - expect(sendButton().getAttribute("aria-label")).toBe("Send"); - }); - - test("keeps queued sends available while Stop is visible", async () => { - const sent: ComposerSendPayload[] = []; - mountStoppable( - true, - () => undefined, - (payload) => { - sent.push(payload); - return Promise.resolve(true); - }, - ); - - typeInto(textarea(), "follow-up"); - await settle(); - - expect(sendButton().hasAttribute("disabled")).toBe(false); - act(() => { - sendButton().click(); - }); - await settle(); - - expect(sent).toEqual([{ text: "follow-up", attachments: [] }]); - }); - - test("renders no Stop button when no turn is running", () => { - mountStoppable(false, () => undefined); - expect(container?.querySelector('[aria-label="Stop"]')).toBeNull(); - }); - - test("clicking Stop calls onStop and disables the button against a double-click", async () => { - let calls = 0; - mountStoppable(true, () => { - calls += 1; - }); - - expect(stopButton().hasAttribute("disabled")).toBe(false); - act(() => { - stopButton().click(); - }); - await settle(); - - expect(calls).toBe(1); - expect(stopButton().hasAttribute("disabled")).toBe(true); - - act(() => { - stopButton().click(); - }); - await settle(); - expect(calls).toBe(1); - }); - - test("the button re-enables once the host reports the turn is no longer running", async () => { - const { rerender } = mountStoppable(true, () => undefined); - act(() => { - stopButton().click(); - }); - await settle(); - expect(stopButton().hasAttribute("disabled")).toBe(true); - - rerender(false); - expect(container?.querySelector('[aria-label="Stop"]')).toBeNull(); - }); - - test("a stop request that REJECTS re-enables the button instead of leaving it stuck", async () => { - let reject: (err: unknown) => void = () => undefined; - mountStoppable( - true, - () => - new Promise((_resolve, rej) => { - reject = rej; - }), - ); - - act(() => { - stopButton().click(); - }); - await settle(); - expect(stopButton().hasAttribute("disabled")).toBe(true); - - await act(async () => { - reject(new Error("network error")); - await Promise.resolve(); - await Promise.resolve(); - }); - - // The turn is still running (the cancel request itself failed, not - // the turn) -- the button must come back so the user can try again. - expect(stopButton().hasAttribute("disabled")).toBe(false); - }); -}); - -describe("Composer dictate", () => { - const recognitions: FakeSpeechRecognition[] = []; - - class FakeSpeechRecognition { - continuous = false; - interimResults = false; - onresult: ((event: { results: FakeSpeechResult[] }) => void) | null = null; - onerror: ((event: unknown) => void) | null = null; - onend: (() => void) | null = null; - started = false; - resultOnStop: readonly { isFinal: boolean; transcript: string }[] | null = null; - - constructor() { - recognitions.push(this); - } - - start() { - this.started = true; - } - - stop() { - this.started = false; - if (this.resultOnStop !== null) { - this.emit(this.resultOnStop); - this.resultOnStop = null; - } - this.onend?.(); - } - - abort() { - this.started = false; - this.onend?.(); - } - - emit(segments: readonly { isFinal: boolean; transcript: string }[]) { - const results = segments.map((segment) => ({ - isFinal: segment.isFinal, - length: 1, - 0: { transcript: segment.transcript }, - })); - this.onresult?.({ results }); - } - } - - type FakeSpeechResult = { - readonly isFinal: boolean; - readonly length: number; - readonly 0: { readonly transcript: string }; - }; - - function installSpeechRecognition() { - Object.defineProperty(window, "SpeechRecognition", { - configurable: true, - writable: true, - value: FakeSpeechRecognition, - }); - } - - afterEach(() => { - recognitions.length = 0; - Reflect.deleteProperty(window, "SpeechRecognition"); - }); - - test("hides the dictate control when speech recognition is unavailable", () => { - mount(() => Promise.resolve(true)); - expect(container?.querySelector('[aria-label="Dictate"]')).toBeNull(); - }); - - test("starts recognition, inserts a transcript on a word boundary, and stops", async () => { - installSpeechRecognition(); - mount(() => Promise.resolve(true)); - typeInto(textarea(), "hello"); - textarea().setSelectionRange(5, 5); - await settle(); - - const dictate = container?.querySelector('[aria-label="Dictate"]'); - if (dictate === null || dictate === undefined) { - throw new Error("dictate button not found"); - } - - act(() => { - dictate.click(); - }); - await settle(); - - expect(recognitions.at(-1)?.started).toBe(true); - expect(recognitions.at(-1)?.continuous).toBe(true); - expect(recognitions.at(-1)?.interimResults).toBe(true); - expect(dictate.getAttribute("aria-label")).toBe("Stop dictating"); - expect(dictate.getAttribute("aria-pressed")).toBe("true"); - expect(dictate.getAttribute("data-listening")).toBe("true"); - - act(() => { - recognitions.at(-1)?.emit([{ isFinal: true, transcript: "world" }]); - }); - await settle(); - - expect(textarea().value).toBe("hello world"); - - act(() => { - dictate.click(); - }); - await settle(); - - expect(recognitions.at(-1)?.started).toBe(false); - const idle = container?.querySelector('[aria-label="Dictate"]'); - expect(idle?.getAttribute("aria-pressed")).toBe("false"); - expect(idle?.getAttribute("data-listening")).toBe("false"); - }); - - test("user Stop flushes a late final result into the draft", async () => { - installSpeechRecognition(); - mount(() => Promise.resolve(true)); - typeInto(textarea(), "hello"); - textarea().setSelectionRange(5, 5); - await settle(); - - const dictate = container?.querySelector('[aria-label="Dictate"]'); - if (dictate === null || dictate === undefined) { - throw new Error("dictate button not found"); - } - - act(() => { - dictate.click(); - }); - await settle(); - - const rec = recognitions.at(-1); - if (rec === undefined) { - throw new Error("speech recognition was not constructed"); - } - rec.resultOnStop = [{ isFinal: true, transcript: "world" }]; - - act(() => { - dictate.click(); - }); - await settle(); - - expect(textarea().value).toBe("hello world"); - expect(rec.started).toBe(false); - }); - - test("send while listening does not restore the draft from a late final result", async () => { - installSpeechRecognition(); - const sent: ComposerSendPayload[] = []; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render( - createElement(Composer, { - agents: [], - onSend: (payload) => { - sent.push(payload); - return Promise.resolve(true); - }, - }), - ); - }); - typeInto(textarea(), "hello"); - textarea().setSelectionRange(5, 5); - await settle(); - - const dictate = container?.querySelector('[aria-label="Dictate"]'); - if (dictate === null || dictate === undefined) { - throw new Error("dictate button not found"); - } - - act(() => { - dictate.click(); - }); - await settle(); - - const rec = recognitions.at(-1); - if (rec === undefined) { - throw new Error("speech recognition was not constructed"); - } - rec.resultOnStop = [{ isFinal: true, transcript: "late words" }]; - - act(() => { - sendButton().click(); - }); - await settle(); - - act(() => { - rec.emit([{ isFinal: true, transcript: "late words" }]); - }); - await settle(); - - expect(textarea().value).toBe(""); - expect(sent).toEqual([{ text: "hello", attachments: [] }]); - }); - - test("Enter while listening does not restore the draft from a late final result", async () => { - installSpeechRecognition(); - const sent: ComposerSendPayload[] = []; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render( - createElement(Composer, { - agents: [], - onSend: (payload) => { - sent.push(payload); - return Promise.resolve(true); - }, - }), - ); - }); - typeInto(textarea(), "hello"); - textarea().setSelectionRange(5, 5); - await settle(); - - const dictate = container?.querySelector('[aria-label="Dictate"]'); - if (dictate === null || dictate === undefined) { - throw new Error("dictate button not found"); - } - - act(() => { - dictate.click(); - }); - await settle(); - - const rec = recognitions.at(-1); - if (rec === undefined) { - throw new Error("speech recognition was not constructed"); - } - rec.resultOnStop = [{ isFinal: true, transcript: "late words" }]; - - act(() => { - textarea().dispatchEvent( - new KeyboardEvent("keydown", { - key: "Enter", - bubbles: true, - cancelable: true, - }), - ); - }); - await settle(); - - act(() => { - rec.emit([{ isFinal: true, transcript: "late words" }]); - }); - await settle(); - - expect(textarea().value).toBe(""); - expect(sent).toEqual([{ text: "hello", attachments: [] }]); - }); - - test("stop then send does not restore the draft from a deferred final result", async () => { - installSpeechRecognition(); - const sent: ComposerSendPayload[] = []; - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - act(() => { - root?.render( - createElement(Composer, { - agents: [], - onSend: (payload) => { - sent.push(payload); - return Promise.resolve(true); - }, - }), - ); - }); - typeInto(textarea(), "hello"); - textarea().setSelectionRange(5, 5); - await settle(); - - const dictate = container?.querySelector('[aria-label="Dictate"]'); - if (dictate === null || dictate === undefined) { - throw new Error("dictate button not found"); - } - - act(() => { - dictate.click(); - }); - await settle(); - - const rec = recognitions.at(-1); - if (rec === undefined) { - throw new Error("speech recognition was not constructed"); - } - - act(() => { - dictate.click(); - }); - await settle(); - - expect(textarea().value).toBe("hello"); - - act(() => { - sendButton().click(); - }); - await settle(); - - act(() => { - rec.emit([{ isFinal: true, transcript: "late words" }]); - }); - await settle(); - - expect(textarea().value).toBe(""); - expect(sent).toEqual([{ text: "hello", attachments: [] }]); - }); - - test("does not report aborted or no-speech recognition errors", async () => { - installSpeechRecognition(); - const report = spyOn(errorSink, "reportError"); - mount(() => Promise.resolve(true)); - - const dictate = container?.querySelector('[aria-label="Dictate"]'); - if (dictate === null || dictate === undefined) { - throw new Error("dictate button not found"); - } - - act(() => { - dictate.click(); - }); - await settle(); - - const rec = recognitions.at(-1); - if (rec === undefined) { - throw new Error("speech recognition was not constructed"); - } - - act(() => { - rec.onerror?.({ error: "no-speech" }); - rec.onerror?.({ error: "aborted" }); - }); - await settle(); - - expect(report).not.toHaveBeenCalled(); - - act(() => { - rec.onerror?.({ error: "network" }); - }); - await settle(); - - expect(report).toHaveBeenCalled(); - report.mockRestore(); - }); -}); diff --git a/apps/web/src/chat/composer.tsx b/apps/web/src/chat/composer.tsx deleted file mode 100644 index b71eb1dd5..000000000 --- a/apps/web/src/chat/composer.tsx +++ /dev/null @@ -1,901 +0,0 @@ -// The message composer: a plain textarea (Enter sends, Shift+Enter breaks -// the line), an accessible file picker for attachments, disabled while -// empty, with an @-mention popover listing the active workbench's agent -// participants. Kept local and simple rather than adopting the library's -// `ChatInput` — that component is built around the agent-chat `ChatMessage` -// model (working/stop) this surface does not use, and its send affordance -// does not compose with an inline mention popover. - -import { Button } from "@corbits/react-ui"; -import { ArrowUp, CircleNotch, Microphone, Paperclip, Stop, X } from "@/lib/icons"; -import { reportError } from "@corbits/error-sink"; -import { - forwardRef, - useEffect, - useImperativeHandle, - useLayoutEffect, - useRef, - useState, -} from "react"; -import type { ChangeEvent, KeyboardEvent } from "react"; - -import { CorbitAvatar } from "./avatar"; -import type { Part } from "./api"; -import { activeMentionQuery, filterMentionCandidates, insertMention } from "./mentions"; -import type { MentionCandidate, MentionQuery } from "./mentions"; -import { CHAT_STRINGS } from "./strings"; - -/** A file the user picked in the composer, already base64-encoded for the wire. */ -export type ComposerAttachment = { - readonly id: string; - readonly name: string; - readonly mediaType: string; - readonly data: string; -}; - -export type ComposerSendPayload = { - readonly text: string; - readonly attachments: readonly ComposerAttachment[]; -}; - -/** Imperative seam a host can grab a ref to, so content from outside the - * composer's own tree — the profile card's Mention action or - * hover-edit of a previous prompt — can land in the active draft. */ -export type ComposerHandle = { - readonly insertText: (text: string) => void; - readonly setText: (text: string) => void; -}; - -/** Splice `insertion` in at `caret`, pure and independent of any DOM state - * so it unit-tests the same way `insertMention` does. */ -export function insertTextAtCaret( - value: string, - caret: number, - insertion: string, -): { readonly text: string; readonly caret: number } { - const before = value.slice(0, caret); - const after = value.slice(caret); - const text = `${before}${insertion}${after}`; - return { text, caret: before.length + insertion.length }; -} - -export type SpeechRecognitionAlternativeLike = { - readonly transcript: string; -}; - -export type SpeechRecognitionResultLike = { - readonly isFinal: boolean; - readonly length: number; - readonly 0: SpeechRecognitionAlternativeLike; -}; - -export type SpeechRecognitionEventLike = { - readonly results: ArrayLike; -}; - -export type SpeechRecognitionLike = { - continuous: boolean; - interimResults: boolean; - onresult: ((event: SpeechRecognitionEventLike) => void) | null; - onerror: ((event: unknown) => void) | null; - onend: (() => void) | null; - start: () => void; - stop: () => void; - abort: () => void; -}; - -type SpeechRecognitionCtor = new () => SpeechRecognitionLike; - -function isSpeechRecognitionCtor(value: unknown): value is SpeechRecognitionCtor { - return typeof value === "function"; -} - -/** Browser `SpeechRecognition` / `webkitSpeechRecognition`, or null. */ -export function speechRecognitionConstructor( - global: object = globalThis, -): SpeechRecognitionCtor | null { - if ("SpeechRecognition" in global) { - const ctor = Reflect.get(global, "SpeechRecognition"); - if (isSpeechRecognitionCtor(ctor)) return ctor; - } - if ("webkitSpeechRecognition" in global) { - const ctor = Reflect.get(global, "webkitSpeechRecognition"); - if (isSpeechRecognitionCtor(ctor)) return ctor; - } - return null; -} - -function speechRecognitionErrorCode(event: unknown): string | null { - if (typeof event !== "object" || event === null) return null; - if (!("error" in event)) return null; - const { error } = event; - if (typeof error !== "string") return null; - return error; -} - -function isBenignSpeechRecognitionError(code: string): boolean { - return code === "aborted" || code === "no-speech"; -} - -function detachDictation(rec: SpeechRecognitionLike) { - rec.onresult = null; - rec.onerror = null; - rec.onend = null; -} - -export function transcriptFromSpeechResults( - results: ArrayLike, -): string { - let text = ""; - for (const result of Array.from(results)) { - if (result.length === 0) continue; - text += result[0].transcript; - } - return text; -} - -/** - * Drop a recognition transcript between `prefix` and `suffix`, inserting a - * space when the join would otherwise glue two words together. - */ -export function spliceDictationTranscript( - prefix: string, - suffix: string, - transcript: string, -): { readonly text: string; readonly caret: number } { - const trimmed = transcript.trim(); - if (trimmed.length === 0) { - return { text: `${prefix}${suffix}`, caret: prefix.length }; - } - const head = - prefix.length === 0 || /\s$/u.test(prefix) ? `${prefix}${trimmed}` : `${prefix} ${trimmed}`; - const text = - suffix.length === 0 || /^\s/u.test(suffix) ? `${head}${suffix}` : `${head} ${suffix}`; - return { text, caret: head.length }; -} - -/** - * The B2 fix, isolated as a pure rule: a successful send clears the draft; - * a failed one keeps exactly what the user had typed so nothing is lost. - */ -export function draftAfterSend(previousValue: string, succeeded: boolean): string { - return succeeded ? "" : previousValue; -} - -/** - * Same rule for selected files: clear the attachment list only after a - * successful send so a failed post does not force the user to re-pick files. - */ -export function attachmentsAfterSend( - previous: readonly ComposerAttachment[], - succeeded: boolean, -): readonly ComposerAttachment[] { - return succeeded ? [] : previous; -} - -/** - * Build the wire `Part[]` for a composer send. Empty trimmed text is omitted; - * each attachment becomes a `FilePart` carrying inline base64 `data`. - */ -export function partsForSend(text: string, attachments: readonly ComposerAttachment[]): Part[] { - const parts: Part[] = []; - const trimmed = text.trim(); - if (trimmed.length > 0) { - parts.push({ kind: "text", text: trimmed }); - } - for (const file of attachments) { - parts.push({ - kind: "file", - name: file.name, - mediaType: file.mediaType, - data: file.data, - }); - } - return parts; -} - -export function canSendComposer(text: string, attachments: readonly ComposerAttachment[]): boolean { - return text.trim().length > 0 || attachments.length > 0; -} - -/** - * Client-side attachment ceilings, kept under the platform's decoded-byte - * limits (10 MiB per file / 30 MiB total) so a pick fails in the composer - * rather than after a failed post. - */ -export const COMPOSER_ATTACHMENT_LIMITS = { - maxCount: 5, - maxPerFileBytes: 5 * 1024 * 1024, - maxTotalBytes: 15 * 1024 * 1024, -} as const; - -export type ComposerAttachmentLimits = { - readonly maxCount: number; - readonly maxPerFileBytes: number; - readonly maxTotalBytes: number; -}; - -/** Size metadata available before FileReader runs (File.size). */ -export type AttachmentPickCandidate = { - readonly name: string; - readonly size: number; -}; - -export type AttachmentValidationError = - | { readonly kind: "count"; readonly max: number; readonly attempted: number } - | { - readonly kind: "perFile"; - readonly name: string; - readonly size: number; - readonly max: number; - } - | { readonly kind: "total"; readonly total: number; readonly max: number }; - -/** - * Validate a multi-file pick against count, per-file, and total size limits - * before any FileReader work. Failures are all-or-nothing for the pick. - */ -export function validateAttachmentPick( - existingCount: number, - existingTotalBytes: number, - candidates: readonly AttachmentPickCandidate[], - limits: ComposerAttachmentLimits = COMPOSER_ATTACHMENT_LIMITS, -): AttachmentValidationError | null { - if (candidates.length === 0) return null; - const attempted = existingCount + candidates.length; - if (attempted > limits.maxCount) { - return { kind: "count", max: limits.maxCount, attempted }; - } - let addedBytes = 0; - for (const file of candidates) { - if (file.size > limits.maxPerFileBytes) { - return { - kind: "perFile", - name: file.name, - size: file.size, - max: limits.maxPerFileBytes, - }; - } - addedBytes += file.size; - } - const total = existingTotalBytes + addedBytes; - if (total > limits.maxTotalBytes) { - return { kind: "total", total, max: limits.maxTotalBytes }; - } - return null; -} - -/** Decoded byte length of a standard base64 payload (padding-aware). */ -export function base64DecodedByteLength(data: string): number { - if (data.length === 0) return 0; - let padding = 0; - if (data.endsWith("==")) padding = 2; - else if (data.endsWith("=")) padding = 1; - return (data.length * 3) / 4 - padding; -} - -export function attachmentBytesOnComposer(attachments: readonly ComposerAttachment[]): number { - let total = 0; - for (const file of attachments) { - total += base64DecodedByteLength(file.data); - } - return total; -} - -function formatLimitMiB(bytes: number): number { - return Math.round(bytes / (1024 * 1024)); -} - -export function attachmentValidationMessage(error: AttachmentValidationError): string { - switch (error.kind) { - case "count": - return CHAT_STRINGS.composerAttachmentCountError(error.max); - case "perFile": - return CHAT_STRINGS.composerAttachmentPerFileError(error.name, formatLimitMiB(error.max)); - case "total": - return CHAT_STRINGS.composerAttachmentTotalError(formatLimitMiB(error.max)); - } -} - -/** ArrowUp/Enter stay blocked while a send or file read is in flight. */ -export function canSendComposerAction( - text: string, - attachments: readonly ComposerAttachment[], - state: { readonly sending: boolean; readonly preparing: boolean }, -): boolean { - if (state.sending || state.preparing) return false; - return canSendComposer(text, attachments); -} - -/** - * Whether the composer offers a Stop affordance — a stand-in - * for "is there a turn to cancel," reported by the host from its own - * `isAwaitingReply` signal (the whole in-flight phase, including after - * tokens have started streaming — not the tokenless `isPendingReply` - * pulse). Deliberately independent of `sending`/ - * `preparing`: a follow-up message can still be typed and queued while a - * turn runs (`turn-queue.ts` batches it), so Stop and Send coexist - * rather than one gating the other. - */ -export function canStopComposer(state: { readonly running: boolean }): boolean { - return state.running; -} - -/** Attach stays blocked while a send or file read is in flight. */ -export function canAttachComposer(state: { - readonly sending: boolean; - readonly preparing: boolean; -}): boolean { - return !state.sending && !state.preparing; -} - -/** - * The send button's three visual states: `"empty"` (nothing to send — - * muted and disabled), `"ready"` (content waiting — primary-orange and - * enabled), `"sending"` (a send is in flight — primary but disabled, with - * a spinner in place of the send glyph). Kept as a pure function of the - * same inputs `canSendComposerAction` already reasons over, so the two - * never drift on what counts as "there's something to send". - */ -export type ComposerSendVisualState = "empty" | "ready" | "sending"; - -export function composerSendVisualState( - text: string, - attachments: readonly ComposerAttachment[], - state: { readonly sending: boolean }, -): ComposerSendVisualState { - if (state.sending) return "sending"; - return canSendComposer(text, attachments) ? "ready" : "empty"; -} - -function readFileAsBase64(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - const result = reader.result; - if (typeof result !== "string") { - reject(new Error("expected a data URL from FileReader")); - return; - } - const comma = result.indexOf(","); - resolve(comma === -1 ? result : result.slice(comma + 1)); - }; - reader.onerror = () => reject(reader.error ?? new Error("failed to read attachment")); - reader.readAsDataURL(file); - }); -} - -let attachmentSeq = 0; - -function nextAttachmentId(): string { - attachmentSeq += 1; - return `att_${attachmentSeq}`; -} - -export const Composer = forwardRef< - ComposerHandle, - { - readonly agents: readonly MentionCandidate[]; - /** Resolves to whether the send succeeded; the composer decides draft/attachment cleanup from that. */ - readonly onSend: (payload: ComposerSendPayload) => Promise; - /** Defaults to the generic workbench copy — a chat passes one naming its counterpart. */ - readonly placeholder?: string; - /** Whether a turn is currently running for this workbench — - * typically the host's own `isAwaitingReply(streamingReply)`. Absent - * or `false` renders no Stop affordance at all. */ - readonly running?: boolean; - /** - * Cancels the running turn — `POST .../turns/cancel`. Required - * whenever `running` can be `true`; the composer never guesses at - * how to stop a turn on its own. May return a promise: a rejection - * re-enables the button immediately (the request itself failed — - * network, a denied grant — not merely a slow cancel, so there is - * no reason to make the person wait for `running` to change before - * they can try again). - */ - readonly onStop?: () => void | Promise; - } ->(function Composer( - { agents, onSend, placeholder = CHAT_STRINGS.composerPlaceholder, running = false, onStop }, - ref, -) { - const [value, setValue] = useState(""); - const [attachments, setAttachments] = useState([]); - const [mention, setMention] = useState(null); - const [highlight, setHighlight] = useState(0); - const [sending, setSending] = useState(false); - const [preparing, setPreparing] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const [focused, setFocused] = useState(false); - // Guards Stop against a double-click firing two cancel - // requests. A second cancel is harmless server-side (compare-and-set), - // but there is no reason to send it. Resets once the host reports the - // turn is no longer running -- not on a timer, since a slow cancel - // (ceiling) must stay disabled rather than re-arm early. - const [stopping, setStopping] = useState(false); - const textareaRef = useRef(null); - const fileInputRef = useRef(null); - const attachGenerationRef = useRef(0); - // `sending` is React state, set only after `onSend` has already been - // awaited into — two calls to `performSend` inside one synchronous tick - // (an Enter keydown and a click both firing before a render lands) would - // both read `sending === false` and both post. This ref is set the - // instant a send starts, synchronously ahead of any render, so a second - // call in the same tick is turned away. - const sendInFlightRef = useRef(false); - const recognitionRef = useRef(null); - const dictatePrefixRef = useRef(""); - const dictateSuffixRef = useRef(""); - const [listening, setListening] = useState(false); - const [dictateAvailable] = useState(() => speechRecognitionConstructor() !== null); - - function stopDictation() { - const rec = recognitionRef.current; - if (rec === null) return; - setListening(false); - rec.stop(); - } - - function abortDictation() { - const rec = recognitionRef.current; - if (rec === null) return; - recognitionRef.current = null; - setListening(false); - detachDictation(rec); - try { - rec.abort(); - } catch { - // report-error-ignore: abort() after user Stop is InvalidStateError - // once recognition has already ended. - } - } - - function applyDictationTranscript(transcript: string) { - const next = spliceDictationTranscript( - dictatePrefixRef.current, - dictateSuffixRef.current, - transcript, - ); - setValue(next.text); - syncComposerSuggestState(next.text, next.caret); - requestAnimationFrame(() => { - textareaRef.current?.setSelectionRange(next.caret, next.caret); - }); - } - - function startDictation() { - abortDictation(); - const Ctor = speechRecognitionConstructor(); - if (Ctor === null) return; - const textarea = textareaRef.current; - const caret = textarea?.selectionStart ?? value.length; - dictatePrefixRef.current = value.slice(0, caret); - dictateSuffixRef.current = value.slice(caret); - const rec = new Ctor(); - rec.continuous = true; - rec.interimResults = true; - rec.onresult = (event) => { - applyDictationTranscript(transcriptFromSpeechResults(event.results)); - }; - rec.onerror = (event) => { - const code = speechRecognitionErrorCode(event); - if (code === null || !isBenignSpeechRecognitionError(code)) { - reportError(event, { operation: "composer.dictate" }); - } - if (recognitionRef.current === rec) { - recognitionRef.current = null; - setListening(false); - } - }; - rec.onend = () => { - if (recognitionRef.current !== rec) return; - setListening(false); - }; - recognitionRef.current = rec; - try { - rec.start(); - setListening(true); - } catch (cause) { - reportError(cause, { operation: "composer.dictate.start" }); - recognitionRef.current = null; - } - } - - function toggleDictation() { - if (listening) { - stopDictation(); - return; - } - startDictation(); - } - - useEffect(() => { - if (!running) setStopping(false); - }, [running]); - - useEffect(() => { - return () => { - const rec = recognitionRef.current; - if (rec === null) return; - detachDictation(rec); - recognitionRef.current = null; - rec.abort(); - }; - }, []); - - /** Auto-grow: the textarea reports its own content height, so the - * measurement resets to the CSS-declared min-height before reading - * `scrollHeight` — otherwise a shrinking draft would get stuck at its - * tallest-ever height. Growth caps out at the CSS max-height, where - * `overflow-y` takes over for scrolling. */ - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (textarea === null) return; - textarea.style.height = "auto"; - textarea.style.height = `${textarea.scrollHeight}px`; - }, [value]); - - function syncComposerSuggestState(text: string, caret: number) { - const openMention = activeMentionQuery(text, caret); - setMention(openMention); - setHighlight(0); - } - - useImperativeHandle( - ref, - () => ({ - insertText: (text: string) => { - abortDictation(); - const textarea = textareaRef.current; - const caret = textarea?.selectionStart ?? value.length; - const result = insertTextAtCaret(value, caret, text); - setValue(result.text); - requestAnimationFrame(() => { - textarea?.focus(); - textarea?.setSelectionRange(result.caret, result.caret); - }); - }, - setText: (text: string) => { - abortDictation(); - attachGenerationRef.current += 1; - setValue(text); - setMention(null); - setAttachments([]); - setErrorMessage(null); - setPreparing(false); - requestAnimationFrame(() => { - const textarea = textareaRef.current; - textarea?.focus(); - textarea?.setSelectionRange(text.length, text.length); - }); - }, - }), - [value], - ); - - const mentionOptions: readonly MentionCandidate[] = - mention !== null ? filterMentionCandidates(agents, mention.query) : []; - const busy = { sending, preparing }; - const canSend = canSendComposerAction(value, attachments, busy); - const canAttach = canAttachComposer(busy); - const sendVisualState = composerSendVisualState(value, attachments, { - sending, - }); - - /** - * Fires the send and tracks its flight for the button's spinner — - * nothing here decides what the timeline shows on success or failure. - * The host owns that: it adds an optimistic pending bubble the instant - * the payload leaves the composer, then resolves or fails it in place, - * so a failed send never comes back here to repopulate the draft — - * recovering the text is the pending bubble's own Discard action. - */ - async function performSend(payload: ComposerSendPayload): Promise { - if (sendInFlightRef.current) return; - sendInFlightRef.current = true; - setSending(true); - setErrorMessage(null); - try { - await onSend(payload); - } finally { - sendInFlightRef.current = false; - setSending(false); - } - } - - /** Splices the picked candidate's handle into the draft. */ - function pickMention(option: MentionCandidate) { - const textarea = textareaRef.current; - if (mention === null || textarea === null) return; - const caret = textarea.selectionStart; - const result = insertMention(value, caret, mention, option.handle); - setValue(result.text); - setMention(null); - requestAnimationFrame(() => { - textarea.focus(); - textarea.setSelectionRange(result.caret, result.caret); - }); - } - - function resetFileInput() { - if (fileInputRef.current !== null) { - fileInputRef.current.value = ""; - } - } - - async function addFiles(fileList: FileList | null) { - if (fileList === null || fileList.length === 0) return; - if (!canAttachComposer({ sending, preparing })) return; - - const files = Array.from(fileList); - const generation = attachGenerationRef.current; - const validation = validateAttachmentPick( - attachments.length, - attachmentBytesOnComposer(attachments), - files.map((file) => ({ name: file.name, size: file.size })), - ); - if (validation !== null) { - setErrorMessage(attachmentValidationMessage(validation)); - resetFileInput(); - return; - } - - setPreparing(true); - setErrorMessage(null); - try { - const next: ComposerAttachment[] = []; - for (const file of files) { - const data = await readFileAsBase64(file); - next.push({ - id: nextAttachmentId(), - name: file.name, - mediaType: file.type.length > 0 ? file.type : "application/octet-stream", - data, - }); - } - // All-or-nothing: only commit once every file in the pick has read. - if (attachGenerationRef.current !== generation) return; - setAttachments((previous) => [...previous, ...next]); - } catch { - if (attachGenerationRef.current !== generation) return; - setErrorMessage(CHAT_STRINGS.composerAttachmentReadError); - } finally { - if (attachGenerationRef.current === generation) { - setPreparing(false); - } - resetFileInput(); - } - } - - function removeAttachment(id: string) { - setAttachments((previous) => previous.filter((file) => file.id !== id)); - } - - /** - * The draft leaves the box the instant it's handed off, win or lose — - * the host's optimistic pending bubble is now the one place that text - * lives until the send actually resolves. A failure never repopulates - * this box; the bubble's Discard action is the only way text comes - * back here (see `ComposerHandle.insertText`, the same seam the - * profile card's Mention action uses). - */ - async function send() { - if (!canSendComposerAction(value, attachments, { sending, preparing })) { - return; - } - abortDictation(); - const payload: ComposerSendPayload = { text: value, attachments }; - setValue(""); - setAttachments([]); - setMention(null); - await performSend(payload); - } - - function handleKeyDown(event: KeyboardEvent) { - if (mention !== null && mentionOptions.length > 0) { - if (event.key === "ArrowDown") { - event.preventDefault(); - setHighlight((index) => (index + 1) % mentionOptions.length); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - setHighlight((index) => (index - 1 + mentionOptions.length) % mentionOptions.length); - return; - } - if (event.key === "Enter" || event.key === "Tab") { - event.preventDefault(); - const chosen = mentionOptions[highlight]; - if (chosen !== undefined) pickMention(chosen); - return; - } - if (event.key === "Escape") { - setMention(null); - return; - } - } - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - void send(); - } - } - - function handleFileChange(event: ChangeEvent) { - void addFiles(event.target.files); - } - - function handleStop() { - if (stopping || onStop === undefined) return; - setStopping(true); - // (Critique finding): a rejected stop request is a FAILED - // cancel, not a slow one -- the `useEffect` above only re-enables - // once the host reports `running` has gone false, which never - // happens for a request that never reached the server. Without - // this catch the button stayed disabled for the rest of the turn's - // life with no way to retry. - Promise.resolve(onStop()).catch(() => setStopping(false)); - } - - return ( -
- {mention !== null && ( -
- {mentionOptions.length === 0 ? ( -
{CHAT_STRINGS.mentionEmpty}
- ) : ( -
- {mentionOptions.map((option, index) => ( - - ))} -
- )} -
- )} -
- - {attachments.length > 0 && ( -
    - {attachments.map((file) => ( -
  • - {file.name} - -
  • - ))} -
- )} -