Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1356,10 +1356,7 @@ function ChatWorkspaceInner({
) : (
<>
{!inThreadView ? (
<PinnedStrip
items={pinnedMessages}
onJump={jumpToMessage}
/>
<PinnedStrip status={pinsStatus} onJump={jumpToMessage} />
) : null}
{openThreadParent !== undefined ? (
<div className="chat-thread-origin-banner">
Expand Down
1 change: 1 addition & 0 deletions packages/chat-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 24 additions & 4 deletions packages/chat-ui/src/pinned-strip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
<div
className="chat-pinned-strip chat-pinned-strip-error"
role="alert"
aria-label={CHAT_STRINGS.pinnedStripLabel}
>
<PushPin className="chat-pinned-strip-icon" aria-hidden="true" />
<p className="chat-pinned-strip-error-message">{status.message}</p>
</div>
);
}

if (status.items.length === 0) return null;

return (
<div
Expand All @@ -47,7 +67,7 @@ export function PinnedStrip({
>
<PushPin className="chat-pinned-strip-icon" aria-hidden="true" />
<div className="chat-pinned-strip-row">
{items.map((item) => {
{status.items.map((item) => {
const preview = previewOf(item);
return (
<button
Expand Down
95 changes: 95 additions & 0 deletions packages/chat-ui/src/pins-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// CL-6832: a pins outage must not read as an honest empty strip. These
// cover the pure status fold `useWorkbenchFeed` applies to the pins
// query — empty success, transient error, and absent-store 404 stay
// distinct kinds.
import { describe, expect, test } from "bun:test";

import { ChatApiError } from "./api";
import { pinsStatusFor } from "./use-workbench-feed";
import type { PinnedMessage } from "./api";
import { CHAT_STRINGS } from "./strings";

const PIN: PinnedMessage = {
id: "m1",
createdAt: "2026-01-01T00:00:00.000Z",
parts: [{ kind: "text", text: "keep this" }],
sender: { name: "Alice", address: "prn_alice@acme.example" },
pinnedBy: "prn_alice",
pinnedAt: "2026-01-01T00:01:00.000Z",
};

describe("pinsStatusFor (CL-6832)", () => {
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" });
});
});
1 change: 1 addition & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 49 additions & 11 deletions packages/chat-ui/src/use-workbench-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. */
Expand All @@ -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. */
Expand Down Expand Up @@ -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[];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -499,7 +537,7 @@ export function useWorkbenchFeed(args: {
return {
threads,
rootThreadId,
pinnedMessages,
pinsStatus,
loadedMessages,
feedStatus,
threadsLoaded: threadsQuery.data !== undefined,
Expand Down
49 changes: 46 additions & 3 deletions packages/chat-ui/test/reactions-and-pins.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,54 @@ describe("pinned strip", () => {

test("renders nothing with no pinned messages", () => {
const markup = renderToStaticMarkup(
<PinnedStrip items={[]} onJump={() => undefined} />,
<PinnedStrip
status={{ kind: "ready", items: [] }}
onJump={() => undefined}
/>,
);
expect(markup).toBe("");
});

test("renders nothing while loading or when pins are unavailable on this host", () => {
expect(
renderToStaticMarkup(
<PinnedStrip status={{ kind: "loading" }} onJump={() => undefined} />,
),
).toBe("");
expect(
renderToStaticMarkup(
<PinnedStrip
status={{ kind: "unavailable" }}
onJump={() => undefined}
/>,
),
).toBe("");
});

test("a load failure renders an error strip — never the honest-empty silence (CL-6832)", () => {
const markup = renderToStaticMarkup(
<PinnedStrip
status={{
kind: "error",
message: "Couldn't load pinned messages.",
}}
onJump={() => undefined}
/>,
);
// Static markup escapes the apostrophe; the alert role is the
// empty-vs-error distinction the strip must keep.
expect(markup).toContain("Couldn&#x27;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(
<PinnedStrip items={pins} onJump={() => undefined} />,
<PinnedStrip
status={{ kind: "ready", items: pins }}
onJump={() => undefined}
/>,
);
expect(markup).toContain("important announcement here");
});
Expand All @@ -218,7 +258,10 @@ describe("pinned strip", () => {
root = createRoot(container);
await act(async () => {
root?.render(
<PinnedStrip items={pins} onJump={(id) => jumped.push(id)} />,
<PinnedStrip
status={{ kind: "ready", items: pins }}
onJump={(id) => jumped.push(id)}
/>,
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] });
Expand Down
Loading