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:
2026-10-27 17:39:36 +01:00
parent 00cda9d2f1
commit 4cb028de41
7 changed files with 444 additions and 85 deletions
+5
View File
@@ -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
+2 -1
View File
@@ -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
+183 -58
View File
@@ -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")
+72
View File
@@ -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))
}
+5 -26
View File
@@ -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">
+123
View File
@@ -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")
})