From 4cb028de4167510a6971b1689afc27869dc34be5 Mon Sep 17 00:00:00 2001 From: stroblme Date: Tue, 27 Oct 2026 17:39:36 +0100 Subject: [PATCH] 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. --- docs/code/agents.md | 5 + docs/interface/index.md | 3 +- frontend/src/components/Chat/ChatPanel.tsx | 241 +++++++++++++----- frontend/src/components/Chat/prompts.check.ts | 54 ++++ frontend/src/components/Chat/prompts.ts | 72 ++++++ frontend/src/routes/_layout.tsx | 31 +-- frontend/tests/chat.spec.ts | 123 +++++++++ 7 files changed, 444 insertions(+), 85 deletions(-) create mode 100644 frontend/src/components/Chat/prompts.check.ts create mode 100644 frontend/src/components/Chat/prompts.ts create mode 100644 frontend/tests/chat.spec.ts diff --git a/docs/code/agents.md b/docs/code/agents.md index ed076ca..308849c 100644 --- a/docs/code/agents.md +++ b/docs/code/agents.md @@ -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 diff --git a/docs/interface/index.md b/docs/interface/index.md index 57e0642..2e09f38 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -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 diff --git a/frontend/src/components/Chat/ChatPanel.tsx b/frontend/src/components/Chat/ChatPanel.tsx index 60924a5..bca0609 100644 --- a/frontend/src/components/Chat/ChatPanel.tsx +++ b/frontend/src/components/Chat/ChatPanel.tsx @@ -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 ( + + ) +} + /** 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(loadHistory) const controller = useRef(null) const bottom = useRef(null) + const input = useRef(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={ -
{ - event.preventDefault() - if (!controlling) { - void send() - return - } - const text = draft.trim() - if (!text || control.busy) return - setDraft("") - void control.send(text) - }} - > - setDraft(event.target.value)} - /> - {busy ? ( - - ) : ( - + {chips(history[tab], STARTERS[tab]).map((prompt) => ( + + ))} + )} -
+
{ + event.preventDefault() + if (!controlling) { + void send() + return + } + const text = draft.trim() + if (!text || control.busy) return + remember("control", text) + setDraft("") + void control.send(text) + }} + > + setDraft(event.target.value)} + /> + {busy ? ( + + ) : ( + + )} +
+ } >
{shown.length === 0 && !waiting ? (

{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."}

) : null} {shown.map((item, index) => ( - + ))} {waiting ? ( @@ -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. +
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} +
+ ) +} + +function Message({ + item, + onReuse, +}: { + item: Item + onReuse: (text: string) => void +}) { switch (item.kind) { case "user": return ( -
- {item.text} -
+ ) case "assistant": return ( @@ -409,9 +532,11 @@ function Message({ item }: { item: Item }) { ) case "heard": return ( -
- {item.text} -
+ ) case "reply": return ( diff --git a/frontend/src/components/Chat/prompts.check.ts b/frontend/src/components/Chat/prompts.check.ts new file mode 100644 index 0000000..5a93512 --- /dev/null +++ b/frontend/src/components/Chat/prompts.check.ts @@ -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") diff --git a/frontend/src/components/Chat/prompts.ts b/frontend/src/components/Chat/prompts.ts new file mode 100644 index 0000000..0600f2c --- /dev/null +++ b/frontend/src/components/Chat/prompts.ts @@ -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 = { + 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 + +/** 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> = {} + 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)) +} diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index fa27657..b395b86 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -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 ( @@ -56,25 +47,13 @@ function Layout() { served through a portal: it raises the offline notification. */} {/* 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. */}
- {home ? : null} - {assistant.data?.available ? ( - - ) : null} +
diff --git a/frontend/tests/chat.spec.ts b/frontend/tests/chat.spec.ts new file mode 100644 index 0000000..6aa1fc0 --- /dev/null +++ b/frontend/tests/chat.spec.ts @@ -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") +})