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
+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 (