From 9c88b1edf48a38b9fa31764992f585826e8a560d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 16:43:12 -0700 Subject: [PATCH 1/2] Add tests for CL-6568: honest no-model failures Covers a tenant whose model_provider row has no credential, the failed-turn strip surfacing the server's cause-aware notice text instead of a generic guess, and Retry recovering the original request text so it isn't lost. --- .../chat-ui/test/failed-turn-strip.test.tsx | 77 +++++++++++++++++++ .../test/no-usable-model-banner.test.tsx | 41 ++++++++++ .../src/usable-model.test.ts | 37 +++++++++ 3 files changed, 155 insertions(+) create mode 100644 packages/chat-ui/test/no-usable-model-banner.test.tsx create mode 100644 packages/inference-settings/src/usable-model.test.ts diff --git a/packages/chat-ui/test/failed-turn-strip.test.tsx b/packages/chat-ui/test/failed-turn-strip.test.tsx index be8da2435..3c140f009 100644 --- a/packages/chat-ui/test/failed-turn-strip.test.tsx +++ b/packages/chat-ui/test/failed-turn-strip.test.tsx @@ -133,4 +133,81 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => { expect(container.querySelector(".chat-turn-failed")).toBeNull(); expect(container.querySelector(".chat-bubble")).not.toBeNull(); }); + + test("the expanded detail shows the notice's own cause-aware text, not a generic guess", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const items: MessageItem[] = [ + { + id: "msg_ok", + createdAt: "2026-01-01T00:00:00.000Z", + parts: [{ kind: "text", text: "hi @echo" }], + sender: { name: null, address: "prn_alice@agents.example" }, + }, + { + id: "msg_notice", + createdAt: "2026-01-01T00:00:05.000Z", + parts: [ + { + kind: "text", + text: "I can't reach a model right now — add or check your model key in Settings, then I'll pick this up.", + turnFailed: true, + }, + ], + sender: { name: null, address: "ins_echo1@agents.example" }, + }, + ]; + await act(async () => { + root?.render( + , + ); + }); + + act(() => { + container + ?.querySelector(".chat-turn-failed-disclosure") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const detail = container.querySelector(".chat-turn-failed-detail"); + expect(detail?.textContent).toBe( + "I can't reach a model right now — add or check your model key in Settings, then I'll pick this up.", + ); + // Never the fixed guess this strip used to always show, regardless of cause. + expect(detail?.textContent).not.toBe( + "No reply arrived — the agent may be unavailable.", + ); + }); + + test("Retry hands back the original request text so it isn't lost", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const retried: (string | undefined)[] = []; + await act(async () => { + root?.render( + retried.push(retryText)} + />, + ); + }); + + act(() => { + ( + container?.querySelector(".chat-turn-failed-retry") as HTMLButtonElement + ).click(); + }); + + expect(retried).toEqual(["hi @echo"]); + }); }); diff --git a/packages/chat-ui/test/no-usable-model-banner.test.tsx b/packages/chat-ui/test/no-usable-model-banner.test.tsx new file mode 100644 index 000000000..3ba235387 --- /dev/null +++ b/packages/chat-ui/test/no-usable-model-banner.test.tsx @@ -0,0 +1,41 @@ +// CL-6568: the pre-send banner a tenant with no usable model sees before +// typing into a workbench with an agent in it — never a silently +// disabled composer, always a visible, actionable "Connect a model". +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 { NoUsableModelBanner } from "../src/no-usable-model-banner"; + +let container: HTMLDivElement | null = null; +let root: Root | null = null; + +afterEach(() => { + if (root !== null) act(() => root?.unmount()); + container?.remove(); + container = null; + root = null; +}); + +describe("NoUsableModelBanner", () => { + test("names the gap and offers to connect, never a dead end", async () => { + const connected: number[] = []; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + connected.push(1)} />, + ); + }); + + expect(container.querySelector(".chat-no-model-banner")).not.toBeNull(); + expect(container.textContent).toContain("No model is connected"); + + act(() => { + (container?.querySelector("button") as HTMLButtonElement).click(); + }); + expect(connected).toEqual([1]); + }); +}); diff --git a/packages/inference-settings/src/usable-model.test.ts b/packages/inference-settings/src/usable-model.test.ts new file mode 100644 index 000000000..5b9f4041d --- /dev/null +++ b/packages/inference-settings/src/usable-model.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import type { ModelInfo } from "@intx/types"; +import { hasUsableModel } from "./usable-model"; + +function modelWithOfferings(offeringCount: number): ModelInfo { + return { + id: "model_1", + canonicalName: "claude-opus", + displayName: "Opus", + offerings: Array.from({ length: offeringCount }, (_, index) => ({ + offeringId: `off_${index}`, + providerId: "prov_1", + providerName: "anthropic", + plugin: "anthropic", + priority: index, + capabilities: [], + })), + } as unknown as ModelInfo; +} + +describe("hasUsableModel", () => { + test("false for an empty resolved catalog", () => { + expect(hasUsableModel([])).toBe(false); + }); + + test("false when every model resolved with zero offerings", () => { + // Mirrors a seeded `model_provider` row with no credential: the + // platform's own resolution (`resolveModelSources`) excludes it, so a + // model that somehow carries no offerings is exactly as unusable as + // no model at all — never trust the row's mere presence. + expect(hasUsableModel([modelWithOfferings(0)])).toBe(false); + }); + + test("true once at least one model resolves at least one offering", () => { + expect(hasUsableModel([modelWithOfferings(1)])).toBe(true); + }); +}); From cad7730929ce510a315f8bc0a106f93d47201f62 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 21 Aug 2026 16:43:23 -0700 Subject: [PATCH 2/2] CL-6568: tell people before they type, and don't lie when it fails A tenant's model_provider row seeds with no credential attached, so "a provider row exists" was standing in for "a model can run" - the gap this fixes on three fronts: - hasUsableModel (@corbits/inference-settings) checks the same resolved catalog resolveModelSources acts on at launch, so it's false whenever no offering actually resolves, provider row or not. - The chat composer now shows NoUsableModelBanner and leads into Settings -> AI providers before a person invests a message in a reply that was never coming - the composer stays live throughout. - The failed-turn strip renders postUndeliveredNotice's own cause-aware text (already distinguishing a missing credential from a generic dispatch failure server-side) instead of a fixed "may be unavailable" guess, and Retry now hands the original request text back to the composer via findRetryText instead of doing nothing. --- apps/web/package.json | 1 + apps/web/src/pages/chat-page.tsx | 26 ++++++- bun.lock | 17 ++--- packages/chat-ui/src/chat-workspace.tsx | 41 +++++++++++ packages/chat-ui/src/index.ts | 3 +- .../chat-ui/src/no-usable-model-banner.tsx | 33 +++++++++ packages/chat-ui/src/strings.ts | 3 + packages/chat-ui/src/styles.css | 26 +++++++ packages/chat-ui/src/timeline.tsx | 72 +++++++++++++++++-- packages/inference-settings/src/index.ts | 1 + .../inference-settings/src/usable-model.ts | 15 ++++ 11 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 packages/chat-ui/src/no-usable-model-banner.tsx create mode 100644 packages/inference-settings/src/usable-model.ts diff --git a/apps/web/package.json b/apps/web/package.json index 9d5ce29bf..6095c293b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "@corbits/command-palette": "workspace:*", "@corbits/context-menu": "workspace:*", "@corbits/inbox": "workspace:*", + "@corbits/inference-settings": "workspace:*", "@corbits/insights": "workspace:*", "@corbits/plugins-ui": "workspace:*", "@corbits/preferences": "workspace:*", diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index ffce99151..4a1751b2d 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -7,7 +7,11 @@ import { libraryArtifactPath } from "@corbits/artifact-ui"; import { describeApiError } from "@corbits/api-query"; import { listPrincipals } from "@corbits/settings-ui"; import { ChatWorkspace, fetchWorkbenchBlob, type Part } from "@corbits/chat-ui"; -import { useQueryClient } from "@tanstack/react-query"; +import { + getResolvedCatalog, + hasUsableModel as computeHasUsableModel, +} from "@corbits/inference-settings"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo } from "react"; import { fetchArtifactDetail } from "../api"; @@ -142,6 +146,24 @@ export function ChatPage({ navigate("/plugins"); }, [providerHealthBanner, requestPluginsConnect, navigate]); + // CL-6568: whether this tenant can actually run inference — never + // whether a `model_provider` row merely exists, since seeding mints + // that row with no credential attached. The same resolved-catalog + // read `resolveModelSources` acts on at launch, so a model only + // carries an offering once a real credential backs it. + const resolvedCatalogQuery = useQuery({ + queryKey: ["chat-page", "resolved-catalog", tenantId], + queryFn: () => getResolvedCatalog(tenantId ?? ""), + enabled: tenantId !== null, + }); + const hasUsableModel = + resolvedCatalogQuery.data !== undefined + ? computeHasUsableModel(resolvedCatalogQuery.data) + : undefined; + const handleConnectModel = useCallback(() => { + navigate("/settings/connections"); + }, [navigate]); + // A file part with an `artifactId` links back to a real Library row // (CL-6000) — this always resolves through the Library artifacts read // surface for that id, the same one `LibraryRoute` reads, never raw blob @@ -239,6 +261,8 @@ export function ChatPage({ onOpenArtifact={openArtifact} onOpenArtifactInLibrary={openArtifactInLibrary} onFixConnection={handleFixConnection} + {...(hasUsableModel !== undefined ? { hasUsableModel } : {})} + onConnectModel={handleConnectModel} {...(approvalActions !== undefined ? { approvalActions } : {})} {...(blockResponses !== undefined ? { blockResponses } : {})} {...(connectGithubActions !== undefined ? { connectGithubActions } : {})} diff --git a/bun.lock b/bun.lock index 8b0c64c73..f0522267f 100644 --- a/bun.lock +++ b/bun.lock @@ -146,6 +146,7 @@ "@corbits/context-menu": "workspace:*", "@corbits/icons": "workspace:*", "@corbits/inbox": "workspace:*", + "@corbits/inference-settings": "workspace:*", "@corbits/insights": "workspace:*", "@corbits/plugins-ui": "workspace:*", "@corbits/preferences": "workspace:*", @@ -734,7 +735,7 @@ }, "packages/github-tools": { "name": "@corbits/github-tools", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@intx/agent": "0.3.0", "@intx/types": "0.3.0", @@ -3491,18 +3492,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/bench-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/tasks-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3525,9 +3516,9 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#caa5214", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-caa5214", "sha512-z8DRBFgA4ukM8p29COeaMjfKZYe5jAUF4OBMiaIQFuW592+DGD/y6Ws6SjGlXmR9azkHNWh8oTzjlWlRP24vsQ=="], - "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index a474de8de..b5d966247 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -55,6 +55,7 @@ import { useStreamingReply, typingAgentNames } from "./streaming-reply"; import { useTurnActivity, TurnActivityStrip } from "./turn-activity"; import type { StreamingReplyState } from "./streaming-reply"; import { AgentBadge, WorkbenchTimeline, messageDomId } from "./timeline"; +import { NoUsableModelBanner } from "./no-usable-model-banner"; import type { CurrentUser, PinActions, @@ -407,6 +408,8 @@ function ChatWorkspaceInner({ onWorkbenchNotFound, onBackToWorkbenchList, onSignIn, + hasUsableModel, + onConnectModel, }: { readonly tenantId: string; readonly workbenchId?: string | null; @@ -493,6 +496,18 @@ function ChatWorkspaceInner({ * 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` (`@corbits/inference-settings`) + * against its resolved catalog, never mere `model_provider` row + * presence (CL-6568). `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(() => { @@ -522,6 +537,18 @@ function ChatWorkspaceInner({ const composerRef = useRef(null); + /** Retry on a failed-turn strip: the request text was already + * recovered (`findRetryText`) rather than resent silently — a person + * may have since fixed what broke, or may not want it re-sent + * verbatim, so this hands it back into the composer ready to send + * rather than re-sending on their behalf. */ + const handleRetryFailedTurn = useCallback( + (_item: TimelineMessageItem, retryText?: string) => { + if (retryText !== undefined) composerRef.current?.insertText(retryText); + }, + [], + ); + const feed = useWorkbenchFeed({ tenantId, activeWorkbenchId, @@ -1274,6 +1301,7 @@ function ChatWorkspaceInner({ : {})} reactionActions={reactionActions} pinActions={pinActions} + onRetryFailedTurn={handleRetryFailedTurn} pendingActions={{ onRetry: retryPendingSend, onDiscard: discardPendingSend, @@ -1302,6 +1330,11 @@ function ChatWorkspaceInner({ />
+ {hasUsableModel === false && hasAgentParticipant ? ( + onConnectModel?.()} + /> + ) : null} 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": @@ -1508,6 +1547,8 @@ export function ChatWorkspace({ ? { onBackToWorkbenchList } : {})} {...(onSignIn !== undefined ? { onSignIn } : {})} + {...(hasUsableModel !== undefined ? { hasUsableModel } : {})} + {...(onConnectModel !== undefined ? { onConnectModel } : {})} /> ); case "empty": diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index 47801afdf..77321bb78 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -1,7 +1,8 @@ export { ChatWorkspace } from "./chat-workspace"; export type { TenantResolution, PresenceMember } from "./chat-workspace"; -export { WorkbenchTimeline, messageDomId } from "./timeline"; +export { WorkbenchTimeline, messageDomId, findRetryText } from "./timeline"; +export { NoUsableModelBanner } from "./no-usable-model-banner"; export type { CurrentUser, ReactionActions, diff --git a/packages/chat-ui/src/no-usable-model-banner.tsx b/packages/chat-ui/src/no-usable-model-banner.tsx new file mode 100644 index 000000000..417c05934 --- /dev/null +++ b/packages/chat-ui/src/no-usable-model-banner.tsx @@ -0,0 +1,33 @@ +// CL-6568: the pre-send half of the fix. A tenant whose one seeded +// `model_provider` row carries no credential (the shape seeding always +// leaves behind) can't run inference — the composer stays live (a person +// may still want to leave a note), but this banner says so before they +// invest a long message in a reply that was never coming, and leads +// straight into the connect flow that already works +// (`@corbits/settings-ui`'s `ConnectionsSection`). +import { Button } from "@corbits/react-ui"; +import { WarningCircle } from "@corbits/icons"; +import { CHAT_STRINGS } from "./strings"; + +export function NoUsableModelBanner({ + onConnectModel, +}: { + readonly onConnectModel: () => void; +}) { + return ( +
+
+ ); +} diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 2d173a195..c2b3419b2 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -275,6 +275,9 @@ export const CHAT_STRINGS = { replyTimedOutNotice: "No reply arrived — the agent may be unavailable.", turnFailedTitle: (sender: string) => `${sender} didn't reply`, turnFailedSub: "No reply arrived — the agent may be unavailable.", + noUsableModelBannerText: + "No model is connected yet, so a reply here won't come through.", + noUsableModelBannerAction: "Connect a model", rowMenuLabel: "Conversation actions", rowMenuRename: "Rename", rowMenuPin: "Pin", diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 32efbd189..3d68d5f4d 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -1787,6 +1787,32 @@ box-sizing: border-box; } +/* CL-6568's pre-send banner — a tenant with no usable model sees this + above the composer, before typing a single character, rather than + discovering it only after a reply never arrives. Never disables the + composer beneath it. */ +.chat-no-model-banner { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.5rem; + padding: 0.4rem 0.6rem; + border-radius: var(--ui-radius-md, 0.375rem); + background: color-mix(in srgb, var(--destructive) 8%, transparent); + color: color-mix(in srgb, var(--destructive) 78%, var(--muted-foreground)); + font-size: 0.8125rem; +} + +.chat-no-model-banner svg { + flex-shrink: 0; + width: 1rem; + height: 1rem; +} + +.chat-no-model-banner-text { + flex: 1; +} + .chat-composer { position: relative; flex-shrink: 0; diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index 5b6873123..aa107cc51 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -642,15 +642,32 @@ function EventLine({ */ function FailedTurnStrip({ item, + detailText, + retryText, participants, currentUser, onRetryFailedTurn, onWhatHappenedFailedTurn, }: { readonly item: TimelineMessageItem; + /** The undelivered-turn notice's own text (`postUndeliveredNotice`, + * `@corbits/chat`) — already cause-aware server-side (a missing model + * credential reads "add or check your model key," a generic dispatch + * failure reads "send it again"). Shown as this strip's own detail + * rather than the fixed `turnFailedSub` string, so the person reads + * the real diagnosis instead of a guess. Falls back to `turnFailedSub` + * only when the notice carries no text at all. */ + readonly detailText: string; + /** The original message this turn never answered, recovered by + * `findRetryText` — handed to `onRetryFailedTurn` so Retry has + * something to resend rather than nothing. */ + readonly retryText?: string; readonly participants: readonly ParticipantRecord[]; readonly currentUser: CurrentUser | undefined; - readonly onRetryFailedTurn?: (item: TimelineMessageItem) => void; + readonly onRetryFailedTurn?: ( + item: TimelineMessageItem, + retryText?: string, + ) => void; readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void; }) { const display = senderDisplay(item.sender, participants, currentUser); @@ -666,7 +683,7 @@ function FailedTurnStrip({ variant="ghost" size="sm" className="chat-turn-failed-retry" - onClick={() => onRetryFailedTurn?.(item)} + onClick={() => onRetryFailedTurn?.(item, retryText)} > {CHAT_STRINGS.prThreadRetryAction} @@ -683,13 +700,43 @@ function FailedTurnStrip({ {expanded ? ( - {CHAT_STRINGS.turnFailedSub} + {detailText.length > 0 ? detailText : CHAT_STRINGS.turnFailedSub} ) : null}
); } +/** + * The nearest message before a failed-turn notice sent by someone other + * than the unreachable agent itself — the request that notice answered. + * `postUndeliveredNotice` posts the notice from that agent's own address + * right after the dispatch it was answering, so walking backward from + * the notice to the first message from a different sender finds that + * request without the wire needing to carry an explicit back-reference. + * Undefined when nothing precedes it (a notice at the very top of what's + * loaded) — Retry then has nothing to hand back, same as before this + * existed. + */ +export function findRetryText( + items: readonly TimelineMessageItem[], + failedItem: TimelineMessageItem, +): string | undefined { + const index = items.findIndex((candidate) => candidate.id === failedItem.id); + if (index === -1) return undefined; + for (let i = index - 1; i >= 0; i -= 1) { + const candidate = items[i]; + if (candidate === undefined) continue; + if (candidate.sender.address === failedItem.sender.address) continue; + const text = candidate.parts + .filter((part): part is Part & { kind: "text" } => part.kind === "text") + .map((part) => part.text) + .join(""); + return text.length > 0 ? text : undefined; + } + return undefined; +} + function FallbackPart({ part }: { part: Part }) { return (
@@ -1124,6 +1171,7 @@ function PinToggleButton({ function MessageParts({ item, + items, participants, currentUser, showDayDivider, @@ -1146,6 +1194,10 @@ function MessageParts({ onWhatHappenedFailedTurn, }: { readonly item: TimelineMessageItem; + /** The full timeline, oldest→newest — only read to recover the request + * text a failed-turn notice answered (`findRetryText`), never for + * anything else this component renders. */ + readonly items: readonly TimelineMessageItem[]; readonly participants: readonly ParticipantRecord[]; readonly currentUser: CurrentUser | undefined; readonly showDayDivider: boolean; @@ -1185,7 +1237,10 @@ function MessageParts({ * renders the strip with inert buttons, never hiding the strip * itself: a failed turn stays visible even on a host that wires no * recovery action for it. */ - readonly onRetryFailedTurn?: (item: TimelineMessageItem) => void; + readonly onRetryFailedTurn?: ( + item: TimelineMessageItem, + retryText?: string, + ) => void; readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void; }) { // A message this reader's own composer submitted and the server hasn't @@ -1241,12 +1296,15 @@ function MessageParts({ } const part = group.part; if (part.kind === "text" && part.turnFailed === true) { + const retryText = findRetryText(items, item); return ( void; + readonly onRetryFailedTurn?: ( + item: TimelineMessageItem, + retryText?: string, + ) => void; /** The failed-turn strip's "what happened" action — same undefined * contract as `onRetryFailedTurn`. */ readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void; @@ -1756,6 +1817,7 @@ export function WorkbenchTimeline({ model.offerings.length > 0); +}