8397452440
* 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>
81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
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)
|
|
})
|
|
})
|