feat(assistant): copy, feedback, an interrupt marker and a way back to the latest answer (#1213)

* feat(assistant): copy, feedback, an interrupt marker and a way back to the latest answer

PR4 of the assistant UI makeover (dev_docs/assistant_redesign_plan.md section 7):
message anatomy and actions.

Assistant turns get a hover action row: copy, thumbs up/down and the existing
regenerate, which until now was the only affordance on an answer. gnubok_feedback
exists as a tool with no UI at all, so the thumbs are local-only for the moment;
the point of this row is that the affordances sit where people look for them,
and wiring the vote through is a follow-up that cannot break reading an answer.

Stop used to abort and leave the half-written answer looking finished, which is
worst exactly when it stopped mid-figure. The partial text still stays, now with
a marker saying it was interrupted.

A failed send cleared the composer and left a user bubble that had never reached
the server, so the question vanished on the next reload and had to be retyped.
The text now goes back into the composer and the unsent bubble is dropped, so
the screen matches what was actually sent. startTurn reports whether the request
got out; a mid-stream failure still counts as sent and keeps its content.

Autoscroll already respected a user who had scrolled up, but nothing told them
an answer had landed below the fold. A "Nytt svar" pill now appears in that
case and takes them back.

Verified: 9549 unit tests pass (7 new pinning the stop and failed-send state
rules), lint and tsc clean on the touched file, guards pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(assistant): anchor the jump pill to the message area, not the whole panel

Self-review before merge: the pill sat at a fixed offset from the bottom of the
component, but the composer below it grows to 128px as the user types. A long
multi-line draft plus a scrolled-up reader would have slid the pill underneath
the composer, exactly when it is needed. It now positions against the message
area itself, so composer height is irrelevant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-26 22:40:02 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 4de648fb5d
commit 8397452440
2 changed files with 234 additions and 13 deletions
+154 -13
View File
@@ -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<void> {
// 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<boolean> {
// 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 (
<div className="relative flex flex-col h-full min-h-0">
{/* 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. */}
<div className="relative flex-1 min-h-0 flex flex-col">
<div
ref={scrollerRef}
className={cn(
@@ -680,6 +733,18 @@ export default function AgentChat({
)}
</div>
{hasUnseenBelow && (
<button
type="button"
onClick={jumpToLatest}
className="absolute left-1/2 -translate-x-1/2 bottom-3 z-10 inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-[11px] text-foreground shadow-md hover:bg-secondary transition-colors"
>
<ArrowDown className="h-3 w-3" />
Nytt svar
</button>
)}
</div>
{/* 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 (
<div className={cn('flex flex-col gap-2', isUser ? 'items-end' : 'items-start')}>
<div
className={cn('group/msg flex flex-col gap-2', isUser ? 'items-end' : 'items-start')}
>
{!isUser && message.reasoning && (
<ReasoningBlock reasoning={message.reasoning} active={isThinking} />
)}
@@ -803,6 +870,12 @@ function MessageBubble({
</div>
)}
{message.interrupted && (
<p className="text-[11px] text-muted-foreground border-t border-dashed border-border pt-1.5">
Avbrutet. Det som hann skrivas står kvar.
</p>
)}
{message.toolCalls && message.toolCalls.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{message.toolCalls.map((tc) => (
@@ -863,13 +936,81 @@ function MessageBubble({
</div>
)}
{showRegenerate && onRegenerate && (
<button
type="button"
onClick={onRegenerate}
className="inline-flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
title="Generera om svaret"
>
{!isUser && message.text && !streamingTail && (
<MessageActions
text={message.text}
onRegenerate={showRegenerate ? onRegenerate : undefined}
/>
)}
</div>
)
}
/**
* 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 (
<div className="flex items-center gap-0.5 opacity-0 focus-within:opacity-100 group-hover/msg:opacity-100 transition-opacity">
<button type="button" onClick={handleCopy} className={btn} title="Kopiera svaret">
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? 'Kopierat' : 'Kopiera'}
</button>
<button
type="button"
onClick={() => setVote(vote === 'up' ? null : 'up')}
className={cn(btn, vote === 'up' && 'text-foreground')}
title="Bra svar"
aria-pressed={vote === 'up'}
>
<ThumbsUp className="h-3 w-3" />
<span className="sr-only">Bra svar</span>
</button>
<button
type="button"
onClick={() => setVote(vote === 'down' ? null : 'down')}
className={cn(btn, vote === 'down' && 'text-foreground')}
title="Dåligt svar"
aria-pressed={vote === 'down'}
>
<ThumbsDown className="h-3 w-3" />
<span className="sr-only">Dåligt svar</span>
</button>
{onRegenerate && (
<button type="button" onClick={onRegenerate} className={btn} title="Generera om svaret">
<RotateCw className="h-3 w-3" />
Generera om
</button>
@@ -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)
})
})