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

This commit is contained in:
2026-11-10 19:39:15 +01:00
parent ea5081086f
commit 4586f3688d
4 changed files with 105 additions and 28 deletions
+50 -2
View File
@@ -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()
}
+37 -19
View File
@@ -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<string, unknown>
ok?: boolean
error?: string
}
| ({ id: number } & (
| { kind: "user" | "assistant" | "error"; text: string }
| {
kind: "tool"
name: string
args?: Record<string, unknown>
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."}
</p>
) : null}
{shown.map((item, index) => (
<Message key={index} item={item} onReuse={reuse} />
{shown.map((item) => (
<Message key={item.id} item={item} onReuse={reuse} />
))}
{waiting ? (
<FluksioLoader className="size-4 text-muted-foreground" />
+14 -5
View File
@@ -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),
},
+4 -2
View File
@@ -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.