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:
co-authored by
Claude Opus 5
parent
4de648fb5d
commit
8397452440
+154
-13
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user