Chat: one phone header button, quick-prompt chips, double-click to reuse a message
The phone header carries a single speech bubble: Control on Home, the assistant elsewhere. Chips above the input offer the user's most sent messages per tab, then starters; a tap or a double-click on a sent or heard message fills the input without sending.
This commit is contained in:
@@ -65,6 +65,11 @@ yours.
|
||||
`MCP_ENABLED` is only needed for external clients, so the built-in assistant
|
||||
works without it.
|
||||
|
||||
In both tabs, chips above the input offer quick prompts: the messages you send
|
||||
most often in this browser, then a few starters. A tap puts the text in the
|
||||
input to edit and send. Double-clicking, or double-tapping, one of your own
|
||||
messages does the same.
|
||||
|
||||
## Control
|
||||
|
||||
A live [Wren](../reference/node-types.md#wren) or
|
||||
|
||||
@@ -26,7 +26,8 @@ The sidebar opens as a sheet on phones.
|
||||
| **Settings** | change your account, appearance and remote access |
|
||||
|
||||
**Assistant** appears when a provider is configured. **Documentation** opens
|
||||
the docs in another tab.
|
||||
the docs in another tab. On a phone the header carries one chat button
|
||||
instead: on Home it opens the Control tab, on any other page the assistant.
|
||||
|
||||
### Search
|
||||
|
||||
|
||||
@@ -20,6 +20,14 @@ import {
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type ChatTab, chatStore, useChat } from "./chatStore"
|
||||
import {
|
||||
chips,
|
||||
type History,
|
||||
loadHistory,
|
||||
record,
|
||||
STARTERS,
|
||||
saveHistory,
|
||||
} from "./prompts"
|
||||
import { type ChatEvent, streamTurn } from "./stream"
|
||||
import { type Action, type ControlItem, useControl } from "./useControl"
|
||||
|
||||
@@ -129,6 +137,30 @@ export function ControlTrigger({ className }: { className?: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The phone header's one way into the chat. Home opens the Control tab, where
|
||||
* its conversation is, and any other page the assistant; the panel falls back
|
||||
* to whichever of the two there is.
|
||||
*/
|
||||
export function ChatTrigger({ tab }: { tab: ChatTab }) {
|
||||
const available = useQuery(aiStatusQueryOptions()).data?.available
|
||||
const nodes = useQuery(controlNodesQueryOptions()).data ?? []
|
||||
if (!available && nodes.length === 0) return null
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => chatStore.open(tab)}
|
||||
aria-label="Chat"
|
||||
title="Chat"
|
||||
data-testid="chat-trigger"
|
||||
>
|
||||
<MessageCircle />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/** A read leaves nothing to refetch; anything else may have changed a flow. */
|
||||
const isRead = (name: string) => /^(list|get)_/.test(name)
|
||||
|
||||
@@ -156,8 +188,10 @@ export function ChatPanel() {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [draft, setDraft] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [history, setHistory] = useState<History>(loadHistory)
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
const bottom = useRef<HTMLDivElement>(null)
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
|
||||
const available = Boolean(status.data?.available)
|
||||
const tabs = TABS.filter(([key]) =>
|
||||
@@ -185,9 +219,28 @@ export function ChatPanel() {
|
||||
body?.scrollTo({ top: body.scrollHeight })
|
||||
}, [shown.length, tailLength, waiting])
|
||||
|
||||
/** Counts a sent message toward its tab's quick prompts. */
|
||||
const remember = (which: ChatTab, text: string) => {
|
||||
const next = {
|
||||
...history,
|
||||
[which]: record(history[which], text, Date.now()),
|
||||
}
|
||||
setHistory(next)
|
||||
saveHistory(next)
|
||||
}
|
||||
|
||||
/** Puts a message into the input to edit and send, whatever was there. */
|
||||
const reuse = (text: string) => {
|
||||
setDraft(text)
|
||||
input.current?.focus()
|
||||
// A double-click selects the word under it, which would only get in the way.
|
||||
window.getSelection()?.removeAllRanges()
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const text = draft.trim()
|
||||
if (!text || busy) return
|
||||
remember("build", text)
|
||||
setDraft("")
|
||||
setBusy(true)
|
||||
setItems((prev) => [...prev, { kind: "user", text }])
|
||||
@@ -314,69 +367,93 @@ export function ChatPanel() {
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!controlling) {
|
||||
void send()
|
||||
return
|
||||
}
|
||||
const text = draft.trim()
|
||||
if (!text || control.busy) return
|
||||
setDraft("")
|
||||
void control.send(text)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={draft}
|
||||
placeholder={
|
||||
controlling
|
||||
? "Type a command"
|
||||
: flow
|
||||
? `Ask about ${flow}`
|
||||
: "Ask for a flow, a fix, or an explanation"
|
||||
}
|
||||
aria-label="Message"
|
||||
autoComplete="off"
|
||||
data-testid="chat-input"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
{busy ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => controller.current?.abort()}
|
||||
aria-label="Stop"
|
||||
<div className="grid gap-2">
|
||||
{draft.trim() ? null : (
|
||||
<div
|
||||
className="flex gap-2 overflow-x-auto py-1 -my-1"
|
||||
data-testid="chat-prompts"
|
||||
>
|
||||
<Square />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
disabled={!draft.trim() || waiting}
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
{chips(history[tab], STARTERS[tab]).map((prompt) => (
|
||||
<Button
|
||||
key={prompt}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm-touch"
|
||||
className="max-w-64"
|
||||
onClick={() => reuse(prompt)}
|
||||
data-testid="chat-prompt"
|
||||
>
|
||||
<span className="truncate">{prompt}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<form
|
||||
className="flex items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!controlling) {
|
||||
void send()
|
||||
return
|
||||
}
|
||||
const text = draft.trim()
|
||||
if (!text || control.busy) return
|
||||
remember("control", text)
|
||||
setDraft("")
|
||||
void control.send(text)
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={input}
|
||||
value={draft}
|
||||
placeholder={
|
||||
controlling
|
||||
? "Type a command"
|
||||
: flow
|
||||
? `Ask about ${flow}`
|
||||
: "Ask for a flow, a fix, or an explanation"
|
||||
}
|
||||
aria-label="Message"
|
||||
autoComplete="off"
|
||||
data-testid="chat-input"
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
/>
|
||||
{busy ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => controller.current?.abort()}
|
||||
aria-label="Stop"
|
||||
>
|
||||
<Square />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
disabled={!draft.trim() || waiting}
|
||||
aria-label="Send"
|
||||
>
|
||||
<Send />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{shown.length === 0 && !waiting ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{controlling
|
||||
? "Type a command, like turn on the kitchen light. What was said to the flow and what it did show up here."
|
||||
? "Type a command or pick one below. What was said to the flow and what it did show up here."
|
||||
: "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} />
|
||||
<Message key={index} item={item} onReuse={reuse} />
|
||||
))}
|
||||
{waiting ? (
|
||||
<FluksioLoader className="size-4 text-muted-foreground" />
|
||||
@@ -387,13 +464,59 @@ export function ChatPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
function Message({ item }: { item: Item }) {
|
||||
const DOUBLE_TAP_MS = 300
|
||||
|
||||
/**
|
||||
* What somebody said, which a double-click or a double-tap puts back into the
|
||||
* input. A touch screen gets no `dblclick` from every browser, so two taps in
|
||||
* a moment are read as one as well.
|
||||
*/
|
||||
function Said({
|
||||
text,
|
||||
className,
|
||||
onReuse,
|
||||
}: {
|
||||
text: string
|
||||
className: string
|
||||
onReuse: (text: string) => void
|
||||
}) {
|
||||
const lastTap = useRef(0)
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: a shortcut; the text stays reachable by selecting it.
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] touch-manipulation self-end whitespace-pre-wrap break-words rounded-lg px-3 py-1.5 text-sm",
|
||||
className,
|
||||
)}
|
||||
title="Double-click to reuse"
|
||||
onDoubleClick={() => onReuse(text)}
|
||||
onPointerUp={(event) => {
|
||||
if (event.pointerType !== "touch") return
|
||||
const double = event.timeStamp - lastTap.current < DOUBLE_TAP_MS
|
||||
lastTap.current = double ? 0 : event.timeStamp
|
||||
if (double) onReuse(text)
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Message({
|
||||
item,
|
||||
onReuse,
|
||||
}: {
|
||||
item: Item
|
||||
onReuse: (text: string) => void
|
||||
}) {
|
||||
switch (item.kind) {
|
||||
case "user":
|
||||
return (
|
||||
<div className="max-w-[85%] self-end whitespace-pre-wrap break-words rounded-lg bg-primary px-3 py-1.5 text-sm text-primary-foreground">
|
||||
{item.text}
|
||||
</div>
|
||||
<Said
|
||||
text={item.text}
|
||||
className="bg-primary text-primary-foreground"
|
||||
onReuse={onReuse}
|
||||
/>
|
||||
)
|
||||
case "assistant":
|
||||
return (
|
||||
@@ -409,9 +532,11 @@ function Message({ item }: { item: Item }) {
|
||||
)
|
||||
case "heard":
|
||||
return (
|
||||
<div className="max-w-[85%] self-end whitespace-pre-wrap break-words rounded-lg bg-muted px-3 py-1.5 text-sm text-muted-foreground">
|
||||
{item.text}
|
||||
</div>
|
||||
<Said
|
||||
text={item.text}
|
||||
className="bg-muted text-muted-foreground"
|
||||
onReuse={onReuse}
|
||||
/>
|
||||
)
|
||||
case "reply":
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* The chat's quick prompts: what a sent message does to the ranking, and what
|
||||
* the row shows when there is little or a lot of it.
|
||||
*
|
||||
* Run: `cd frontend && bun run src/components/Chat/prompts.check.ts`
|
||||
*/
|
||||
import assert from "node:assert/strict"
|
||||
|
||||
import { chips, record, type Sent, SHOWN } from "./prompts"
|
||||
|
||||
// Sending the same text again counts it once more, not twice in the list.
|
||||
let sent: Sent[] = []
|
||||
sent = record(sent, "list my flows", 1)
|
||||
sent = record(sent, " list my flows ", 2)
|
||||
assert.deepEqual(sent, [{ text: "list my flows", count: 2, last: 2 }])
|
||||
|
||||
// Most often first; among equals, the one sent last.
|
||||
sent = record(sent, "a", 3)
|
||||
sent = record(sent, "b", 4)
|
||||
assert.deepEqual(
|
||||
sent.map((s) => s.text),
|
||||
["list my flows", "b", "a"],
|
||||
)
|
||||
|
||||
// Nothing, or something too long to read on a chip, is not remembered.
|
||||
assert.equal(record(sent, " ", 5), sent)
|
||||
assert.equal(record(sent, "x".repeat(201), 5), sent)
|
||||
|
||||
// Starters fill what the history leaves, minus any the history already holds.
|
||||
assert.deepEqual(chips(sent, ["b", "s1", "s2"]), [
|
||||
"list my flows",
|
||||
"b",
|
||||
"a",
|
||||
"s1",
|
||||
"s2",
|
||||
])
|
||||
|
||||
// A long history is cut to what the row shows, the best first.
|
||||
const many = Array.from({ length: 12 }, (_, i) => `m${i}`).reduce(
|
||||
(acc, text, i) => record(acc, text, i),
|
||||
[] as Sent[],
|
||||
)
|
||||
const shown = chips(many, ["s1"])
|
||||
assert.equal(shown.length, SHOWN)
|
||||
assert.equal(shown[0], "m11")
|
||||
assert.ok(!shown.includes("s1"))
|
||||
|
||||
// What is kept is capped too, and the least used goes first.
|
||||
let kept: Sent[] = []
|
||||
for (let i = 0; i < 40; i++) kept = record(kept, `n${i}`, i)
|
||||
assert.equal(kept.length, 30)
|
||||
assert.equal(kept[0].text, "n39")
|
||||
|
||||
console.log("prompts: ok")
|
||||
@@ -0,0 +1,72 @@
|
||||
import { safeStorage } from "@/lib/safeStorage"
|
||||
import type { ChatTab } from "./chatStore"
|
||||
|
||||
/** What the chips offer until the user's own messages have taught them better. */
|
||||
export const STARTERS: Record<ChatTab, readonly string[]> = {
|
||||
build: [
|
||||
"Is everything running?",
|
||||
"What failed recently?",
|
||||
"List my flows",
|
||||
"Which devices are offline?",
|
||||
],
|
||||
control: [
|
||||
"heat the living room to 22 degrees",
|
||||
"is it cold outside",
|
||||
"kitchen lights on",
|
||||
"good night",
|
||||
],
|
||||
}
|
||||
|
||||
/** A message the user sent: how often, and when last. */
|
||||
export type Sent = { text: string; count: number; last: number }
|
||||
export type History = Record<ChatTab, Sent[]>
|
||||
|
||||
/** Chips shown, and messages remembered per tab. */
|
||||
export const SHOWN = 8
|
||||
const KEPT = 30
|
||||
/** A pasted log is not a prompt anyone wants a chip for. */
|
||||
const MAX_LENGTH = 200
|
||||
const KEY = "fluksio.chatPrompts"
|
||||
|
||||
/** Most often first, the most recent among equals. */
|
||||
const byRank = (a: Sent, b: Sent) => b.count - a.count || b.last - a.last
|
||||
|
||||
/** The list after `text` was sent at `now`, ranked and capped. */
|
||||
export function record(sent: Sent[], text: string, now: number): Sent[] {
|
||||
const clean = text.trim()
|
||||
if (!clean || clean.length > MAX_LENGTH) return sent
|
||||
const known = sent.some((s) => s.text === clean)
|
||||
const next = known
|
||||
? sent.map((s) =>
|
||||
s.text === clean ? { ...s, count: s.count + 1, last: now } : s,
|
||||
)
|
||||
: [...sent, { text: clean, count: 1, last: now }]
|
||||
return next.sort(byRank).slice(0, KEPT)
|
||||
}
|
||||
|
||||
/** What the row shows: the user's own messages, then starters up to `SHOWN`. */
|
||||
export function chips(sent: Sent[], starters: readonly string[]): string[] {
|
||||
const own = [...sent].sort(byRank).map((s) => s.text)
|
||||
return [...own, ...starters.filter((s) => !own.includes(s))].slice(0, SHOWN)
|
||||
}
|
||||
|
||||
const valid = (s: unknown): s is Sent =>
|
||||
typeof (s as Sent)?.text === "string" &&
|
||||
Number.isFinite((s as Sent).count) &&
|
||||
Number.isFinite((s as Sent).last)
|
||||
|
||||
export function loadHistory(): History {
|
||||
let stored: Partial<Record<ChatTab, unknown>> = {}
|
||||
try {
|
||||
stored = JSON.parse(safeStorage.get(KEY) ?? "{}") ?? {}
|
||||
} catch {
|
||||
// Not ours or cut off: start over.
|
||||
}
|
||||
const list = (tab: ChatTab) =>
|
||||
Array.isArray(stored[tab]) ? (stored[tab] as unknown[]).filter(valid) : []
|
||||
return { build: list("build"), control: list("control") }
|
||||
}
|
||||
|
||||
export function saveHistory(history: History) {
|
||||
safeStorage.set(KEY, JSON.stringify(history))
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import {
|
||||
createFileRoute,
|
||||
Outlet,
|
||||
redirect,
|
||||
useRouterState,
|
||||
} from "@tanstack/react-router"
|
||||
import { Bot } from "lucide-react"
|
||||
|
||||
import {
|
||||
aiStatusQueryOptions,
|
||||
ControlTrigger,
|
||||
} from "@/components/Chat/ChatPanel"
|
||||
import { chatStore } from "@/components/Chat/chatStore"
|
||||
import { ChatTrigger } from "@/components/Chat/ChatPanel"
|
||||
import { ConnectionNotice } from "@/components/Common/ConnectionNotice"
|
||||
import ErrorComponent from "@/components/Common/ErrorComponent"
|
||||
import { Footer } from "@/components/Common/Footer"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import AppSidebar from "@/components/Sidebar/AppSidebar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
@@ -45,8 +38,6 @@ function Layout() {
|
||||
// here rather than only inside the editor.
|
||||
useFlowSocket()
|
||||
const home = useRouterState({ select: (s) => s.location.pathname === "/" })
|
||||
// Gated as the sidebar's entry is: with no provider there is nothing to open.
|
||||
const assistant = useQuery(aiStatusQueryOptions())
|
||||
|
||||
return (
|
||||
<SidebarProvider className="bg-card">
|
||||
@@ -56,25 +47,13 @@ function Layout() {
|
||||
served through a portal: it raises the offline notification. */}
|
||||
<ConnectionNotice />
|
||||
{/* The sidebar carries its own collapse control; a phone has no
|
||||
sidebar on screen to carry it. Home's chat sits opposite, where
|
||||
the brain view has it on a wider screen, and the assistant in the
|
||||
corner, one tap away rather than behind the sidebar sheet. */}
|
||||
sidebar on screen to carry it. The chat sits opposite, one tap
|
||||
away rather than behind the sidebar sheet: Home's conversation
|
||||
with the house, the assistant anywhere else. */}
|
||||
<header className="sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 px-4 md:hidden">
|
||||
<SidebarTrigger className="-ml-1 text-muted-foreground" />
|
||||
<div className="-mr-3 ml-auto flex items-center">
|
||||
{home ? <ControlTrigger className="size-11" /> : null}
|
||||
{assistant.data?.available ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className="text-muted-foreground"
|
||||
onClick={chatStore.toggle}
|
||||
aria-label="Assistant"
|
||||
title="Assistant"
|
||||
>
|
||||
<Bot />
|
||||
</Button>
|
||||
) : null}
|
||||
<ChatTrigger tab={home ? "control" : "build"} />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 p-4 sm:p-6 md:p-8">
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { expect, type Page, test } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* The chat on a phone: its one button in the header, which tab it opens, and
|
||||
* how a message gets back into the input (a double-click or double-tap, a
|
||||
* quick-prompt chip).
|
||||
*
|
||||
* A provider and a wren node are instance state, not something a suite should
|
||||
* set up, so the three endpoints the panel reads are answered here.
|
||||
*/
|
||||
|
||||
test.use({ viewport: { width: 393, height: 851 }, hasTouch: true })
|
||||
|
||||
const SAID = "which flows are running"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/v1/ai/status", (route) =>
|
||||
route.fulfill({ json: { available: true, model: "test" } }),
|
||||
)
|
||||
await page.route("**/api/v1/ai/control", (route) =>
|
||||
route.fulfill({
|
||||
json: [
|
||||
{
|
||||
id: "home.control",
|
||||
flow: "home",
|
||||
title: "Home · Control",
|
||||
prompt_msg: "home.command",
|
||||
watch: [{ msg: "home.command", kind: "text", node: "" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
await page.route("**/api/v1/ai/chat", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? route.fulfill({
|
||||
json: [
|
||||
{ role: "user", content: SAID },
|
||||
{ role: "assistant", content: "Two." },
|
||||
],
|
||||
})
|
||||
: route.fallback(),
|
||||
)
|
||||
})
|
||||
|
||||
const pressed = (page: Page, tab: string) =>
|
||||
page.getByTestId("chat-tabs").getByRole("button", { name: tab })
|
||||
|
||||
test("one chat button opens Control on Home", async ({ page }) => {
|
||||
await page.goto("/")
|
||||
await expect(page.getByTestId("chat-trigger")).toHaveCount(1)
|
||||
// Neither the old assistant button nor Home's wider-screen one is on screen.
|
||||
await expect(page.getByRole("button", { name: "Assistant" })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Chat" })).toHaveCount(1)
|
||||
|
||||
await page.getByTestId("chat-trigger").click()
|
||||
await expect(pressed(page, "Control")).toHaveAttribute("aria-pressed", "true")
|
||||
})
|
||||
|
||||
test("the same button opens the assistant elsewhere", async ({ page }) => {
|
||||
await page.goto("/flows")
|
||||
await page.getByTestId("chat-trigger").click()
|
||||
await expect(pressed(page, "Build")).toHaveAttribute("aria-pressed", "true")
|
||||
})
|
||||
|
||||
test("a message and a chip go back into the input", async ({ page }) => {
|
||||
await page.goto("/flows")
|
||||
await page.getByTestId("chat-trigger").click()
|
||||
const input = page.getByTestId("chat-input")
|
||||
const said = page.getByText(SAID)
|
||||
|
||||
await said.dblclick()
|
||||
await expect(input).toHaveValue(SAID)
|
||||
await expect(input).toBeFocused()
|
||||
// Something typed leaves no room for the row.
|
||||
await expect(page.getByTestId("chat-prompts")).toHaveCount(0)
|
||||
|
||||
await input.fill("")
|
||||
const box = await said.boundingBox()
|
||||
if (!box) throw new Error("the message is not on screen")
|
||||
const x = box.x + box.width / 2
|
||||
const y = box.y + box.height / 2
|
||||
await page.touchscreen.tap(x, y)
|
||||
await page.touchscreen.tap(x, y)
|
||||
await expect(input).toHaveValue(SAID)
|
||||
|
||||
await input.fill("")
|
||||
const chip = page.getByTestId("chat-prompt").first()
|
||||
const text = (await chip.textContent()) ?? ""
|
||||
expect(text).not.toBe("")
|
||||
await chip.click()
|
||||
await expect(input).toHaveValue(text)
|
||||
// A chip fills the input; it does not send.
|
||||
await expect(page.getByText(text)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("what was sent comes back as a chip, the most used first", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/ai/chat", (route) =>
|
||||
route.request().method() === "POST"
|
||||
? route.fulfill({
|
||||
contentType: "text/event-stream",
|
||||
body: 'data: {"type":"done"}\n\n',
|
||||
})
|
||||
: route.fallback(),
|
||||
)
|
||||
await page.goto("/flows")
|
||||
await page.getByTestId("chat-trigger").click()
|
||||
const input = page.getByTestId("chat-input")
|
||||
for (const text of ["once", "twice", "twice"]) {
|
||||
await input.fill(text)
|
||||
// Sending is refused while the last turn is still running.
|
||||
await expect(page.getByRole("button", { name: "Send" })).toBeEnabled()
|
||||
await input.press("Enter")
|
||||
await expect(input).toHaveValue("")
|
||||
}
|
||||
|
||||
await page.reload()
|
||||
await page.getByTestId("chat-trigger").click()
|
||||
const chips = page.getByTestId("chat-prompt")
|
||||
await expect(chips.nth(0)).toHaveText("twice")
|
||||
await expect(chips.nth(1)).toHaveText("once")
|
||||
})
|
||||
Reference in New Issue
Block a user