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
|
`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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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 {
|
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">
|
||||||
|
|||||||
@@ -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