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.{" "} + +

+ ) : ( + + )} +
+ ); } 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 && ( + + )} +
+ ); +} + 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", - }), - ]), - ); - }); -});