{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. */}
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")
+})