diff --git a/components/agent/AgentChat.tsx b/components/agent/AgentChat.tsx index 6f3ec0bf..af33b256 100644 --- a/components/agent/AgentChat.tsx +++ b/components/agent/AgentChat.tsx @@ -10,6 +10,10 @@ import { BookmarkX, Check, Brain, + Copy, + ThumbsUp, + ThumbsDown, + ArrowDown, } from 'lucide-react' import dynamic from 'next/dynamic' import { Button } from '@/components/ui/button' @@ -110,6 +114,9 @@ export interface ChatMessage { toolCalls?: { tool_use_id: string; name: string; completed?: boolean }[] staged?: StagedOperation[] memoryEvents?: MemoryEvent[] + // Set when the user pressed Stop mid-stream: the partial text stays, with a + // marker so a truncated answer is never mistaken for a complete one. + interrupted?: boolean } // Emitted by run-turn.ts after a successful remember_fact / forget_fact call @@ -250,12 +257,17 @@ export default function AgentChat({ // bottom. Scrolling up to re-read a long answer should NOT yank the user // back on every streaming token. Threshold accounts for sub-pixel rounding. const wasAtBottomRef = useRef(true) + // True when the user has scrolled up AND new content has landed below them. + // Without this, reading back through a long answer while the next one streams + // silently buries the reply: no yank (that would be worse), but a way back. + const [hasUnseenBelow, setHasUnseenBelow] = useState(false) useEffect(() => { const el = scrollerRef.current if (!el) return const onScroll = () => { const distance = el.scrollHeight - (el.scrollTop + el.clientHeight) wasAtBottomRef.current = distance < 64 + if (wasAtBottomRef.current) setHasUnseenBelow(false) } el.addEventListener('scroll', onScroll, { passive: true }) return () => el.removeEventListener('scroll', onScroll) @@ -265,9 +277,20 @@ export default function AgentChat({ if (!el) return if (wasAtBottomRef.current) { el.scrollTop = el.scrollHeight + setHasUnseenBelow(false) + } else { + setHasUnseenBelow(true) } }, [messages]) + function jumpToLatest() { + const el = scrollerRef.current + if (!el) return + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }) + wasAtBottomRef.current = true + setHasUnseenBelow(false) + } + async function startTurn(body: { conversationId: string | null userMessage: string @@ -275,7 +298,10 @@ export default function AgentChat({ // hidden so it never renders as a user bubble (e.g. a rejection correction // fed back into the chat). The caller also skips adding a visible bubble. hidden?: boolean - }): Promise { + // Resolves false when the turn never reached the server (network error or + // a non-2xx), so the caller can hand the user's text back to the composer + // instead of stranding a bubble that was never persisted. + }): Promise { // Abort any in-flight turn before starting a new one: guards against // racing two turns when handleSend is triggered twice fast. activeControllerRef.current?.abort() @@ -307,11 +333,11 @@ export default function AgentChat({ signal, }) } catch (err) { - if (signal.aborted) return + if (signal.aborted) return false setErrorMessage(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte nå assistenten.') setStreaming(false) activeControllerRef.current = null - return + return false } if (!response.ok || !response.body) { @@ -329,7 +355,7 @@ export default function AgentChat({ setErrorMessage(msg) setStreaming(false) activeControllerRef.current = null - return + return false } // Assistant bubble is appended LAZILY: only when the first event that @@ -396,12 +422,24 @@ export default function AgentChat({ activeControllerRef.current = null } } + + // The request reached the server. A mid-stream failure is reported through + // errorMessage and leaves whatever streamed on screen, so it does not count + // as "never sent". + return true } function handleStop() { activeControllerRef.current?.abort() activeControllerRef.current = null setStreaming(false) + // Keep whatever streamed, but mark it: a half-finished answer that looks + // finished is worse than no answer, especially when it stopped mid-figure. + setMessages((prev) => { + const last = prev[prev.length - 1] + if (!last || last.role !== 'assistant' || last.interrupted) return prev + return [...prev.slice(0, -1), { ...last, interrupted: true }] + }) } function handleRegenerate() { @@ -620,7 +658,18 @@ export default function AgentChat({ if (!text || streaming) return setInput('') setMessages((prev) => [...prev, { role: 'user', text }]) - await startTurn({ conversationId, userMessage: text }) + const ok = await startTurn({ conversationId, userMessage: text }) + if (!ok) { + // The turn never reached the server, so nothing was persisted and the + // dangling user bubble would vanish on reload. Put the text back in the + // composer instead of making the user retype it, and drop the bubble so + // what is on screen matches what was actually sent. + setMessages((prev) => { + const last = prev[prev.length - 1] + return last?.role === 'user' && last.text === text ? prev.slice(0, -1) : prev + }) + setInput((current) => (current.length > 0 ? current : text)) + } } // Auto-resize the textarea as the user types. Capped at 8rem (~128px) so @@ -646,6 +695,10 @@ export default function AgentChat({ return (
+ {/* The pill is positioned against THIS box, not the whole component: the + composer below grows as the user types, and a fixed offset from the + bottom would slide the pill under it. */} +
+ {hasUnseenBelow && ( + + )} +
+ {/* Paywall: /api/agent/invoke 403s without the ai capability. Replace the composer with an upsell so an already-open conversation (or a deep link to /chat/*) never offers an input that can't send. */} @@ -771,7 +836,9 @@ function MessageBubble({ const hideEmptyBubble = (!isUser && !message.text && !streamingTail) || isThinking const markdownLoaded = useMarkdownReady() return ( -
+
{!isUser && message.reasoning && ( )} @@ -803,6 +870,12 @@ function MessageBubble({
)} + {message.interrupted && ( +

+ Avbrutet. Det som hann skrivas står kvar. +

+ )} + {message.toolCalls && message.toolCalls.length > 0 && (
{message.toolCalls.map((tc) => ( @@ -863,13 +936,81 @@ function MessageBubble({
)} - {showRegenerate && onRegenerate && ( -
+ ) +} + +/** + * Hover row under a finished assistant answer: copy, feedback, regenerate. + * + * Feedback is deliberately fire-and-forget and local-only for now: the point of + * this row is that the affordances exist where users look for them. Wiring the + * thumbs to gnubok_feedback is a follow-up, and a failed vote must never + * interrupt reading an answer. + */ +function MessageActions({ + text, + onRegenerate, +}: { + text: string + onRegenerate?: () => void +}) { + const [copied, setCopied] = useState(false) + const [vote, setVote] = useState<'up' | 'down' | null>(null) + + useEffect(() => { + if (!copied) return + const t = setTimeout(() => setCopied(false), 2000) + return () => clearTimeout(t) + }, [copied]) + + async function handleCopy() { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + } catch { + // Clipboard can be blocked (permissions, insecure context). Silent: the + // user can still select the text, and an error toast here would be noise. + } + } + + const btn = + 'inline-flex items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] text-muted-foreground hover:bg-secondary hover:text-foreground transition-colors' + + return ( +
+ + + + {onRegenerate && ( + diff --git a/components/agent/__tests__/message-anatomy.test.ts b/components/agent/__tests__/message-anatomy.test.ts new file mode 100644 index 00000000..ec02b62d --- /dev/null +++ b/components/agent/__tests__/message-anatomy.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import type { ChatMessage } from '../AgentChat' + +/** + * The pure state transitions behind PR4's message anatomy. The rendering is + * covered by a visual pass (this repo has no component tests), but the state + * rules are exactly where the regressions would hide, so they are pinned here. + */ + +/** Mirrors handleStop: mark the last assistant turn as interrupted, once. */ +function markInterrupted(prev: ChatMessage[]): ChatMessage[] { + const last = prev[prev.length - 1] + if (!last || last.role !== 'assistant' || last.interrupted) return prev + return [...prev.slice(0, -1), { ...last, interrupted: true }] +} + +/** Mirrors handleSend's recovery: drop the bubble that was never persisted. */ +function dropUnsentBubble(prev: ChatMessage[], text: string): ChatMessage[] { + const last = prev[prev.length - 1] + return last?.role === 'user' && last.text === text ? prev.slice(0, -1) : prev +} + +describe('stop keeps partial content', () => { + it('marks the streaming assistant turn as interrupted', () => { + const out = markInterrupted([ + { role: 'user', text: 'hur gick juli?' }, + { role: 'assistant', text: 'Juli gick 12 procent' }, + ]) + + expect(out[1]!.interrupted).toBe(true) + // The partial text must survive: a truncated answer is still useful, it + // just must not look complete. + expect(out[1]!.text).toBe('Juli gick 12 procent') + }) + + it('is idempotent', () => { + const once = markInterrupted([{ role: 'assistant', text: 'delvis' }]) + expect(markInterrupted(once)).toBe(once) + }) + + it('does not mark a user turn', () => { + const messages: ChatMessage[] = [{ role: 'user', text: 'vänta' }] + expect(markInterrupted(messages)).toBe(messages) + }) + + it('does nothing on an empty thread', () => { + const messages: ChatMessage[] = [] + expect(markInterrupted(messages)).toBe(messages) + }) +}) + +describe('failed send returns the text', () => { + it('removes the bubble that was never persisted', () => { + const out = dropUnsentBubble( + [ + { role: 'assistant', text: 'tidigare svar' }, + { role: 'user', text: 'boka om Circle K' }, + ], + 'boka om Circle K', + ) + + // Nothing reached the server, so nothing was persisted: leaving the bubble + // would show a question that vanishes on the next reload. + expect(out).toHaveLength(1) + expect(out[0]!.role).toBe('assistant') + }) + + it('leaves the thread alone when the last turn is something else', () => { + const messages: ChatMessage[] = [ + { role: 'user', text: 'boka om Circle K' }, + { role: 'assistant', text: 'hann svara' }, + ] + expect(dropUnsentBubble(messages, 'boka om Circle K')).toBe(messages) + }) + + it('only removes the matching text', () => { + const messages: ChatMessage[] = [{ role: 'user', text: 'en annan fråga' }] + expect(dropUnsentBubble(messages, 'boka om Circle K')).toBe(messages) + }) +})