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.