From cd63d85b4496519fe764090893af0f0b3d43c2a2 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 13:30:26 -0700 Subject: [PATCH 1/3] Load operator extension paths in atomic Planner sessions HARNESS_EXTENSIONS lists extension or package paths that every atomic Planner session loads, as Atomic's --extension flag would. A package's extensions, skills and workflows all register, so an operator can give the Planner workflows without installing them into the agent directory. The paths are split on the platform path delimiter, must be absolute and exist, and are refused under any other harness. Background workers never load them, and the startup line names them. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test passed: 1907 pass, 2 PostgreSQL skips, 0 fail; the new Planner test fails without the adapter change Assistant-verification: bun run types passed: all workspaces Assistant-verification: bun run ci passed: dprint, oxlint (7 existing warnings, none new), tokens, design contract, design record, Impeccable (no new findings) Assistant-verification: adapter integration passed: a Planner session with a local package on HARNESS_EXTENSIONS listed that package's workflows through the workflow tool Co-authored-by: Alex Lavaee --- .env.example | 3 ++ apps/server/src/config.test.ts | 11 ++++ apps/server/src/config.ts | 16 +++++- apps/server/src/harness/atomic/adapter.ts | 8 ++- apps/server/src/harness/atomic/full.test.ts | 57 ++++++++++++++++++++- apps/server/src/harness/harnesses.test.ts | 14 +++++ apps/server/src/harness/harnesses.ts | 26 +++++++++- apps/server/src/testing/config.ts | 1 + docs/hosted-agent.md | 5 +- docs/self-hosting.md | 1 + 10 files changed, 136 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 845f169c..d28a0da2 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,9 @@ HARNESS=copilot-sdk # HARNESS=atomic requires auto (the host's Atomic login and environment keys, # loopback only) or ai-gateway (AI_GATEWAY_API_KEY, any bind). #HARNESS_AUTH= +# HARNESS=atomic only: absolute extension or package paths every Planner +# session loads, as Atomic's --extension would, joined by ':' (';' on Windows). +#HARNESS_EXTENSIONS= BACKGROUND_JOBS=on WEB_RESEARCH=on diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 614aebb6..278b9dc8 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "bun:test"; +import { delimiter } from "node:path"; import { describe as description } from "./config"; import { configured, LOCAL, REQUIRED } from "./testing/config"; @@ -108,6 +109,16 @@ describe("configuration", () => { } }); + it("splits HARNESS_EXTENSIONS on the path delimiter and names the paths at startup", () => { + let atomic = { HARNESS: "atomic", HARNESS_AUTH: "ai-gateway", MODEL: "stub/model" }; + expect(configured(atomic).harnessExtensions).toEqual([]); + expect(description(configured(atomic))).not.toContain("Planner extensions"); + let paths = ["/opt/first", "/opt/second"]; + let config = configured({ ...atomic, HARNESS_EXTENSIONS: paths.join(delimiter) + delimiter }); + expect(config.harnessExtensions).toEqual(paths); + expect(description(config)).toContain("Planner extensions: /opt/first, /opt/second"); + }); + it("requires a valid PostgreSQL URL", () => { expect(() => configured({ DATABASE_URL: undefined })).toThrow("DATABASE_URL is required"); expect(() => configured({ DATABASE_URL: "https://database.test" })).toThrow("PostgreSQL URL"); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 4e3ef036..0171efa3 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -6,6 +6,8 @@ * than a puzzling behaviour three screens later. */ +import { delimiter } from "node:path"; + import { loadAuth } from "./auth/config"; import type { AuthConfig } from "./auth/config"; @@ -18,6 +20,11 @@ export type Config = { model: string; harness: string; harnessAuth: string | undefined; + /** + * Extension or package paths every atomic Planner session loads, as if + * passed to the CLI's `--extension`. Split on the platform path delimiter. + */ + harnessExtensions: string[]; /** * Whether to run the agent at all. * @@ -59,11 +66,15 @@ function model(harness: string): string { } return DEFAULT_MODEL; } -export function harnessSelection(): Pick { +export function harnessSelection(): Pick< + Config, + "host" | "harness" | "harnessAuth" | "harnessExtensions" +> { return { host: process.env.SERVER_HOST || "127.0.0.1", harness: process.env.HARNESS || "copilot-sdk", harnessAuth: process.env.HARNESS_AUTH || undefined, + harnessExtensions: (process.env.HARNESS_EXTENSIONS ?? "").split(delimiter).filter(Boolean), }; } @@ -136,6 +147,9 @@ export function describe(config: Config): string { ...(config.harness === "atomic" ? ["Planner: full Atomic session (shell and filesystem access as this process's user)"] : []), + ...(config.harnessExtensions.length + ? [`Planner extensions: ${config.harnessExtensions.join(", ")}`] + : []), config.backgroundJobs ? "background jobs: on" : "background jobs: off", config.webResearch ? "web research: on" : "web research: off", admission, diff --git a/apps/server/src/harness/atomic/adapter.ts b/apps/server/src/harness/atomic/adapter.ts index c19d5840..4eb74c24 100644 --- a/apps/server/src/harness/atomic/adapter.ts +++ b/apps/server/src/harness/atomic/adapter.ts @@ -123,6 +123,12 @@ export type AtomicSettings = { model?: string; /** Providers registered by code rather than discovered from the host. */ providers?: Record; + /** + * Extension or package paths every Planner session loads, as the CLI's + * `--extension` would; their workflows and skills register too. Worker + * sessions never load them. + */ + extensions?: readonly string[]; }; export class ToolBoundaryError extends Error { @@ -475,7 +481,7 @@ export function createAtomicAdapter( cwd, agentDir, settingsManager, - ...(full ? {} : { + ...(full ? { additionalExtensionPaths: [...settings.extensions ?? []] } : { noExtensions: true, noSkills: true, noPromptTemplates: true, diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index 8a755590..0a14ad2e 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -65,6 +65,8 @@ let stub = startStubModelServer((prompt, prior) => ? { kind: "tool", name: "ask_user_question", arguments: JSON.stringify(freeText[prompt]) } : prompt === "cwd" ? { kind: "tool", name: "bash", arguments: JSON.stringify({ command: "pwd" }) } + : prompt === "workflows" + ? { kind: "tool", name: "workflow", arguments: JSON.stringify({ action: "list" }) } : { kind: "text", text: "Ready." } ); afterAll(stub.stop); @@ -72,7 +74,7 @@ afterAll(stub.stop); async function run( registered: boolean, prompt = "plain", - { host, checkout = true, worker = false, projectPackage = false }: { + { host, checkout = true, worker = false, projectPackage = false, operatorPackage = false }: { host?: HostInput; /** False runs the Planner in an empty directory, as a channel without a checkout does. */ checkout?: boolean; @@ -80,6 +82,8 @@ async function run( worker?: boolean; /** Install a local package through the checkout's `.atomic/settings.json`. */ projectPackage?: boolean; + /** Load a local package through the operator's extension paths, as `HARNESS_EXTENSIONS` does. */ + operatorPackage?: boolean; } = {}, ) { let root = await mkdtemp(join(tmpdir(), "chopin-atomic-full-")); @@ -87,9 +91,11 @@ async function run( let cwd = join(root, checkout ? "checkout" : "empty"); let previous = process.env.ATOMIC_CODING_AGENT_DIR; let received: QuestionParams[] = []; + let operatorPackageDir = join(root, "operator-package"); let harness = createAtomicAdapter({ auth: "ai-gateway", model: "stub/model", + extensions: operatorPackage ? [operatorPackageDir] : undefined, providers: { stub: { baseUrl: stub.baseUrl, @@ -157,6 +163,43 @@ async function run( await mkdir(join(cwd, ".atomic"), { recursive: true }); await writeFile(join(cwd, ".atomic", "settings.json"), JSON.stringify({ packages: [pkg] })); } + if (operatorPackage) { + await mkdir(join(operatorPackageDir, "skills", "operator-package-marker"), { + recursive: true, + }); + await writeFile( + join(operatorPackageDir, "package.json"), + JSON.stringify({ + name: "operator-package", + type: "module", + atomic: { + extensions: ["./extension.ts"], + skills: ["./skills"], + workflows: ["./workflow.ts"], + }, + }), + ); + await writeFile( + join(operatorPackageDir, "extension.ts"), + `export default api => api.registerTool({name: "operator_package_tool", label: "Operator package", description: "Marker", parameters: {type:"object"}, async execute() { return {content:[{type:"text",text:"operator package"}], details:{}}; }});`, + ); + await writeFile( + join(operatorPackageDir, "skills", "operator-package-marker", "SKILL.md"), + "---\nname: operator-package-marker\ndescription: OPERATOR-PACKAGE-SKILL-MARKER\n---\nOperator package skill.\n", + ); + await writeFile( + join(operatorPackageDir, "workflow.ts"), + `import { workflow } from "@bastani/atomic/workflows"; +export default workflow({ + name: "operator-package-workflow", + description: "OPERATOR-PACKAGE-WORKFLOW-MARKER", + inputs: {}, + outputs: {}, + run: async () => ({}), +}); +`, + ); + } process.env.ATOMIC_CODING_AGENT_DIR = agentDir; let humanInput: HostInput = host ?? { confirm: async () => false, @@ -288,6 +331,18 @@ test("a checkout's project settings add its packages without writing either sett expect(plain.requests[0]!.toolNames).not.toContain("project_tool"); }); +test("operator extension paths add a package's tools, skills, and workflows to Planner sessions only", async () => { + let result = await run(true, "workflows", { operatorPackage: true, worker: true }); + let request = result.requests[0]!; + expect(request.toolNames).toContain("operator_package_tool"); + expect(request.system).toContain("OPERATOR-PACKAGE-SKILL-MARKER"); + expect(result.requests.at(-1)!.toolResults.join("\n")).toContain("operator-package-workflow"); + expect(result.workerRequests[0]!.toolNames).not.toContain("operator_package_tool"); + let plain = await run(true, "workflows"); + expect(plain.requests[0]!.toolNames).not.toContain("operator_package_tool"); + expect(plain.requests.at(-1)!.toolResults.join("\n")).not.toContain("operator-package-workflow"); +}); + test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { let room = await hostInputRoom(); try { diff --git a/apps/server/src/harness/harnesses.test.ts b/apps/server/src/harness/harnesses.test.ts index 95360422..1751c071 100644 --- a/apps/server/src/harness/harnesses.test.ts +++ b/apps/server/src/harness/harnesses.test.ts @@ -1,4 +1,6 @@ import { afterEach, expect, it } from "bun:test"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { harnesses, harnessFor, registerCredential, shutdownHarnesses } from "./harnesses"; @@ -66,6 +68,18 @@ it("refuses Atomic's host-login auto mode on a non-loopback bind but accepts ai- .toBe("atomic"); }); +it("accepts only existing absolute extension paths, and only for the atomic harness", () => { + let atomic = { harness: "atomic", harnessAuth: "auto", host: "127.0.0.1" }; + expect(() => harnessFor({ ...atomic, harnessExtensions: ["relative/package"] })) + .toThrow("HARNESS_EXTENSIONS path relative/package must be absolute"); + let missing = join(tmpdir(), `chopin-missing-${crypto.randomUUID()}`); + expect(() => harnessFor({ ...atomic, harnessExtensions: [missing] })) + .toThrow(`HARNESS_EXTENSIONS path ${missing} does not exist`); + expect(() => harnessFor({ harness: "pi", harnessAuth: "auto", harnessExtensions: [tmpdir()] })) + .toThrow("HARNESS_EXTENSIONS requires HARNESS=atomic, not pi"); + expect(harnessFor({ ...atomic, harnessExtensions: [tmpdir()] }).harnessId).toBe("atomic"); +}); + it("registers credentials per session without reuse or rewriting", () => { let token = "session-token"; let release = registerCredential("session-id", () => token); diff --git a/apps/server/src/harness/harnesses.ts b/apps/server/src/harness/harnesses.ts index d8928c26..685456f4 100644 --- a/apps/server/src/harness/harnesses.ts +++ b/apps/server/src/harness/harnesses.ts @@ -1,3 +1,6 @@ +import { existsSync } from "node:fs"; +import { isAbsolute } from "node:path"; + import { ATOMIC_AUTH_MODES, createAtomicAdapter } from "./atomic/adapter"; import { forgetWorkspaces } from "./atomic/workspace"; import { createCopilotSdk } from "./copilot-sdk/adapter"; @@ -18,10 +21,13 @@ function createPiHarness(settings: { auth?: string }): HarnessV1 & { shutdown(): } function createAtomicHarness( - settings: { auth?: string }, + settings: { auth?: string; extensions?: readonly string[] }, ): HarnessV1 & { shutdown(): Promise } { // Like Pi, Atomic takes neither a GitHub credential nor a credit limit. - return createAtomicAdapter({ auth: settings.auth as AtomicAuthMode }); + return createAtomicAdapter({ + auth: settings.auth as AtomicAuthMode, + extensions: settings.extensions, + }); } export const harnesses = { @@ -34,6 +40,7 @@ export const harnesses = { credentials: (id: string) => string | undefined; limits: (id: string) => { maxAiCredits: number } | undefined; auth?: string; + extensions?: readonly string[]; }) => HarnessV1 >; @@ -59,14 +66,28 @@ const AUTH_MODES = new Map>([ ["atomic", new Set(ATOMIC_AUTH_MODES)], ]); +/** Only the atomic Planner loads operator extensions, each an existing absolute path. */ +function checkedExtensions(harness: string, extensions: readonly string[] = []): readonly string[] { + if (extensions.length && harness !== "atomic") { + throw new Error(`HARNESS_EXTENSIONS requires HARNESS=atomic, not ${harness}`); + } + for (let path of extensions) { + if (!isAbsolute(path)) throw new Error(`HARNESS_EXTENSIONS path ${path} must be absolute`); + if (!existsSync(path)) throw new Error(`HARNESS_EXTENSIONS path ${path} does not exist`); + } + return extensions; +} + export function harnessFor(config: { harness: string; harnessAuth?: string; + harnessExtensions?: readonly string[]; host?: string; }): HarnessV1 { if (!Object.hasOwn(harnesses, config.harness)) { throw new Error(`Unknown harness: ${config.harness}`); } + let extensions = checkedExtensions(config.harness, config.harnessExtensions); let auth = config.harnessAuth; let isLoopback = loopback(config.host ?? "127.0.0.1"); let modes = AUTH_MODES.get(config.harness); @@ -94,6 +115,7 @@ export function harnessFor(config: { return maxAiCredits === undefined ? undefined : { maxAiCredits }; }, auth, + extensions, }); } diff --git a/apps/server/src/testing/config.ts b/apps/server/src/testing/config.ts index 22c62468..f08f1dde 100644 --- a/apps/server/src/testing/config.ts +++ b/apps/server/src/testing/config.ts @@ -26,6 +26,7 @@ export function configured(overrides: Record = {}) { WEB_RESEARCH: undefined, HARNESS: undefined, HARNESS_AUTH: undefined, + HARNESS_EXTENSIONS: undefined, AUTH_MODE: undefined, SERVER_HOST: undefined, PORT: undefined, diff --git a/docs/hosted-agent.md b/docs/hosted-agent.md index c144df19..802af973 100644 --- a/docs/hosted-agent.md +++ b/docs/hosted-agent.md @@ -132,7 +132,10 @@ agent directory (including `ATOMIC_CODING_AGENT_DIR` or the legacy context files, and a read-only copy of its settings. A verified checkout's `.atomic/settings.json` is read the same way, as trusted project settings, so packages installed for that project load too; the document's own directory -has none. Chopin's compaction, summary, and cache overrides still apply on top. +has none. Paths in `HARNESS_EXTENSIONS` load in every Planner session as if +passed to Atomic's `--extension` flag, so a package listed there adds its +extensions, skills, and workflows without being installed in the agent +directory. Chopin's compaction, summary, and cache overrides still apply on top. Chopin appends its Planner instructions to Atomic's assembled prompt. Session, settings, and model credentials remain in memory; Atomic's own enabled tools, extensions, MCP diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 40567a2d..bb16caeb 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -307,6 +307,7 @@ the image. | `MODEL` | `gpt-6-luna` | Model requested for hosted agent sessions. Required under `HARNESS=pi` and `HARNESS=atomic`; under `atomic` it must be `provider/model` from Atomic's catalog. | | `HARNESS` | `copilot-sdk` | Adapter name selected from Chopin's harness map (`copilot-sdk`, `pi`, or `atomic`). An unknown name refuses at startup. `atomic` gives every Planner session shell and filesystem access as the server process's user, hosted instances included; see [Choose and trust a harness](#choose-and-trust-a-harness). | | `HARNESS_AUTH` | unset | Auth mode forwarded to the selected adapter. For `copilot-sdk`, `direct` and `ai-gateway` are allowed on any bind and `auto` requires a loopback-only `SERVER_HOST`; the adapter does not otherwise consume it. For `pi`, it is required: `auto`, `openai`, `anthropic`, and `custom` require a loopback-only `SERVER_HOST`, only `ai-gateway` is allowed otherwise, and `direct` is always refused. For `atomic`, it is required and must be `auto`, which requires a loopback-only `SERVER_HOST`, or `ai-gateway`; every other value is refused. | +| `HARNESS_EXTENSIONS` | unset | Extension or package paths every atomic Planner session loads, as Atomic's `--extension` flag would, separated by the platform path delimiter (`:`, or `;` on Windows). A package's extensions, skills, and workflows all register. Each path must be absolute and exist; any other harness refuses the variable at startup. Background workers never load them. The code runs in the server process as its user. | | `AGENT` | on | Set exactly `off` to prevent hosted agent turns, disable the entire background-job runner, and avoid Copilot CLI startup. | | `BACKGROUND_JOBS` | on | Set exactly `off` to disable background job scheduling. `AGENT=off` disables the entire runner. | | `WEB_RESEARCH` | on | Set exactly `off` to disable new public-web research while retaining durable requests, artifacts, and other jobs. | From c709f429ace2be9fcb2bc5a4a7a181a54294a65c Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 13:41:31 -0700 Subject: [PATCH 2/3] Give the operator-extension Planner tests time to start the workflow backend CI timed out the operator-extension test at Bun's 5 s default. Its first workflow tool call starts Atomic's durable backend, and without Postgres that falls back to the in-memory backend only after several seconds; with no Postgres runtime and no Docker, the call alone took 4.7 s locally. The test is split in two, and both tests that call the workflow tool get 30 s. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: bun test failed before this change on CI: operator extension paths test timed out after 5000 ms Assistant-verification: bun test passed: full.test.ts with PATH lacking docker and ATOMIC_POSTGRES_RUNTIME_DIR=/nonexistent (15 pass), and the whole suite (1908 pass, 2 skip, 0 fail) Assistant-verification: bun run types and bun run ci passed: no new oxlint warnings, no new Impeccable findings Co-authored-by: Alex Lavaee --- apps/server/src/harness/atomic/full.test.ts | 26 ++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/server/src/harness/atomic/full.test.ts b/apps/server/src/harness/atomic/full.test.ts index 0a14ad2e..d1d99a1c 100644 --- a/apps/server/src/harness/atomic/full.test.ts +++ b/apps/server/src/harness/atomic/full.test.ts @@ -331,17 +331,27 @@ test("a checkout's project settings add its packages without writing either sett expect(plain.requests[0]!.toolNames).not.toContain("project_tool"); }); -test("operator extension paths add a package's tools, skills, and workflows to Planner sessions only", async () => { - let result = await run(true, "workflows", { operatorPackage: true, worker: true }); - let request = result.requests[0]!; - expect(request.toolNames).toContain("operator_package_tool"); - expect(request.system).toContain("OPERATOR-PACKAGE-SKILL-MARKER"); - expect(result.requests.at(-1)!.toolResults.join("\n")).toContain("operator-package-workflow"); - expect(result.workerRequests[0]!.toolNames).not.toContain("operator_package_tool"); +/** The first workflow tool call starts Atomic's durable backend, which falls back slowly without Postgres. */ +const WORKFLOW_TOOL_TIMEOUT_MS = 30_000; + +test( + "operator extension paths add a package's tools, skills, and workflows to Planner sessions only", + async () => { + let result = await run(true, "workflows", { operatorPackage: true, worker: true }); + let request = result.requests[0]!; + expect(request.toolNames).toContain("operator_package_tool"); + expect(request.system).toContain("OPERATOR-PACKAGE-SKILL-MARKER"); + expect(result.requests.at(-1)!.toolResults.join("\n")).toContain("operator-package-workflow"); + expect(result.workerRequests[0]!.toolNames).not.toContain("operator_package_tool"); + }, + WORKFLOW_TOOL_TIMEOUT_MS, +); + +test("without operator extension paths, Planner sessions have none of that package", async () => { let plain = await run(true, "workflows"); expect(plain.requests[0]!.toolNames).not.toContain("operator_package_tool"); expect(plain.requests.at(-1)!.toolResults.join("\n")).not.toContain("operator-package-workflow"); -}); +}, WORKFLOW_TOOL_TIMEOUT_MS); test("free-text Decisions answers to multi-select and preview questions reach the model", async () => { let room = await hostInputRoom(); From 11552cc82ed4f27121003ae61ea38973c6985a65 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 1 Oct 2026 16:41:37 -0700 Subject: [PATCH 3/3] Show in the composer whether a message reaches the Planner or only the room A message without @chopin is sent as a room message, and the composer gave no sign of it, so a member who expected a Planner reply got silence. The composer now says, below the draft and before anything is sent, "Sends to the Planner, which will reply" or "Room only. Add @chopin to ask the Planner". The cue comes from the same chatSendPayload prediction the send uses, so it follows references and an off Planner (no cue) exactly as the wire destination will. The @chopin addressing rule and addressed() are unchanged, and the Send button keeps its name. Assistant-workflow: goal (run c99248c7-a6cb-4858-95f9-5039c865f0b6) Assistant-model: Claude Sonnet 5.5 Assistant-verification: bun test passed: apps/web 355 pass, 0 fail, including destinationCue cases for room, planner, reference-masked mention, empty draft and Planner off Assistant-verification: bun run types and bun run ci passed: dprint, oxlint (7 existing warnings, none new), tokens, design contract, Impeccable (no new findings) Assistant-verification: playwright e2e passed: bun run e2e e2e/harness.e2e.ts, 2 passed including the composer destination cue test Co-authored-by: Alex Lavaee --- apps/web/src/chat/chat.tsx | 22 +++++++++++++++++++++- apps/web/src/chat/references.test.ts | 27 +++++++++++++++++++++++++++ apps/web/src/chat/references.ts | 18 ++++++++++++++++++ e2e/harness.e2e.ts | 13 +++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/apps/web/src/chat/chat.tsx b/apps/web/src/chat/chat.tsx index 70f2cc72..16221cd3 100644 --- a/apps/web/src/chat/chat.tsx +++ b/apps/web/src/chat/chat.tsx @@ -26,6 +26,7 @@ import { beforeInputSelection, boundedChatError, chatSendPayload, + destinationCue, insertReference, MAX_REFERENCES, prepareDraftSubmission, @@ -107,6 +108,7 @@ export function Chat( draftRef.current = draft; let pickerId = useId(); let instructionsId = useId(); + let cueId = useId(); let synchronized = useRef(undefined); let activity = useRef(onActivity); let reportedBusy = useRef(false); @@ -239,6 +241,10 @@ export function Chat( setDismissedPicker(undefined); }; + let cue = sendError + ? undefined + : destinationCue(draft.text, draft.references, agent, referencesEnabled); + let submit = () => { if (submission.current || !composerReady || !wire) return; let current = draftRef.current; @@ -339,7 +345,11 @@ export function Chat( : undefined} aria-autocomplete={referencesEnabled ? "list" : undefined} aria-controls={pickerOpen ? pickerId : undefined} - aria-describedby={referencesEnabled ? instructionsId : undefined} + aria-describedby={[ + referencesEnabled ? instructionsId : undefined, + cue ? cueId : undefined, + ] + .filter(Boolean).join(" ") || undefined} aria-disabled={!composerReady || submitting} aria-expanded={referencesEnabled ? pickerOpen : undefined} aria-haspopup={referencesEnabled ? "listbox" : undefined} @@ -443,6 +453,16 @@ export function Chat( {sendError} )} + {cue && ( +

+ {cue.text} +

+ )} {agent && (busy || counts.active > 0) && (