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 `MCP_ENABLED` is only needed for external clients, so the built-in assistant
works without it. 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 ## Control
A live [Wren](../reference/node-types.md#wren) or 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 | | **Settings** | change your account, appearance and remote access |
**Assistant** appears when a provider is configured. **Documentation** opens **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 ### Search
+183 -58
View File
@@ -20,6 +20,14 @@ import {
import { useIsMobile } from "@/hooks/useMobile" import { useIsMobile } from "@/hooks/useMobile"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { type ChatTab, chatStore, useChat } from "./chatStore" 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 ChatEvent, streamTurn } from "./stream"
import { type Action, type ControlItem, useControl } from "./useControl" 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. */ /** A read leaves nothing to refetch; anything else may have changed a flow. */
const isRead = (name: string) => /^(list|get)_/.test(name) const isRead = (name: string) => /^(list|get)_/.test(name)
@@ -156,8 +188,10 @@ export function ChatPanel() {
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)
const [draft, setDraft] = useState("") const [draft, setDraft] = useState("")
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [history, setHistory] = useState<History>(loadHistory)
const controller = useRef<AbortController | null>(null) const controller = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(null) const bottom = useRef<HTMLDivElement>(null)
const input = useRef<HTMLInputElement>(null)
const available = Boolean(status.data?.available) const available = Boolean(status.data?.available)
const tabs = TABS.filter(([key]) => const tabs = TABS.filter(([key]) =>
@@ -185,9 +219,28 @@ export function ChatPanel() {
body?.scrollTo({ top: body.scrollHeight }) body?.scrollTo({ top: body.scrollHeight })
}, [shown.length, tailLength, waiting]) }, [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 send = async () => {
const text = draft.trim() const text = draft.trim()
if (!text || busy) return if (!text || busy) return
remember("build", text)
setDraft("") setDraft("")
setBusy(true) setBusy(true)
setItems((prev) => [...prev, { kind: "user", text }]) setItems((prev) => [...prev, { kind: "user", text }])
@@ -314,69 +367,93 @@ export function ChatPanel() {
</> </>
} }
footer={ footer={
<form <div className="grid gap-2">
className="flex items-center gap-2" {draft.trim() ? null : (
onSubmit={(event) => { <div
event.preventDefault() className="flex gap-2 overflow-x-auto py-1 -my-1"
if (!controlling) { data-testid="chat-prompts"
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"
> >
<Square /> {chips(history[tab], STARTERS[tab]).map((prompt) => (
</Button> <Button
) : ( key={prompt}
<Button type="button"
type="submit" variant="outline"
size="icon" size="sm-touch"
className="shrink-0" className="max-w-64"
disabled={!draft.trim() || waiting} onClick={() => reuse(prompt)}
aria-label="Send" data-testid="chat-prompt"
> >
<Send /> <span className="truncate">{prompt}</span>
</Button> </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"> <div className="flex flex-col gap-2 p-4">
{shown.length === 0 && !waiting ? ( {shown.length === 0 && !waiting ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">
{controlling {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."} : "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> </p>
) : null} ) : null}
{shown.map((item, index) => ( {shown.map((item, index) => (
<Message key={index} item={item} /> <Message key={index} item={item} onReuse={reuse} />
))} ))}
{waiting ? ( {waiting ? (
<FluksioLoader className="size-4 text-muted-foreground" /> <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) { switch (item.kind) {
case "user": case "user":
return ( 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"> <Said
{item.text} text={item.text}
</div> className="bg-primary text-primary-foreground"
onReuse={onReuse}
/>
) )
case "assistant": case "assistant":
return ( return (
@@ -409,9 +532,11 @@ function Message({ item }: { item: Item }) {
) )
case "heard": case "heard":
return ( 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"> <Said
{item.text} text={item.text}
</div> className="bg-muted text-muted-foreground"
onReuse={onReuse}
/>
) )
case "reply": case "reply":
return ( 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 { import {
createFileRoute, createFileRoute,
Outlet, Outlet,
redirect, redirect,
useRouterState, useRouterState,
} from "@tanstack/react-router" } from "@tanstack/react-router"
import { Bot } from "lucide-react"
import { import { ChatTrigger } from "@/components/Chat/ChatPanel"
aiStatusQueryOptions,
ControlTrigger,
} from "@/components/Chat/ChatPanel"
import { chatStore } from "@/components/Chat/chatStore"
import { ConnectionNotice } from "@/components/Common/ConnectionNotice" import { ConnectionNotice } from "@/components/Common/ConnectionNotice"
import ErrorComponent from "@/components/Common/ErrorComponent" import ErrorComponent from "@/components/Common/ErrorComponent"
import { Footer } from "@/components/Common/Footer" import { Footer } from "@/components/Common/Footer"
import { useFlowSocket } from "@/components/Flow/useFlowSocket" import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import AppSidebar from "@/components/Sidebar/AppSidebar" import AppSidebar from "@/components/Sidebar/AppSidebar"
import { Button } from "@/components/ui/button"
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
@@ -45,8 +38,6 @@ function Layout() {
// here rather than only inside the editor. // here rather than only inside the editor.
useFlowSocket() useFlowSocket()
const home = useRouterState({ select: (s) => s.location.pathname === "/" }) 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 ( return (
<SidebarProvider className="bg-card"> <SidebarProvider className="bg-card">
@@ -56,25 +47,13 @@ function Layout() {
served through a portal: it raises the offline notification. */} served through a portal: it raises the offline notification. */}
<ConnectionNotice /> <ConnectionNotice />
{/* The sidebar carries its own collapse control; a phone has no {/* The sidebar carries its own collapse control; a phone has no
sidebar on screen to carry it. Home's chat sits opposite, where sidebar on screen to carry it. The chat sits opposite, one tap
the brain view has it on a wider screen, and the assistant in the away rather than behind the sidebar sheet: Home's conversation
corner, one tap away rather than behind the sidebar sheet. */} 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"> <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" /> <SidebarTrigger className="-ml-1 text-muted-foreground" />
<div className="-mr-3 ml-auto flex items-center"> <div className="-mr-3 ml-auto flex items-center">
{home ? <ControlTrigger className="size-11" /> : null} <ChatTrigger tab={home ? "control" : "build"} />
{assistant.data?.available ? (
<Button
variant="ghost"
size="icon-touch"
className="text-muted-foreground"
onClick={chatStore.toggle}
aria-label="Assistant"
title="Assistant"
>
<Bot />
</Button>
) : null}
</div> </div>
</header> </header>
<main className="flex-1 p-4 sm:p-6 md:p-8"> <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")
})