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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user