diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx
index 7cb26e5bd..8f4b4a7d5 100644
--- a/packages/chat-ui/src/chat-workspace.tsx
+++ b/packages/chat-ui/src/chat-workspace.tsx
@@ -641,7 +641,7 @@ function ChatWorkspaceInner({
activeWorkbenchId,
...(onWorkbenchNotFound !== undefined ? { onWorkbenchNotFound } : {}),
});
- const { threads, rootThreadId, pinnedMessages, refreshFeed } = feed;
+ const { threads, rootThreadId, pinsStatus, refreshFeed } = feed;
const navigation = useThreadNavigation({
tenantId,
@@ -1356,10 +1356,7 @@ function ChatWorkspaceInner({
) : (
<>
{!inThreadView ? (
-
+
) : null}
{openThreadParent !== undefined ? (
diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts
index d103a7e04..3abe740f2 100644
--- a/packages/chat-ui/src/index.ts
+++ b/packages/chat-ui/src/index.ts
@@ -22,6 +22,7 @@ export {
export type { AvatarFill, GeneratedAvatarStyle } from "./avatar-identity";
export { PinnedStrip } from "./pinned-strip";
+export type { PinsStatus } from "./use-workbench-feed";
export {
Composer,
draftAfterSend,
diff --git a/packages/chat-ui/src/pinned-strip.tsx b/packages/chat-ui/src/pinned-strip.tsx
index 300b1e731..b33c44c22 100644
--- a/packages/chat-ui/src/pinned-strip.tsx
+++ b/packages/chat-ui/src/pinned-strip.tsx
@@ -3,11 +3,16 @@
// nothing when there are no pins — an empty strip is chrome nobody
// asked to see, the same "no items, no surface" rule the timeline's own
// empty state follows for the reverse case (see `WorkbenchTimeline`).
+//
+// A load failure is distinct from honest empty (CL-6832): the strip
+// surfaces an alert instead of disappearing. A host with no pins store
+// (`unavailable`) still renders nothing — absent feature, absent chrome.
import { PushPin } from "@corbits/icons";
import type { PinnedMessage } from "./api";
import { CHAT_STRINGS } from "./strings";
+import type { PinsStatus } from "./use-workbench-feed";
/**
* A short, single-line preview of a pinned message's content — its
@@ -28,16 +33,31 @@ function previewOf(item: PinnedMessage): string {
}
export function PinnedStrip({
- items,
+ status,
onJump,
}: {
- readonly items: readonly PinnedMessage[];
+ readonly status: PinsStatus;
/** Scrolls the timeline to the pinned message's own row — the host
* owns nothing here beyond calling this; `PinnedStrip` resolves the
* DOM id itself via `messageDomId`. */
readonly onJump: (messageId: string) => void;
}) {
- if (items.length === 0) return null;
+ if (status.kind === "loading" || status.kind === "unavailable") return null;
+
+ if (status.kind === "error") {
+ return (
+
+ );
+ }
+
+ if (status.items.length === 0) return null;
return (
- {items.map((item) => {
+ {status.items.map((item) => {
const preview = previewOf(item);
return (
{
+ test("no active workbench is still loading", () => {
+ expect(
+ pinsStatusFor({
+ activeWorkbenchId: null,
+ data: undefined,
+ error: null,
+ }),
+ ).toEqual({ kind: "loading" });
+ });
+
+ test("a successful empty list is ready with no items — not an error", () => {
+ expect(
+ pinsStatusFor({
+ activeWorkbenchId: "wb_1",
+ data: [],
+ error: null,
+ }),
+ ).toEqual({ kind: "ready", items: [] });
+ });
+
+ test("a successful list with pins is ready with those items", () => {
+ expect(
+ pinsStatusFor({
+ activeWorkbenchId: "wb_1",
+ data: [PIN],
+ error: null,
+ }),
+ ).toEqual({ kind: "ready", items: [PIN] });
+ });
+
+ test("a load failure is an error with plain-language copy — never coerced to []", () => {
+ const status = pinsStatusFor({
+ activeWorkbenchId: "wb_1",
+ data: undefined,
+ // A non-5xx client error uses the strip's own fallback copy via
+ // describeChatError — the important bit is the kind, not the prose.
+ error: new ChatApiError("The server answered 400 for /pins.", 400),
+ });
+ expect(status).toEqual({
+ kind: "error",
+ message: CHAT_STRINGS.pinnedStripLoadError,
+ });
+ });
+
+ test("a 404 (pins store not wired on this host) is unavailable, not empty ready", () => {
+ expect(
+ pinsStatusFor({
+ activeWorkbenchId: "wb_1",
+ data: undefined,
+ error: new ChatApiError("The server answered 404 for /pins.", 404),
+ }),
+ ).toEqual({ kind: "unavailable" });
+ });
+
+ test("prior successful data wins over a background refetch error", () => {
+ expect(
+ pinsStatusFor({
+ activeWorkbenchId: "wb_1",
+ data: [PIN],
+ error: new ChatApiError("The server answered 500 for /pins.", 500),
+ }),
+ ).toEqual({ kind: "ready", items: [PIN] });
+ });
+
+ test("pending first load with no data and no error is loading", () => {
+ expect(
+ pinsStatusFor({
+ activeWorkbenchId: "wb_1",
+ data: undefined,
+ error: null,
+ }),
+ ).toEqual({ kind: "loading" });
+ });
+});
diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts
index 4b6c2f845..1d4f04d87 100644
--- a/packages/chat-ui/src/strings.ts
+++ b/packages/chat-ui/src/strings.ts
@@ -143,6 +143,7 @@ export const CHAT_STRINGS = {
pinnedStripLabel: "Pinned messages",
pinnedStripEmptyPreview: "Pinned message",
pinnedStripJumpAction: (preview: string) => `Jump to: ${preview}`,
+ pinnedStripLoadError: "Couldn't load pinned messages.",
pendingSendLabel: "Sending…",
pendingSendFailedLabel: "Not sent",
pendingSendRetryAction: "Retry",
diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css
index c27f4b5d9..dd600867b 100644
--- a/packages/chat-ui/src/styles.css
+++ b/packages/chat-ui/src/styles.css
@@ -3156,6 +3156,16 @@
color: var(--foreground);
}
+.chat-pinned-strip-error {
+ color: var(--muted-foreground);
+}
+
+.chat-pinned-strip-error-message {
+ margin: 0;
+ font-size: 0.75rem;
+ color: var(--muted-foreground);
+}
+
.chat-thread-affordance {
display: flex;
align-items: center;
diff --git a/packages/chat-ui/src/use-workbench-feed.ts b/packages/chat-ui/src/use-workbench-feed.ts
index 26fe6b741..b7ea8a90c 100644
--- a/packages/chat-ui/src/use-workbench-feed.ts
+++ b/packages/chat-ui/src/use-workbench-feed.ts
@@ -29,6 +29,7 @@ import type {
Workbench,
WorkbenchThreadRow,
} from "./api";
+import { CHAT_STRINGS } from "./strings";
/** Whether this workbench's mailbox has loaded, and why not if it hasn't.
* The items themselves are a separate question — which slice of the
@@ -49,6 +50,40 @@ export type FeedStatus =
}
| { readonly kind: "ready" };
+/** Whether this workbench's pinned strip has loaded, and why not if it
+ * hasn't (CL-6832). A successful empty list is `ready` with no items; a
+ * read failure is `error`; a 404 (pins store not wired on this host) is
+ * `unavailable` — the same "absent store, absent surface" contract the
+ * wire's own `pinned` field follows. Never coerce a failure into `[]`. */
+export type PinsStatus =
+ | { readonly kind: "loading" }
+ | { readonly kind: "ready"; readonly items: readonly PinnedMessage[] }
+ | { readonly kind: "unavailable" }
+ | { readonly kind: "error"; readonly message: string };
+
+/** Pure fold over the pins query — kept free of React so the empty-vs-error
+ * distinction is unit-testable without mounting the feed hook. */
+export function pinsStatusFor(args: {
+ readonly activeWorkbenchId: string | null;
+ readonly data: readonly PinnedMessage[] | undefined;
+ readonly error: unknown;
+}): PinsStatus {
+ if (args.activeWorkbenchId === null) return { kind: "loading" };
+ // React Query keeps the last successful data through a failed refetch, so
+ // a background failure leaves the strip exactly as it was.
+ if (args.data !== undefined) return { kind: "ready", items: args.data };
+ if (args.error != null) {
+ if (args.error instanceof ChatApiError && args.error.status === 404) {
+ return { kind: "unavailable" };
+ }
+ return {
+ kind: "error",
+ message: describeChatError(args.error, CHAT_STRINGS.pinnedStripLoadError),
+ };
+ }
+ return { kind: "loading" };
+}
+
/** How long a loaded feed counts as fresh. An agent turn emits dozens of
* stream events in under a second; with a stale window every one of them
* after the first is served from cache instead of hitting the hub. */
@@ -63,7 +98,6 @@ const CHAT_FEED_COALESCE_MS = 250;
* hand the memos below a new array identity on every render. */
const NO_THREADS: readonly WorkbenchThreadRow[] = [];
const NO_MESSAGES: readonly MessageItem[] = [];
-const NO_PINNED_MESSAGES: readonly PinnedMessage[] = [];
/** The three reads that make up one workbench's feed. They share a
* prefix so a single `invalidateQueries` refreshes all of them. */
@@ -364,7 +398,9 @@ export function applyStreamPin(
export interface WorkbenchFeed {
readonly threads: readonly WorkbenchThreadRow[];
readonly rootThreadId: string;
- readonly pinnedMessages: readonly PinnedMessage[];
+ /** Pins load state — empty success, error, and unavailable stay distinct
+ * (CL-6832). Prefer this over inventing an empty list on failure. */
+ readonly pinsStatus: PinsStatus;
/** Every message in the workbench, unfiltered. What thread a reader is
* looking at selects a slice of this — see `./thread-feed.ts`. */
readonly loadedMessages: readonly MessageItem[];
@@ -406,20 +442,22 @@ export function useWorkbenchFeed(args: {
});
const pinsQuery = useQuery({
queryKey: chatPinsQueryKey(tenantId, activeWorkbenchId),
- // No `pins` store on this host, or a transient read failure — either
- // way the strip just doesn't show, the same "absent store, absent
- // surface" contract the wire's own `pinned` field follows.
- queryFn: () =>
- listPinnedMessages(tenantId, activeWorkbenchId ?? "").catch(
- () => [] as readonly PinnedMessage[],
- ),
+ queryFn: () => listPinnedMessages(tenantId, activeWorkbenchId ?? ""),
enabled: activeWorkbenchId !== null,
staleTime: CHAT_FEED_STALE_MS,
});
const threads = threadsQuery.data?.items ?? NO_THREADS;
const rootThreadId = threadsQuery.data?.rootThreadId ?? "";
- const pinnedMessages = pinsQuery.data ?? NO_PINNED_MESSAGES;
+ const pinsStatus = useMemo(
+ () =>
+ pinsStatusFor({
+ activeWorkbenchId,
+ data: pinsQuery.data,
+ error: pinsQuery.error,
+ }),
+ [activeWorkbenchId, pinsQuery.data, pinsQuery.error],
+ );
const loadedMessages = messagesQuery.data?.items ?? NO_MESSAGES;
// The parent a thread hangs off comes from the thread itself once it
@@ -499,7 +537,7 @@ export function useWorkbenchFeed(args: {
return {
threads,
rootThreadId,
- pinnedMessages,
+ pinsStatus,
loadedMessages,
feedStatus,
threadsLoaded: threadsQuery.data !== undefined,
diff --git a/packages/chat-ui/test/reactions-and-pins.test.tsx b/packages/chat-ui/test/reactions-and-pins.test.tsx
index 77fa7608f..4e5354001 100644
--- a/packages/chat-ui/test/reactions-and-pins.test.tsx
+++ b/packages/chat-ui/test/reactions-and-pins.test.tsx
@@ -199,14 +199,54 @@ describe("pinned strip", () => {
test("renders nothing with no pinned messages", () => {
const markup = renderToStaticMarkup(
- undefined} />,
+ undefined}
+ />,
);
expect(markup).toBe("");
});
+ test("renders nothing while loading or when pins are unavailable on this host", () => {
+ expect(
+ renderToStaticMarkup(
+ undefined} />,
+ ),
+ ).toBe("");
+ expect(
+ renderToStaticMarkup(
+ undefined}
+ />,
+ ),
+ ).toBe("");
+ });
+
+ test("a load failure renders an error strip — never the honest-empty silence (CL-6832)", () => {
+ const markup = renderToStaticMarkup(
+ undefined}
+ />,
+ );
+ // Static markup escapes the apostrophe; the alert role is the
+ // empty-vs-error distinction the strip must keep.
+ expect(markup).toContain("Couldn't load pinned messages.");
+ expect(markup).toContain('role="alert"');
+ expect(markup).toContain("chat-pinned-strip-error");
+ expect(markup).not.toContain("chat-pinned-strip-item");
+ });
+
test("renders a jump chip previewing the pinned message's text", () => {
const markup = renderToStaticMarkup(
- undefined} />,
+ undefined}
+ />,
);
expect(markup).toContain("important announcement here");
});
@@ -218,7 +258,10 @@ describe("pinned strip", () => {
root = createRoot(container);
await act(async () => {
root?.render(
- jumped.push(id)} />,
+ jumped.push(id)}
+ />,
);
});
diff --git a/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx b/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx
index 1c6d25120..3992dae08 100644
--- a/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx
+++ b/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx
@@ -103,7 +103,8 @@ function stubFetch() {
}
return json({ items: [] });
}
- if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json([]);
+ if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path))
+ return json({ items: [] });
if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({});
if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path))
return json({ items: [] });