From 4586f3688d41e26c98f36ae3fcf8c7473f64f0f9 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 10 Nov 2026 19:39:15 +0100 Subject: [PATCH] The chat stacks above the editor's panels, so an expanded code editor no longer covers it; chat lines are keyed by an id given when each is made instead of their index; make verify captures the chat on both tabs, over the expanded editor, and as a phone's sheet, with the assistant's status and transcript answered by the script --- frontend/scripts/capture-screenshots.mjs | 52 +++++++++++++++++++- frontend/src/components/Chat/ChatPanel.tsx | 56 ++++++++++++++-------- frontend/src/components/Chat/useControl.ts | 19 ++++++-- frontend/src/components/Flow/SidePanel.tsx | 6 ++- 4 files changed, 105 insertions(+), 28 deletions(-) diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index 952446b..d399943 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -87,7 +87,8 @@ for (const theme of ["light", "dark"]) { await page.waitForTimeout(1500) await page.screenshot({ path: `${dir}/app-dashboard.png` }) - await captureFlows(page, dir) + const flow = await captureFlows(page, dir) + await captureChat(page, dir, flow) await captureDashboards(page, dir) await captureDevices(page, dir) await captureMedia(page, dir) @@ -97,7 +98,7 @@ for (const theme of ["light", "dark"]) { await context.close() console.log( - ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel,app-devices,app-device,app-round,app-runs,app-run,app-run-context,app-workers}.png`, + ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-chat*,app-panel,app-devices,app-device,app-round,app-runs,app-run,app-run-context,app-workers}.png`, ) } } @@ -344,4 +345,51 @@ async function captureFlows(page, dir) { }) await page.waitForTimeout(1500) await page.screenshot({ path: `${dir}/app-flow-panel.png` }) + return page.url() +} + +/** + * The chat panel, with no model behind it: the assistant's status and its + * stored conversation are answered here, and nothing is sent. On a desktop, + * the Build and Control tabs under a node panel, then over the expanded code + * editor; on a phone, the sheet the header opens on Home. The Control tab is + * there when a flow has a wren or assistant node, as the Demo does. + */ +async function captureChat(page, dir, flow) { + await page.route("**/api/v1/ai/status", (route) => + route.fulfill({ json: { available: true, model: "llama3.2" } }), + ) + await page.route("**/api/v1/ai/chat", (route) => route.fulfill({ json: [] })) + const chat = page.getByTestId("chat-panel") + + if (page.viewportSize().width < 768) { + await page.goto(`${APP_URL}/`, { waitUntil: "networkidle" }) + await page.getByTestId("chat-trigger").click() + await chat.waitFor({ timeout: 15000 }) + await page.waitForTimeout(1000) + await page.screenshot({ path: `${dir}/app-chat.png` }) + } else { + await page.goto(flow, { waitUntil: "networkidle" }) + await page.locator(".react-flow__node").first().click() + await page.getByTestId("node-panel").waitFor({ timeout: 15000 }) + await page.getByRole("button", { name: "Assistant" }).click() + await chat.waitFor({ timeout: 15000 }) + await page.waitForTimeout(1000) + await page.screenshot({ path: `${dir}/app-chat-build.png` }) + + const control = page.getByTestId("chat-tabs").getByText("Control") + if (await control.count()) { + await control.click() + await page.waitForTimeout(500) + await page.screenshot({ path: `${dir}/app-chat-control.png` }) + } + + const expand = page.getByRole("button", { name: "Expand the editor" }) + if (await expand.count()) { + await expand.click() + await page.waitForTimeout(1000) + await page.screenshot({ path: `${dir}/app-chat-editor.png` }) + } + } + await page.unrouteAll() } diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 55ed93c..36184f0 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -29,7 +29,7 @@ import { saveHistory, } from "./prompts" import { type ChatEvent, streamTurn } from "./stream" -import { type Action, type ControlItem, useControl } from "./useControl" +import { type Action, type ControlItem, nextId, useControl } from "./useControl" /** The renderer arrives with the first answer, not with the app. */ const Markdown = lazy(() => import("./Markdown")) @@ -63,14 +63,16 @@ const TABS: readonly (readonly [ChatTab, string])[] = [ ] type Item = - | { kind: "user" | "assistant" | "error"; text: string } - | { - kind: "tool" - name: string - args?: Record - ok?: boolean - error?: string - } + | ({ id: number } & ( + | { kind: "user" | "assistant" | "error"; text: string } + | { + kind: "tool" + name: string + args?: Record + ok?: boolean + error?: string + } + )) | ControlItem /** The stored transcript, in the shape the live turn appends. */ @@ -80,10 +82,10 @@ function fromTranscript(transcript: ChatItem[]): Item[] { const pending: number[] = [] for (const message of transcript) { if (message.role === "user" && message.content) { - items.push({ kind: "user", text: message.content }) + items.push({ id: nextId(), kind: "user", text: message.content }) } else if (message.role === "assistant") { if (message.content) - items.push({ kind: "assistant", text: message.content }) + items.push({ id: nextId(), kind: "assistant", text: message.content }) for (const call of message.tool_calls ?? []) { const fn = (call.function ?? {}) as { name?: string @@ -96,7 +98,12 @@ function fromTranscript(transcript: ChatItem[]): Item[] { args = undefined } pending.push(items.length) - items.push({ kind: "tool", name: fn.name ?? "tool", args }) + items.push({ + id: nextId(), + kind: "tool", + name: fn.name ?? "tool", + args, + }) } } else if (message.role === "tool") { const index = pending.shift() @@ -249,7 +256,7 @@ export function ChatPanel() { remember("build", text) setDraft("") setBusy(true) - setItems((prev) => [...prev, { kind: "user", text }]) + setItems((prev) => [...prev, { id: nextId(), kind: "user", text }]) const abort = new AbortController() controller.current = abort const onEvent = (event: ChatEvent) => { @@ -261,16 +268,24 @@ export function ChatPanel() { setItems((prev) => { const last = prev[prev.length - 1] if (last?.kind !== "assistant") - return [...prev, { kind: "assistant", text: event.content }] + return [ + ...prev, + { id: nextId(), kind: "assistant", text: event.content }, + ] const text = event.type === "delta" ? last.text + event.content : event.content - return [...prev.slice(0, -1), { kind: "assistant", text }] + return [...prev.slice(0, -1), { ...last, text }] }) break case "tool_call": setItems((prev) => [ ...prev, - { kind: "tool", name: event.name, args: event.arguments }, + { + id: nextId(), + kind: "tool", + name: event.name, + args: event.arguments, + }, ]) break case "tool_result": @@ -290,7 +305,10 @@ export function ChatPanel() { } break case "error": - setItems((prev) => [...prev, { kind: "error", text: event.detail }]) + setItems((prev) => [ + ...prev, + { id: nextId(), kind: "error", text: event.detail }, + ]) break } } @@ -458,8 +476,8 @@ export function ChatPanel() { : "Describe a flow to build, a change to make, or ask what a failure means. Drafts land in the editor for you to publish."}

) : null} - {shown.map((item, index) => ( - + {shown.map((item) => ( + ))} {waiting ? ( diff --git a/frontend/src/components/Chat/useControl.ts b/frontend/src/components/Chat/useControl.ts index 9555ad4..4421e8a 100644 --- a/frontend/src/components/Chat/useControl.ts +++ b/frontend/src/components/Chat/useControl.ts @@ -14,9 +14,15 @@ export type Action = { confidence?: number } -export type ControlItem = +/** A line of the chat, keyed by `id` while the list around it changes. */ +export type ControlItem = { id: number } & ( | { kind: "user" | "heard" | "reply" | "error"; text: string } | { kind: "actions"; actions: Action[] } +) + +let lastId = 0 +/** The `id` of a line just made. */ +export const nextId = () => ++lastId /** The line each kind of message makes. */ const LINE = { partial: "heard", text: "user", reply: "reply" } as const @@ -29,7 +35,7 @@ function added(items: ControlItem[], item: ControlItem): ControlItem[] { const last = items[items.length - 1] // The words heard so far grow in one line, which the final text replaces. if (last?.kind === "heard" && (item.kind === "heard" || item.kind === "user")) - return [...items.slice(0, -1), item] + return [...items.slice(0, -1), { ...item, id: last.id }] // A reply arrives a sentence at a time. if (last?.kind === "reply" && item.kind === "reply") return [ @@ -66,10 +72,11 @@ export function useControl(node: ControlNode | undefined) { ? (value.value as Action[]) : [] // With a reply to come, an empty list says nothing. - if (actions.length > 0 || !replies) add({ kind: "actions", actions }) + if (actions.length > 0 || !replies) + add({ id: nextId(), kind: "actions", actions }) if (!replies) setPending(false) } else if (typeof value.value === "string" && value.value) { - add({ kind: LINE[watch.kind], text: value.value }) + add({ id: nextId(), kind: LINE[watch.kind], text: value.value }) if (watch.kind === "reply") setPending(false) } }) @@ -82,7 +89,7 @@ export function useControl(node: ControlNode | undefined) { const failure = liveStore.getFailure(id) if (!failure || failure.ts <= since) return since = failure.ts - add({ kind: "error", text: failure.error }) + add({ id: nextId(), kind: "error", text: failure.error }) setPending(false) }), ) @@ -98,6 +105,7 @@ export function useControl(node: ControlNode | undefined) { setItems((prev) => [ ...prev, { + id: nextId(), kind: "error", text: "No answer yet. Check that its flow is running.", }, @@ -121,6 +129,7 @@ export function useControl(node: ControlNode | undefined) { setItems((prev) => [ ...prev, { + id: nextId(), kind: "error", text: typeof detail === "string" ? detail : String(error), }, diff --git a/frontend/src/components/Flow/SidePanel.tsx b/frontend/src/components/Flow/SidePanel.tsx index e97ff78..64eb5e5 100644 --- a/frontend/src/components/Flow/SidePanel.tsx +++ b/frontend/src/components/Flow/SidePanel.tsx @@ -401,8 +401,10 @@ export function SidePanel({ data-testid={testId} data-expanded={expanded || undefined} className={cn( - "pointer-events-auto z-20 flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md", - fixed ? "fixed" : "absolute", + "pointer-events-auto flex flex-col overflow-hidden rounded-lg border border-border bg-card/80 shadow-e2 backdrop-blur-md", + // A panel on any screen floats over that screen's own panels: an + // expanded code editor takes the lane it otherwise shares. + fixed ? "fixed z-30" : "absolute z-20", expanded ? // Still a floating surface, given the whole inset: the toolbar // and the flow-name box translate off screen while it is open.