From ab60a19f9fb30f9756171eb14e26d2956731d9d3 Mon Sep 17 00:00:00 2001
From: Luka Skugor
Date: Fri, 6 Nov 2026 11:09:03 +0100
Subject: [PATCH] cockpit: chats you can find, each a messenger
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- /chats/ opens the conversation as a page: back to Chats, who
answers, newest at the bottom, the box at the bottom; it is the node chat
of the thread's node (the same component as on node pages), so system
events stay behind Activity and live cards still show
- a sent message shows at once as "Sending…" and the box empties; a refusal
puts the words back with "Not sent: "
- an agent's message longer than six lines folds under "more"
- tests: messenger send and refusal, fold, the thread page; e2e Chats on a
real server at 1440 and 390
- web lint and type-check green again on staging (useFactory, a test)
Co-Authored-By: Claude Opus 5.5
---
tests/e2e_ui/factory_v3/test_v3_chats.py | 60 +++++++++
.../factory/cockpit/ChatThreadPage.tsx | 116 ++++++++++++++++--
.../components/factory/room/RoomComposer.tsx | 8 +-
.../components/factory/room/RoomMessage.tsx | 68 +++++++++-
.../factory/room/RoomThreadView.tsx | 72 +++++++++--
web/src/components/factory/room/roomModel.ts | 3 +-
.../factory/v3/chat/NodeChat.test.tsx | 85 +++++++++++++
.../components/factory/v3/chat/NodeChat.tsx | 46 ++++++-
web/src/hooks/SessionUpdatesProvider.test.tsx | 2 +-
web/src/hooks/useFactory.ts | 4 +-
web/src/lib/factoryApi.ts | 2 +
web/src/pages/factory/Cockpit.test.tsx | 15 +++
web/src/pages/factory/LearnPages.test.tsx | 27 ----
13 files changed, 449 insertions(+), 59 deletions(-)
create mode 100644 tests/e2e_ui/factory_v3/test_v3_chats.py
diff --git a/tests/e2e_ui/factory_v3/test_v3_chats.py b/tests/e2e_ui/factory_v3/test_v3_chats.py
new file mode 100644
index 00000000000..9229814c480
--- /dev/null
+++ b/tests/e2e_ui/factory_v3/test_v3_chats.py
@@ -0,0 +1,60 @@
+"""
+Chats on a real server with the 13:40 seed: every conversation in one list
+(the ones you wrote in first), each opening as a messenger: newest at the
+bottom, the box at the bottom, a sent message shown at once and kept once the
+server has it, system events behind Activity.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+
+import pytest
+from playwright.sync_api import expect
+
+from tests.e2e_ui.factory_v3.conftest import LOAD_MS, Cockpit, Theme
+
+
+@pytest.mark.parametrize(("width", "height"), [(1440, 900), (390, 844)], ids=["1440", "390"])
+def test_chats_lists_your_conversations_and_opens_one_as_a_messenger(
+ open_cockpit: Callable[[int, int, Theme], Cockpit],
+ width: int,
+ height: int,
+) -> None:
+ cockpit = open_cockpit(width, height, "dark")
+ page = cockpit.page
+ cockpit.open_place("chats")
+ rows = page.get_by_test_id("cockpit-chat-row")
+ expect(rows.first).to_be_visible(timeout=LOAD_MS)
+ # The person wrote in #factory: it comes first, with its last line.
+ expect(rows.first).to_have_attribute("data-thread", "factory")
+ expect(rows.first).to_have_attribute("data-mine", "true")
+ expect(rows.first).to_contain_text("Factory chat")
+ rows.first.click()
+ thread = page.get_by_test_id("cockpit-chat-thread")
+ expect(thread).to_be_visible(timeout=LOAD_MS)
+ chat = thread.get_by_test_id("v3-chat")
+ messages = chat.get_by_test_id("room-message-list")
+ expect(messages).to_contain_text("Wave 1 is under way", timeout=LOAD_MS)
+ # System events are not conversation (a live card that needs this person is).
+ for text in ("moved 3 times", "spawned", "INC-", "resolved"):
+ expect(messages).not_to_contain_text(text)
+ expect(chat.get_by_test_id("v3-chat-activity")).to_be_visible()
+ box = chat.get_by_test_id("room-composer-input")
+ box_at = box.bounding_box()
+ assert box_at is not None
+ bottom = height - (56 if width < 768 else 0)
+ assert box_at["y"] + box_at["height"] <= bottom, (box_at, height)
+ box.fill("Is the debit test green yet? (sample)")
+ box.press("Enter")
+ expect(box).to_have_value("")
+ expect(messages).to_contain_text("Is the debit test green yet? (sample)", timeout=LOAD_MS)
+ expect(messages.get_by_test_id("room-sending")).to_have_count(0, timeout=LOAD_MS)
+ # Newest at the bottom: the message just sent is the last one in view.
+ last = messages.locator(":scope > li").last
+ expect(last).to_contain_text("Is the debit test green yet? (sample)")
+ page.get_by_test_id("cockpit-chat-back").click()
+ expect(page.get_by_test_id("cockpit-chat-row").first).to_contain_text(
+ "You: Is the debit test green yet? (sample)", timeout=LOAD_MS
+ )
+ assert cockpit.errors == [], cockpit.errors
diff --git a/web/src/components/factory/cockpit/ChatThreadPage.tsx b/web/src/components/factory/cockpit/ChatThreadPage.tsx
index 31058809fd1..bfcd22f9294 100644
--- a/web/src/components/factory/cockpit/ChatThreadPage.tsx
+++ b/web/src/components/factory/cockpit/ChatThreadPage.tsx
@@ -1,14 +1,114 @@
-// One conversation (`/chats/`): for now, the chat of the node the
-// thread is about, opened on that node's page.
+// One conversation (`/chats/`), a messenger: who you talk to on
+// top, newest at the bottom, the box at the bottom. It is the node chat of
+// the node the thread is about (the same component as on node pages), shown
+// as the page: system events stay behind its Activity line.
-import { Navigate } from "react-router-dom";
+import { useMemo } from "react";
+import { Link } from "@/lib/routing";
+import { cn } from "@/lib/utils";
+import { useRoomThreads } from "../room/roomQueries";
import { useFactoryShell } from "../shell/FactoryShellContext";
-import { CHAT_PARAM } from "../v3/chat/chatLayout";
+import { FOCUS_RING } from "../ui/statusTokens";
+import { NodeChat } from "../v3/chat/NodeChat";
import { chatNodeRef } from "../v3/chat/nodeThread";
-import { nodeHref } from "../v3/nodeRoutes";
+import { type NodeActions, NodeContextProvider } from "../v3/NodeContext";
+import { useNode, usePrefetchNode } from "../v3/nodeQueries";
+import { PRODUCT_REF, nodeHref } from "../v3/nodeRoutes";
+import { useAnswerAsk } from "../v3/nodeSteer";
+import { useSheetActions } from "../v3/sheets/V3Sheets";
+import { chatName, useNodeNames } from "./chats";
+import { chatsHref } from "./cockpitRoutes";
+
+function Loading() {
+ return (
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ );
+}
export function ChatThreadPage({ thread }: { thread: string }) {
- const { factoryId, snapshot } = useFactoryShell();
- const ref = chatNodeRef(thread, { snapshot, sample: false });
- return ;
+ const { factoryId: fid, snapshot } = useFactoryShell();
+ const threads = useRoomThreads(fid, { nodes: true }).data?.data;
+ const view = threads?.find((t) => t.key === thread) ?? null;
+ const ref = view?.node_ref || chatNodeRef(thread, { snapshot, sample: false });
+ const node = useNode(ref || PRODUCT_REF, fid);
+ const names = useNodeNames(fid);
+ const name = view
+ ? chatName(view, snapshot, names)
+ : thread === "factory"
+ ? "Factory chat"
+ : null;
+ const prefetch = usePrefetchNode(fid);
+ const answer = useAnswerAsk(ref, { factoryId: fid });
+ const sheets = useSheetActions();
+ const actions = useMemo(
+ () => ({ ...sheets, factoryId: fid, prefetch, onAnswer: answer }),
+ [answer, fid, prefetch, sheets],
+ );
+ const page = node.data;
+ return (
+
+
+
+ ‹ Chats
+
+
+ {name ?? (
+
+ )}
+ {view?.responder_title && (
+
+ with {view.responder_title}
+
+ )}
+
+ {page && page.node.ref !== PRODUCT_REF && (
+ prefetch(page.node.ref)}
+ className={cn(
+ "shrink-0 text-[12.5px] text-muted-foreground hover:text-foreground hover:underline",
+ FOCUS_RING,
+ )}
+ >
+ Open its page ›
+
+ )}
+
+ {page ? (
+
+
+
+ ) : node.isError ? (
+
+ This chat could not be opened.{" "}
+ void node.refetch()} className="underline">
+ Try again
+
+
+ ) : (
+
+ )}
+
+ );
}
diff --git a/web/src/components/factory/room/RoomComposer.tsx b/web/src/components/factory/room/RoomComposer.tsx
index ea43f0c9c32..9c143c39c66 100644
--- a/web/src/components/factory/room/RoomComposer.tsx
+++ b/web/src/components/factory/room/RoomComposer.tsx
@@ -322,12 +322,16 @@ export function RoomComposer({
}
setSending(true);
setError(null);
+ // The box empties at once (the thread shows the message as sending); a
+ // refusal puts the words back with the server's reason.
+ const typed = text;
+ update("", 0);
try {
await onSend(node?.prepare ? node.prepare(body) : body, roomMentions(body));
- update("", 0);
intent?.run();
} catch (err) {
- setError(err instanceof Error ? err.message : String(err));
+ update(typed, typed.length);
+ setError(`Not sent: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setSending(false);
}
diff --git a/web/src/components/factory/room/RoomMessage.tsx b/web/src/components/factory/room/RoomMessage.tsx
index c6f8c3b2ef3..e49a636092c 100644
--- a/web/src/components/factory/room/RoomMessage.tsx
+++ b/web/src/components/factory/room/RoomMessage.tsx
@@ -6,6 +6,7 @@
* quiet line. Consecutive messages of one author fold under one header.
*/
+import { useLayoutEffect, useRef, useState } from "react";
import { BotIcon, CornerDownRightIcon } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import type { ChatMessageView } from "@/lib/factoryApi";
@@ -76,6 +77,58 @@ function Delivery({ message }: { message: ChatMessageView }) {
);
}
+/** An agent's message taller than this many lines folds under "more". */
+export const FOLD_LINES = 6;
+
+/** An agent's long message: its first lines, and "more" to read the rest in place. */
+function Folded({ children, testId }: { children: React.ReactNode; testId: string }) {
+ const box = useRef(null);
+ const [tall, setTall] = useState(false);
+ const [open, setOpen] = useState(false);
+ useLayoutEffect(() => {
+ const el = box.current;
+ if (!el) return;
+ const measure = () => {
+ const line = Number.parseFloat(getComputedStyle(el).lineHeight) || 21;
+ setTall(el.scrollHeight > line * (FOLD_LINES + 1));
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const watch = new ResizeObserver(measure);
+ watch.observe(el);
+ return () => watch.disconnect();
+ }, []);
+ return (
+
+
+ {children}
+ {tall && !open && (
+
+ )}
+
+ {tall && (
+
setOpen((o) => !o)}
+ aria-expanded={open}
+ className="mt-0.5 cursor-pointer text-xs font-medium text-status-blue-fg hover:underline"
+ >
+ {open ? "less" : "more"}
+
+ )}
+
+ );
+}
+
export function RoomMessage({
message,
sources,
@@ -101,8 +154,10 @@ export function RoomMessage({
@@ -135,7 +190,18 @@ export function RoomMessage({
{replyTo.body}
)}
-
+ {message.author_kind === "agent" ? (
+
+
+
+ ) : (
+
+ )}
+ {message.pending && (
+
+ Sending…
+
+ )}
{message.card && message.card.type !== "system" && (
diff --git a/web/src/components/factory/room/RoomThreadView.tsx b/web/src/components/factory/room/RoomThreadView.tsx
index 039229fba18..e904575d00d 100644
--- a/web/src/components/factory/room/RoomThreadView.tsx
+++ b/web/src/components/factory/room/RoomThreadView.tsx
@@ -134,6 +134,36 @@ function NoteRow({ note, onDismiss }: { note: LocalNote; onDismiss: () => void }
);
}
+/** A message on its way: the viewer's words, marked as sending until the server answers. */
+function pendingMessage(
+ id: string,
+ thread: string,
+ body: string,
+ author: string,
+ after: number,
+): ChatMessageView {
+ return {
+ id,
+ factory_id: "",
+ thread,
+ seq: after + 0.5,
+ author_kind: "human",
+ author,
+ author_title: null,
+ author_role: null,
+ body,
+ mentions: [],
+ refs: [],
+ reply_to: null,
+ card: null,
+ routed: [],
+ auto_reply: false,
+ created_at: Math.floor(Date.now() / 1000),
+ lane: "conversation",
+ pending: true,
+ };
+}
+
function SkeletonRows() {
return (
@@ -268,6 +298,7 @@ export function RoomThreadView({
const here = peopleHere(snapshot.presence, snapshot, thread);
const [notes, setNotes] = useState
([]);
const noteSeq = useRef(0);
+ const pendingSeq = useRef(0);
const [adding, setAdding] = useState<{ title: string | null } | null>(null);
const [charter, setCharter] = useState(null);
const channel = useReadMarkSync();
@@ -383,21 +414,38 @@ export function RoomThreadView({
};
const send = async (body: string, mentions: string[]) => {
- const res = await postRoomMessage(fid, { thread, body, mentions });
- atBottom.current = true;
- queryClient.setQueryData(
- messagesQueryKey(fid, thread, feed),
- (prev) => {
+ // Shown at once as "sending", then swapped for the server's row (or dropped on a refusal).
+ const pendingId = `pending-${(pendingSeq.current += 1)}`;
+ const key = messagesQueryKey(fid, thread, feed);
+ type Pages = typeof messagesQuery.data;
+ const edit = (change: (rows: ChatMessageView[]) => ChatMessageView[]) =>
+ queryClient.setQueryData(key, (prev) => {
if (!prev || prev.pages.length === 0) return prev;
const [first, ...rest] = prev.pages;
- const fresh: ChatMessageView[] = [res.message, ...res.notes].filter(
- (m) => !first!.data.some((d) => d.id === m.id),
- );
- return { ...prev, pages: [{ ...first!, data: [...first!.data, ...fresh] }, ...rest] };
- },
- );
+ return { ...prev, pages: [{ ...first!, data: change(first!.data) }, ...rest] };
+ });
+ atBottom.current = true;
+ edit((rows) => [
+ ...rows,
+ pendingMessage(pendingId, thread, body, snapshot.viewer?.user_id ?? "you", lastSeq(rows)),
+ ]);
+ let res: Awaited>;
+ try {
+ res = await postRoomMessage(fid, { thread, body, mentions });
+ } catch (error) {
+ edit((rows) => rows.filter((m) => m.id !== pendingId));
+ throw error;
+ }
+ atBottom.current = true;
+ edit((rows) => {
+ const kept = rows.filter((m) => m.id !== pendingId);
+ const fresh: ChatMessageView[] = [res.message, ...res.notes].filter(
+ (m) => !kept.some((d) => d.id === m.id),
+ );
+ return [...kept, ...fresh];
+ });
void queryClient.invalidateQueries({ queryKey: threadsQueryKey(fid) });
- void queryClient.invalidateQueries({ queryKey: messagesQueryKey(fid, thread, feed) });
+ void queryClient.invalidateQueries({ queryKey: key });
};
const renderItem = (item: FoldedItem, prev?: FoldedItem): ReactNode => {
diff --git a/web/src/components/factory/room/roomModel.ts b/web/src/components/factory/room/roomModel.ts
index 30b8d91264e..a984677de2d 100644
--- a/web/src/components/factory/room/roomModel.ts
+++ b/web/src/components/factory/room/roomModel.ts
@@ -299,7 +299,8 @@ export function busFilterFor(snapshot: FactorySnapshot, thread: string): { proje
/** The highest seq on screen (what reading the thread marks). */
export function lastSeq(messages: readonly ChatMessageView[]): number {
- return messages.reduce((max, m) => Math.max(max, m.seq), 0);
+ // A message still on its way has no seq of its own yet.
+ return messages.reduce((max, m) => (m.pending ? max : Math.max(max, m.seq)), 0);
}
/** How a message's author reads: a person's name, an agent's title, or "system". */
diff --git a/web/src/components/factory/v3/chat/NodeChat.test.tsx b/web/src/components/factory/v3/chat/NodeChat.test.tsx
index 51d1d4b85dd..2311a5ed397 100644
--- a/web/src/components/factory/v3/chat/NodeChat.test.tsx
+++ b/web/src/components/factory/v3/chat/NodeChat.test.tsx
@@ -675,6 +675,91 @@ describe("cockpit v3 · the node chat column", () => {
});
});
+describe("cockpit v3 · the chat is a messenger", () => {
+ /** Hold every post to the chat until `release` (or refuse it with `status`). */
+ function gated(status = 200) {
+ let release = () => {};
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ const wrap = (f: FakeFetch) => {
+ const held = (async (path: string, init?: RequestInit) => {
+ if (init?.method === "POST" && path.split("?")[0]!.endsWith("/chat")) {
+ await gate;
+ if (status !== 200) {
+ return new Response(
+ JSON.stringify({ error: { code: "forbidden", message: "you may only read here" } }),
+ { status, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ }
+ return f(path, init);
+ }) as FakeFetch;
+ held.posts = f.posts;
+ held.writes = f.writes;
+ fake = held;
+ };
+ return { wrap, release: () => release() };
+ }
+
+ it("shows a sent message at once, marked as sending, and empties the box", async () => {
+ setWindow("desktop");
+ const { wrap, release } = gated();
+ const view = renderChat("assistant-core", { install: wrap });
+ const box = await composer();
+ const user = userEvent.setup();
+ await user.click(box);
+ await user.keyboard("is the debit test green yet{Enter}");
+ expect(box.value).toBe("");
+ const list = screen.getByTestId("room-message-list");
+ await waitFor(() => expect(within(list).getByTestId("room-sending")).toBeInTheDocument());
+ expect(list).toHaveTextContent("is the debit test green yet");
+ release();
+ // The server's row takes its place (this fake does not keep it for the re-read).
+ await waitFor(() =>
+ expect(
+ within(screen.getByTestId("room-message-list")).queryByTestId("room-sending"),
+ ).toBeNull(),
+ );
+ expect(view.fetch.posts.map((p) => p.body)).toEqual([
+ expect.stringContaining("is the debit test green yet"),
+ ]);
+ expect(box.value).toBe("");
+ });
+
+ it("puts a refused message back in the box with the server's reason", async () => {
+ setWindow("desktop");
+ const { wrap, release } = gated(403);
+ renderChat("assistant-core", { install: wrap });
+ const box = await composer();
+ const user = userEvent.setup();
+ await user.click(box);
+ await user.keyboard("ship it{Enter}");
+ release();
+ await waitFor(() => expect(box.value).toBe("ship it"));
+ expect(await screen.findByText(/Not sent: .*read/)).toBeInTheDocument();
+ expect(
+ within(screen.getByTestId("room-message-list")).queryByTestId("room-sending"),
+ ).toBeNull();
+ });
+
+ it("folds an agent's long message under more", async () => {
+ setWindow("desktop");
+ const tall = vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(600);
+ try {
+ renderChat("assistant-core", { install });
+ const list = await screen.findByTestId("room-message-list");
+ const folded = await within(list).findAllByTestId(/^room-fold-/);
+ expect(folded[0]).toHaveAttribute("data-folded", "true");
+ const more = within(list).getAllByRole("button", { name: "more" })[0]!;
+ await userEvent.setup().click(more);
+ expect(folded[0]).toHaveAttribute("data-folded", "false");
+ } finally {
+ tall.mockRestore();
+ }
+ });
+});
+
describe("cockpit v3 · the node chat on a phone", () => {
it("names #factory's Atlas on the pinned box of a sample node", async () => {
setWindow("phone");
diff --git a/web/src/components/factory/v3/chat/NodeChat.tsx b/web/src/components/factory/v3/chat/NodeChat.tsx
index d2d91fd4f59..588ef9c5050 100644
--- a/web/src/components/factory/v3/chat/NodeChat.tsx
+++ b/web/src/components/factory/v3/chat/NodeChat.tsx
@@ -367,18 +367,32 @@ export interface NodeChatProps {
page: NodePage;
/** The page shows sample data: the chat is this factory's own root thread. */
sample?: boolean;
+ /**
+ * `auto`: beside the page from 1180 px, else a pinned box that opens it full
+ * screen. `page`: the conversation is the page (Chats), as tall as the window.
+ */
+ layout?: "auto" | "page";
+ /** The thread to show when it is not the node's own (Chats opens any thread). */
+ threadKey?: string;
}
let localSeq = 0;
-export function NodeChat({ page: here, sample = false }: NodeChatProps) {
+export function NodeChat({
+ page: here,
+ sample = false,
+ layout = "auto",
+ threadKey,
+}: NodeChatProps) {
const { factoryId: fid, snapshot, clockOffsetS } = useFactoryShell();
const actions = useNodeActions();
const slot = useNodeChatSlot();
const navigate = useNavigate();
const location = useLocation();
const [params, setParams] = useSearchParams();
- const beside = useMediaQuery(CHAT_BESIDE_QUERY, true);
+ const asPage = layout === "page";
+ const wide = useMediaQuery(CHAT_BESIDE_QUERY, true);
+ const beside = wide || asPage;
const phone = useIsMobileViewport();
const full = !beside && params.get(CHAT_PARAM) === "1";
const inputRef = useRef(null);
@@ -390,7 +404,8 @@ export function NodeChat({ page: here, sample = false }: NodeChatProps) {
const perNode = usePreflight().data?.node_threads === true;
// With the per-node threads (`?nodes=1`): a child's own thread can have news.
const threads = useRoomThreads(fid, { nodes: perNode }).data?.data ?? null;
- const thread = nodeThread(chat, { snapshot, threads, perNode, sample });
+ const nodeOwn = nodeThread(chat, { snapshot, threads, perNode, sample });
+ const thread = threadKey && threadKey !== nodeOwn.key ? { key: threadKey, about: null } : nodeOwn;
const room = threads?.find((t) => t.key === thread.key) ?? null;
// Who answers the thread the chat actually posts to: the node's own
// responder only when that thread is the node's own (else a borrowed
@@ -720,9 +735,10 @@ export function NodeChat({ page: here, sample = false }: NodeChatProps) {
visible={beside || full}
className="min-h-0 flex-1"
node={{
- title,
+ // Chats names the conversation in its own header; the children's news is the node page's.
+ title: asPage ? : title,
lead,
- top,
+ top: asPage ? null : top,
bottom,
bottomCount: cards.length,
card: renderCard,
@@ -759,6 +775,19 @@ export function NodeChat({ page: here, sample = false }: NodeChatProps) {
);
+ if (asPage) {
+ return (
+
+ {threadView(false, "flex-1")}
+
+ );
+ }
if (beside) return
{threadView(false, "flex-1")} ;
return (
<>
@@ -831,6 +860,13 @@ function ChatColumn({ here, children }: { here: NodePage; children: ReactNode })
);
}
+/** Chats: the conversation fills the window under the page's header. */
+const PAGE_HEIGHT =
+ "calc(100dvh - var(--omnigent-header-height, 0px) - var(--omnigent-inset-top, 0px) - var(--omnigent-inset-bottom, 0px) - 7.5rem)";
+/** A phone also keeps its tab bar and the factory's name above. */
+const PAGE_HEIGHT_PHONE =
+ "calc(100dvh - var(--omnigent-header-height, 0px) - var(--omnigent-inset-top, 0px) - var(--omnigent-inset-bottom, 0px) - 13rem)";
+
const STUCK_HEIGHT =
"calc(100dvh - var(--omnigent-header-height, 0px) - var(--omnigent-inset-top, 0px) - var(--omnigent-inset-bottom, 0px) - 2rem)";
diff --git a/web/src/hooks/SessionUpdatesProvider.test.tsx b/web/src/hooks/SessionUpdatesProvider.test.tsx
index d287474cbf0..09e3660a9b0 100644
--- a/web/src/hooks/SessionUpdatesProvider.test.tsx
+++ b/web/src/hooks/SessionUpdatesProvider.test.tsx
@@ -462,7 +462,7 @@ describe("SessionUpdatesProvider factory_changed frames", () => {
later();
expect(keys()).toContainEqual(["factory-events", "f4f4ba9c"]);
expect(keys()).toContainEqual(["factory-team", "f4f4ba9c"]);
- expect(keys().filter((k) => k[0] === "factory")).toHaveLength(1);
+ expect(keys().filter((k) => k?.[0] === "factory")).toHaveLength(1);
});
it("invalidates the factory snapshot, its events, the factory list and decisions", () => {
diff --git a/web/src/hooks/useFactory.ts b/web/src/hooks/useFactory.ts
index 09c1b89fb30..0f6c28fc005 100644
--- a/web/src/hooks/useFactory.ts
+++ b/web/src/hooks/useFactory.ts
@@ -143,8 +143,8 @@ export function factoryInvalidationKeys(
export const FACTORY_FRAME_MIN_MS = 2_000;
export interface FactoryFrameThrottle {
- push(factoryId: string, scopes: readonly string[] | null | undefined): void;
- dispose(): void;
+ push: (factoryId: string, scopes: readonly string[] | null | undefined) => void;
+ dispose: () => void;
}
/**
diff --git a/web/src/lib/factoryApi.ts b/web/src/lib/factoryApi.ts
index 42bfae95e1c..693c66b840f 100644
--- a/web/src/lib/factoryApi.ts
+++ b/web/src/lib/factoryApi.ts
@@ -1309,6 +1309,8 @@ export interface ChatMessageView {
created_at: number;
/** With `feed=all` (cockpit v3's node chat): what people and agents say, or the system's activity. */
lane?: "conversation" | "activity";
+ /** (client) The viewer's message on its way to the server. */
+ pending?: boolean;
}
/** One Room thread: `factory`, `improvements`, `project:
`, `pr:` or a node's. */
diff --git a/web/src/pages/factory/Cockpit.test.tsx b/web/src/pages/factory/Cockpit.test.tsx
index e69503688b4..013e82464ab 100644
--- a/web/src/pages/factory/Cockpit.test.tsx
+++ b/web/src/pages/factory/Cockpit.test.tsx
@@ -221,6 +221,21 @@ describe("the cockpit · Chats", () => {
});
});
+describe("the cockpit · a chat", () => {
+ it("opens a thread as a messenger page: back to Chats, who answers, the box at the bottom", async () => {
+ serve([thread("factory", { responder_title: "Atlas" })]);
+ renderAt("/chats");
+ fireEvent.click(await screen.findByTestId("cockpit-chat-row"));
+ await waitFor(() => expect(location).toBe(`${BASE}/chats/factory`));
+ const page = await screen.findByTestId("cockpit-chat-thread");
+ expect(within(page).getByRole("heading")).toHaveTextContent(/^Factory chat\s*with Atlas$/);
+ const chat = await within(page).findByTestId("v3-chat");
+ expect(chat).toHaveAttribute("data-layout", "page");
+ expect(await within(chat).findByTestId("room-composer-input")).toBeInTheDocument();
+ expect(within(page).getByTestId("cockpit-chat-back")).toHaveAttribute("href", `${BASE}/chats`);
+ });
+});
+
describe("the cockpit · older addresses", () => {
it.each([
["/projects/db-version", "/n/db-version"],
diff --git a/web/src/pages/factory/LearnPages.test.tsx b/web/src/pages/factory/LearnPages.test.tsx
index 2cfbea48fbf..d58860700bd 100644
--- a/web/src/pages/factory/LearnPages.test.tsx
+++ b/web/src/pages/factory/LearnPages.test.tsx
@@ -89,12 +89,6 @@ function renderAt(path: string) {
);
}
-function sent(method: string, path: string): unknown[] {
- return server.calls
- .filter((c) => c.method === method && c.path.split("?")[0] === path)
- .map((c) => c.body);
-}
-
beforeEach(() => {
authenticatedFetch.mockReset();
resetFactoryControls();
@@ -246,24 +240,3 @@ describe("Improvements", () => {
});
// ── Feedback round trips ───────────────────────────────────────
-
-describe("feedback", () => {
- it("flags a question as unnecessary", async () => {
- serve();
- renderAt("/decisions");
-
- const [button] = await screen.findAllByTestId("decision-unneeded-D-2");
- fireEvent.click(button!);
-
- await waitFor(() =>
- expect(sent("POST", "/v1/factory/feedback")).toEqual([
- expect.objectContaining({
- target_kind: "decision",
- target_id: "a9d44647df3050199c1577ba21f16b67",
- kind: "unneeded",
- project: "db-version",
- }),
- ]),
- );
- });
-});