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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,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),
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user