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
60 changes: 60 additions & 0 deletions tests/e2e_ui/factory_v3/test_v3_chats.py
Original file line number Diff line number Diff line change
@@ -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
116 changes: 108 additions & 8 deletions web/src/components/factory/cockpit/ChatThreadPage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,114 @@
// One conversation (`/chats/<thread>`): for now, the chat of the node the
// thread is about, opened on that node's page.
// One conversation (`/chats/<thread>`), 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 (
<div aria-busy="true" aria-label="Loading the chat" className="flex flex-col gap-4 py-4">
{[0, 1, 2].map((i) => (
<div key={i} className="flex gap-2">
<div className="size-6 shrink-0 animate-pulse rounded-full bg-muted" />
<div className="flex flex-1 flex-col gap-1.5">
<div className="h-3.5 w-28 animate-pulse rounded bg-muted" />
<div className="h-3.5 w-2/3 animate-pulse rounded bg-muted" />
</div>
</div>
))}
</div>
);
}

export function ChatThreadPage({ thread }: { thread: string }) {
const { factoryId, snapshot } = useFactoryShell();
const ref = chatNodeRef(thread, { snapshot, sample: false });
return <Navigate replace to={`${nodeHref(factoryId, ref)}?${CHAT_PARAM}=1`} />;
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<NodeActions>(
() => ({ ...sheets, factoryId: fid, prefetch, onAnswer: answer }),
[answer, fid, prefetch, sheets],
);
const page = node.data;
return (
<div
data-testid="cockpit-chat-thread"
data-thread={thread}
className="mx-auto flex max-w-[52rem] min-w-0 flex-col"
>
<header className="mb-2 flex min-w-0 items-center gap-3 border-b border-border pb-2">
<Link
to={chatsHref(fid)}
componentId="factory.cockpit.chat.back"
data-testid="cockpit-chat-back"
className={cn("shrink-0 text-[13px] text-status-blue-fg hover:underline", FOCUS_RING)}
>
‹ Chats
</Link>
<h1 className="min-w-0 flex-1 truncate text-[15px] font-semibold">
{name ?? (
<span className="inline-block h-4 w-40 animate-pulse rounded bg-muted align-middle" />
)}
{view?.responder_title && (
<span className="ml-2 text-[12.5px] font-normal text-muted-foreground">
with {view.responder_title}
</span>
)}
</h1>
{page && page.node.ref !== PRODUCT_REF && (
<Link
to={nodeHref(fid, page.node.ref)}
componentId="factory.cockpit.chat.node"
data-testid="cockpit-chat-node"
onMouseEnter={() => prefetch(page.node.ref)}
className={cn(
"shrink-0 text-[12.5px] text-muted-foreground hover:text-foreground hover:underline",
FOCUS_RING,
)}
>
Open its page ›
</Link>
)}
</header>
{page ? (
<NodeContextProvider value={actions}>
<NodeChat page={page} layout="page" threadKey={thread} />
</NodeContextProvider>
) : node.isError ? (
<p role="alert" className="text-sm text-status-red-fg">
This chat could not be opened.{" "}
<button type="button" onClick={() => void node.refetch()} className="underline">
Try again
</button>
</p>
) : (
<Loading />
)}
</div>
);
}
8 changes: 6 additions & 2 deletions web/src/components/factory/room/RoomComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
68 changes: 67 additions & 1 deletion web/src/components/factory/room/RoomMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<HTMLDivElement>(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 (
<div className="min-w-0">
<div
ref={box}
data-testid={testId}
data-folded={tall && !open ? "true" : "false"}
className={cn("relative min-w-0 leading-[1.5]", tall && !open && "overflow-hidden")}
style={tall && !open ? { maxHeight: `${FOLD_LINES * 1.5}em` } : undefined}
>
{children}
{tall && !open && (
<span
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent"
/>
)}
</div>
{tall && (
<button
type="button"
onClick={() => 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"}
</button>
)}
</div>
);
}

export function RoomMessage({
message,
sources,
Expand All @@ -101,8 +154,10 @@ export function RoomMessage({
<li
data-testid={`room-message-${message.seq}`}
data-kind={message.author_kind}
data-pending={message.pending ? "true" : undefined}
className={cn(
"group/msg grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] gap-x-2",
message.pending && "opacity-70",
continued ? "pt-0.5" : "pt-2.5",
)}
>
Expand Down Expand Up @@ -135,7 +190,18 @@ export function RoomMessage({
<span className="min-w-0 truncate">{replyTo.body}</span>
</p>
)}
<RoomText body={message.body} sources={sources} testId={`room-body-${message.seq}`} />
{message.author_kind === "agent" ? (
<Folded testId={`room-fold-${message.seq}`}>
<RoomText body={message.body} sources={sources} testId={`room-body-${message.seq}`} />
</Folded>
) : (
<RoomText body={message.body} sources={sources} testId={`room-body-${message.seq}`} />
)}
{message.pending && (
<p data-testid="room-sending" className="text-xs text-muted-foreground">
Sending…
</p>
)}
{message.card && message.card.type !== "system" && (
<div className="mt-1 min-w-0">
<RoomCard message={message} sources={sources} attached />
Expand Down
72 changes: 60 additions & 12 deletions web/src/components/factory/room/RoomThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div aria-busy="true" aria-label="Loading messages" className="flex flex-col gap-4 p-3">
Expand Down Expand Up @@ -268,6 +298,7 @@ export function RoomThreadView({
const here = peopleHere(snapshot.presence, snapshot, thread);
const [notes, setNotes] = useState<LocalNote[]>([]);
const noteSeq = useRef(0);
const pendingSeq = useRef(0);
const [adding, setAdding] = useState<{ title: string | null } | null>(null);
const [charter, setCharter] = useState<string | null>(null);
const channel = useReadMarkSync();
Expand Down Expand Up @@ -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<typeof messagesQuery.data>(
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<Pages>(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<ReturnType<typeof postRoomMessage>>;
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 => {
Expand Down
Loading
Loading