From 94ef66a2c415605d891b7cc4f1190a0a9cc9e901 Mon Sep 17 00:00:00 2001 From: pdelfan Date: Fri, 16 Oct 2026 22:13:44 -0700 Subject: [PATCH] A pending reply is drawn by one module, PendingReply.tsx: its waiting or live header, its stream, and the lines about a wait, a busy provider, or a lost stream, until its finished record replaces it. MessageView uses it while a record is pending, and it follows the conversation the FollowedConversation context names, which ConversationView provides, so MessageView and BranchView no longer take pending and live slots and BranchRow no longer checks the status. The stream's part views get the pending record rather than a made-up one, and the model's name comes from MessageView alone. LiveAuthor.tsx, ReplyStream.tsx, and StreamingReply.tsx are gone. A conversation test streams a reply, then has its record arrive, and checks the time it thought carries over and its text shows once. --- CHANGELOG.md | 1 + .../conversation/components/BranchView.tsx | 11 +- .../components/ConversationView.tsx | 54 +++--- .../conversation/components/LiveAuthor.tsx | 67 ------- .../conversation/components/MessageView.tsx | 23 ++- .../conversation/components/PendingReply.tsx | 173 ++++++++++++++++++ .../conversation/components/ReplyStream.tsx | 31 ---- .../components/StreamingReply.tsx | 42 ----- .../components/ConversationView.test.tsx | 48 ++++- .../components/MessageView.test.tsx | 63 ++++--- ...eAuthor.test.tsx => PendingReply.test.tsx} | 2 +- specs/tool-views.md | 2 +- specs/web-ui.md | 6 +- 13 files changed, 300 insertions(+), 223 deletions(-) delete mode 100644 apps/web/src/features/conversation/components/LiveAuthor.tsx create mode 100644 apps/web/src/features/conversation/components/PendingReply.tsx delete mode 100644 apps/web/src/features/conversation/components/ReplyStream.tsx delete mode 100644 apps/web/src/features/conversation/components/StreamingReply.tsx rename apps/web/tests/features/conversation/components/{LiveAuthor.test.tsx => PendingReply.test.tsx} (87%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c255f7..2382ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ What changed in each release of SCN Chat, for the people who use it, run it, or ### Changed - For forkers: a tab reaches the browser store through `StoreClient.calls`, which has every method of the store's logic, rather than `StoreClient.worker`. Web tests run components over the real store through `testStore` in `apps/web/tests/helpers/store.ts`, rather than hand-built fakes. +- For forkers: a pending reply is drawn by `PendingReply.tsx` in the conversation feature, which `MessageView` uses while a record is pending, and which follows the conversation `FollowedConversation` names. `MessageView` and `BranchView` no longer take `pending` and `live` slots, and `LiveAuthor.tsx`, `ReplyStream.tsx`, and `StreamingReply.tsx` are gone. ## [0.2.1] - 2026-10-16 diff --git a/apps/web/src/features/conversation/components/BranchView.tsx b/apps/web/src/features/conversation/components/BranchView.tsx index fac5e26..63eed7a 100644 --- a/apps/web/src/features/conversation/components/BranchView.tsx +++ b/apps/web/src/features/conversation/components/BranchView.tsx @@ -9,10 +9,6 @@ interface RowOptions { blobUrl: BlobUrl /** Show another version of a message, by its rkey. */ onPick: (rkey: string) => void - /** What a pending message shows as it streams, such as its stream. */ - pending?: (message: BranchMessage) => ReactNode - /** What a pending reply's header shows in place of its author, such as its live status. */ - live?: (message: BranchMessage) => ReactNode /** The controls under a message. */ actions?: (message: BranchMessage) => ReactNode /** What a failed reply offers beside its error, such as a button to try again. */ @@ -39,8 +35,6 @@ export function BranchView(props: Props) { next={step.siblings[step.index + 1]?.rkey} blobUrl={props.blobUrl} onPick={props.onPick} - pending={props.pending} - live={props.live} actions={props.actions} retry={props.retry} arrived={props.arrived} @@ -62,14 +56,11 @@ interface RowProps extends RowOptions, Omit { */ const BranchRow = memo(function BranchRow(props: RowProps) { const message = props.message - const status = message.record.status return ( ) diff --git a/apps/web/src/features/conversation/components/ConversationView.tsx b/apps/web/src/features/conversation/components/ConversationView.tsx index 8655d6c..81ffbb5 100644 --- a/apps/web/src/features/conversation/components/ConversationView.tsx +++ b/apps/web/src/features/conversation/components/ConversationView.tsx @@ -18,9 +18,8 @@ import type { ComposerHandle } from './Composer.tsx' import { ConversationHeader } from './ConversationHeader.tsx' import { ConversationSkeleton } from './ConversationSkeleton.tsx' import { EmptyConversation } from './EmptyConversation.tsx' -import { LiveAuthor } from './LiveAuthor.tsx' import { type ConversationOperations, MessageActions, RunningReply } from './message-actions.tsx' -import { ReplyStream } from './ReplyStream.tsx' +import { FollowedConversation } from './PendingReply.tsx' interface Props { skey: string @@ -91,36 +90,27 @@ export function ConversationView(props: Props) { {waiting && } {chat.empty && composer.current?.fill(text)} />} - - - ( - - )} - live={(message) => ( - - )} - actions={(message) => } - // A failed reply tries again with the same model. - retry={(message) => ( - - regenerate((message.record.parent as string | undefined) ?? null, null) - } - /> - )} - arrived={(message) => !opened?.has(message.rkey)} - /> - - + + + + } + // A failed reply tries again with the same model. + retry={(message) => ( + + regenerate((message.record.parent as string | undefined) ?? null, null) + } + /> + )} + arrived={(message) => !opened?.has(message.rkey)} + /> + + + Math.max(0, Math.floor((Date.now() - start) / 1000)) - -/** The whole seconds since a time, ticking as each one passes, or none without a time. */ -function useSecondsSince(since: unknown): number | undefined { - const start = parseDate(since)?.getTime() - const [seconds, setSeconds] = useState(() => (start === undefined ? 0 : secondsSince(start))) - useEffect(() => { - if (start === undefined) return - let timer: ReturnType - // Wakes just after each whole second since the start, so each shows as it passes. - const tick = () => { - setSeconds(secondsSince(start)) - timer = setTimeout(tick, 1000 - ((Date.now() - start) % 1000) + 10) - } - tick() - return () => clearTimeout(timer) - }, [start]) - return start === undefined ? undefined : seconds -} - -interface WaitingAuthorProps { - model: string - /** When the reply was created, to count the wait from. */ - since?: unknown -} - -/** - * The header of a reply that has shown nothing yet: the model's name, breathing at the slow pace of - * the rest of the live work, and how long it has been waiting. A reply just asked for shows nothing - * for a moment, then fades in its waiting header, so a quick one never flashes it. - */ -export function WaitingAuthor(props: WaitingAuthorProps) { - const seconds = useSecondsSince(props.since) - return ( - - {props.model} - {seconds !== undefined && seconds > 0 && ( - - {formatSeconds(seconds)} - - )} - - ) -} - -interface LiveAuthorProps { - skey: string - rkey: string - model: string - /** When the reply was created, to count the wait from. */ - since?: unknown -} - -/** - * The header of a pending reply, following its stream: waiting until something arrives, then the - * model's name. It rerenders only once the reply starts, not with each delta. - */ -export function LiveAuthor(props: LiveAuthorProps) { - const started = useReplyStarted(props.skey, props.rkey) - if (!started) return - return {props.model} -} diff --git a/apps/web/src/features/conversation/components/MessageView.tsx b/apps/web/src/features/conversation/components/MessageView.tsx index 055ba73..356f498 100644 --- a/apps/web/src/features/conversation/components/MessageView.tsx +++ b/apps/web/src/features/conversation/components/MessageView.tsx @@ -3,8 +3,8 @@ import { stepsOf } from '../../../parts/steps.ts' import type { BlobUrl, Part } from '../../../parts/types.ts' import { WorkButton, WorkList } from '../../../parts/WorkSteps.tsx' import { cn } from '../../../shared/cn.ts' -import { WaitingAuthor } from './LiveAuthor.tsx' import { PartsView } from './PartsView.tsx' +import { PendingAuthor, PendingBody } from './PendingReply.tsx' import { SiblingPicker, type Siblings } from './SiblingPicker.tsx' import { Status } from './Status.tsx' @@ -13,10 +13,6 @@ export interface MessageViewProps { /** The message's record key, which its steps' times are kept by. */ rkey?: string record: Record - /** What a pending reply shows as it streams, such as its stream. */ - pending?: ReactNode - /** What a pending reply's header shows in place of its author, such as its live status. */ - live?: ReactNode blobUrl: BlobUrl siblings?: Siblings actions?: ReactNode @@ -35,15 +31,16 @@ function author(record: Record): string { } interface AuthorProps { + rkey?: string record: Record - live?: ReactNode } /** Who wrote the message, or for a pending reply, its live status. */ function Author(props: AuthorProps) { if (props.record.status !== 'pending') return {author(props.record)} - if (props.live) return props.live - return + return ( + + ) } /** @@ -57,7 +54,9 @@ export function MessageView(props: MessageViewProps) { const content = record.content as { $type: string; parts?: Part[] } const parts = content.parts ?? [] const encrypted = content.$type.endsWith('#encryptedContent') - const steps = record.status === 'pending' ? [] : stepsOf(parts) + // A pending reply shows its stream until its finished record replaces it. + const pending = record.status === 'pending' + const steps = pending ? [] : stepsOf(parts) const user = record.role === 'user' const workId = useId() const [workOpen, setWorkOpen] = useState(false) @@ -82,16 +81,16 @@ export function MessageView(props: MessageViewProps) { user ? '[&>strong]:hidden' : "[&>strong]:before:size-1.75 [&>strong]:before:rounded-full [&>strong]:before:bg-author-dot [&>strong]:before:content-['']", - record.status === 'pending' && + pending && '[&>strong]:before:animate-breathe [&>strong]:before:bg-accent-mark motion-reduce:[&>strong]:before:animate-none', record.status === 'error' && '[&>strong]:before:bg-danger', )} > - + {props.siblings && props.siblings.count > 1 && } - {record.status === 'pending' && props.pending} + {pending && } {encrypted &&

This message is encrypted.

} {/* The actions sit by the message's edge, a reply's at the start and the user's at the end, diff --git a/apps/web/src/features/conversation/components/PendingReply.tsx b/apps/web/src/features/conversation/components/PendingReply.tsx new file mode 100644 index 0000000..b786a19 --- /dev/null +++ b/apps/web/src/features/conversation/components/PendingReply.tsx @@ -0,0 +1,173 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { formatSeconds } from '../../../parts/step-clock.ts' +import type { BlobUrl } from '../../../parts/types.ts' +import { parseDate } from '../../../shared/time.ts' +import type { ReplyParts } from '../lib/reply-stream.ts' +import { useFollowedReply, useReplyStarted } from '../reply-follower.ts' +import { PartsView } from './PartsView.tsx' +import { statusLine } from './Status.tsx' + +/** + * The conversation whose pending replies the reply follower follows, by its key. Where there is + * none, as on a shared page, a pending reply shows only that it is waiting. + */ +export const FollowedConversation = createContext(null) + +const secondsSince = (start: number) => Math.max(0, Math.floor((Date.now() - start) / 1000)) + +/** The whole seconds since a time, ticking as each one passes, or none without a time. */ +function useSecondsSince(since: unknown): number | undefined { + const start = parseDate(since)?.getTime() + const [seconds, setSeconds] = useState(() => (start === undefined ? 0 : secondsSince(start))) + useEffect(() => { + if (start === undefined) return + let timer: ReturnType + // Wakes just after each whole second since the start, so each shows as it passes. + const tick = () => { + setSeconds(secondsSince(start)) + timer = setTimeout(tick, 1000 - ((Date.now() - start) % 1000) + 10) + } + tick() + return () => clearTimeout(timer) + }, [start]) + return start === undefined ? undefined : seconds +} + +interface WaitingAuthorProps { + model: string + /** When the reply was created, to count the wait from. */ + since?: unknown +} + +/** + * The header of a reply that has shown nothing yet: the model's name, breathing at the slow pace of + * the rest of the live work, and how long it has been waiting. A reply just asked for shows nothing + * for a moment, then fades in its waiting header, so a quick one never flashes it. + */ +export function WaitingAuthor(props: WaitingAuthorProps) { + const seconds = useSecondsSince(props.since) + return ( + + {props.model} + {seconds !== undefined && seconds > 0 && ( + + {formatSeconds(seconds)} + + )} + + ) +} + +interface AuthorProps { + /** The reply's key, which its stream is followed by. */ + rkey?: string + model: string + /** When the reply was created, to count the wait from. */ + since?: unknown +} + +/** + * A pending reply's header in place of its author: waiting until its stream shows something, then + * the model's name. It rerenders only once the reply starts, not with each delta. + */ +export function PendingAuthor(props: AuthorProps) { + const skey = useContext(FollowedConversation) + if (!skey || !props.rkey) return + return +} + +interface LiveAuthorProps { + skey: string + rkey: string + model: string + since?: unknown +} + +function LiveAuthor(props: LiveAuthorProps) { + const started = useReplyStarted(props.skey, props.rkey) + if (!started) return + return {props.model} +} + +interface BodyProps { + rkey?: string + /** The pending record, which the part views read as the stream's. */ + record: Record + blobUrl: BlobUrl +} + +/** + * A pending reply's stream as it arrives, from the reply follower, so each delta rerenders this + * alone. Until something arrives it shows nothing, and the reply's header says it is waiting. + * Once the stream ends with the reply unfinished, it says the conversation is checking the PDS for + * the reply instead. The finished record replaces all of it. + */ +export function PendingBody(props: BodyProps) { + const skey = useContext(FollowedConversation) + if (!skey || !props.rkey) return null + return +} + +interface StreamProps { + skey: string + rkey: string + record: Record + blobUrl: BlobUrl +} + +function ReplyStream(props: StreamProps) { + const { streamed, notLive } = useFollowedReply(props.skey, props.rkey) + return ( + <> + {streamed && ( + + )} + {notLive === 'dropped' && ( +

Lost the live stream. Checking for the reply…

+ )} + {notLive === 'elsewhere' && ( +

Not streaming on this server. Checking for the reply…

+ )} + + ) +} + +interface StreamingReplyProps { + reply: ReplyParts + record: Record + blobUrl: BlobUrl + /** The reply's key, so its steps' times carry over to the finished record. */ + rkey?: string +} + +/** + * The reply streamed so far, step by step. Before anything arrives the reply's header shows it + * waiting, so this only explains a wait for the rate limit or a busy provider. + */ +export function StreamingReply(props: StreamingReplyProps) { + if (props.reply.queued) + return ( +

In line: you have started the most replies allowed this minute.

+ ) + return ( + <> + + {props.reply.retrying && ( +

+ The model's provider is busy. Trying again… +

+ )} + + ) +} diff --git a/apps/web/src/features/conversation/components/ReplyStream.tsx b/apps/web/src/features/conversation/components/ReplyStream.tsx deleted file mode 100644 index b514442..0000000 --- a/apps/web/src/features/conversation/components/ReplyStream.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { BlobUrl } from '../../../parts/types.ts' -import { useFollowedReply } from '../reply-follower.ts' -import { statusLine } from './Status.tsx' -import { StreamingReply } from './StreamingReply.tsx' - -interface Props { - skey: string - rkey: string - blobUrl: BlobUrl -} - -/** - * A pending reply's stream as it arrives, from the reply follower, so each delta rerenders this - * alone. Until something arrives it shows nothing, and the - * reply's header says it is waiting. Once the stream ends with the reply unfinished, it says the - * conversation is checking the PDS for the reply instead. - */ -export function ReplyStream(props: Props) { - const { streamed, notLive } = useFollowedReply(props.skey, props.rkey) - return ( - <> - {streamed && } - {notLive === 'dropped' && ( -

Lost the live stream. Checking for the reply…

- )} - {notLive === 'elsewhere' && ( -

Not streaming on this server. Checking for the reply…

- )} - - ) -} diff --git a/apps/web/src/features/conversation/components/StreamingReply.tsx b/apps/web/src/features/conversation/components/StreamingReply.tsx deleted file mode 100644 index 969beb8..0000000 --- a/apps/web/src/features/conversation/components/StreamingReply.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { BlobUrl } from '../../../parts/types.ts' -import type { ReplyParts } from '../lib/reply-stream.ts' -import { PartsView } from './PartsView.tsx' -import { statusLine } from './Status.tsx' - -interface Props { - reply: ReplyParts - blobUrl: BlobUrl - /** The reply's key, so its steps' times carry over to the finished record. */ - rkey?: string -} - -/** The reply's record as far as the stream goes, for the part views that read it. */ -const streaming = { role: 'assistant', status: 'pending' } - -/** - * The reply streamed so far, step by step, shown until the reply's record arrives. Before anything - * arrives the reply's header shows it waiting, so this only explains a wait for the rate limit or - * a busy provider. - */ -export function StreamingReply(props: Props) { - if (props.reply.queued) - return ( -

In line: you have started the most replies allowed this minute.

- ) - return ( - <> - - {props.reply.retrying && ( -

- The model's provider is busy. Trying again… -

- )} - - ) -} diff --git a/apps/web/tests/features/conversation/components/ConversationView.test.tsx b/apps/web/tests/features/conversation/components/ConversationView.test.tsx index 78eaa10..c86136a 100644 --- a/apps/web/tests/features/conversation/components/ConversationView.test.tsx +++ b/apps/web/tests/features/conversation/components/ConversationView.test.tsx @@ -6,9 +6,10 @@ import { MeContext } from '../../../../src/features/auth/session.ts' import { BusyBanner } from '../../../../src/features/conversation/components/BusyBanner.tsx' import { ConversationView } from '../../../../src/features/conversation/components/ConversationView.tsx' import type * as messageView from '../../../../src/features/conversation/components/MessageView.tsx' +import { resetStepClocks } from '../../../../src/parts/step-clock.ts' import type { HandoverState } from '../../../../src/store/handover.ts' import { StoreProvider } from '../../../../src/store/react.tsx' -import { FakeEventSource, stubEventSource } from '../../../helpers/event-source.ts' +import { FakeEventSource, lastSource, stubEventSource } from '../../../helpers/event-source.ts' import { stubFetch, writes } from '../../../helpers/fetch.ts' import { renderAt } from '../../../helpers/router.tsx' import { type HeldMessage, type TestStore, testStore } from '../../../helpers/store.ts' @@ -92,7 +93,10 @@ beforeEach(() => { stubFetch() stubEventSource() }) -afterEach(() => vi.unstubAllGlobals()) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) describe('ConversationView', () => { it('shows skeletons in place of the title and messages, and no placeholder, until the local copy answers', async () => { @@ -299,6 +303,46 @@ describe('ConversationView', () => { for (const rkey of ['u', 'u.r0', 'v']) expect(messageRenders.get(rkey)).toBeUndefined() }) + it('hands a streamed reply over to its record, keeping how long it thought', async () => { + resetStepClocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + const question = user('u', 'question') + const answer = (status: string, parts: Record[]) => + msg('u.r0', { + role: 'assistant', + parent: 'u', + status, + content: { $type: d('plainContent'), parts }, + createdAt: '2026-09-26T00:00:01Z', + }) + const store = storeWith([question, answer('pending', [])]) + await renderWith(store, ) + await vi.waitFor(() => + expect(lastSource()?.url).toBe('/api/conversations/s1/messages/u.r0/stream'), + ) + act(() => lastSource().emit('part-start', { index: 0, partType: 'reasoningPart' })) + await act(async () => vi.advanceTimersByTimeAsync(3_000)) + act(() => { + lastSource().emit('part-start', { index: 1, partType: 'textPart' }) + lastSource().emit('delta', { index: 1, text: 'Use porcelain.' }) + }) + expect(await screen.findByText('Use porcelain.')).toBeInTheDocument() + // The finished record reaches the local copy, and the stream ends. + act(() => + update(store, [ + question, + answer('complete', [ + { $type: d('reasoningPart'), text: 'Floors take wear.' }, + { $type: d('textPart'), text: 'Use porcelain.' }, + ]), + ]), + ) + act(() => lastSource().emit('status', { status: 'complete' })) + expect(await screen.findByRole('button', { name: /^Thought for 3s/ })).toBeInTheDocument() + expect(screen.getAllByText('Use porcelain.')).toHaveLength(1) + expect(lastSource().closed).toBe(true) + }) + it('syncs and shows a message written from another client', async () => { const fetch = vi.fn(async () => Response.json({})) stubFetch(fetch) diff --git a/apps/web/tests/features/conversation/components/MessageView.test.tsx b/apps/web/tests/features/conversation/components/MessageView.test.tsx index 418b00e..81e6036 100644 --- a/apps/web/tests/features/conversation/components/MessageView.test.tsx +++ b/apps/web/tests/features/conversation/components/MessageView.test.tsx @@ -2,11 +2,17 @@ import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { MessageView } from '../../../../src/features/conversation/components/MessageView.tsx' -import { StreamingReply } from '../../../../src/features/conversation/components/StreamingReply.tsx' +import { StreamingReply } from '../../../../src/features/conversation/components/PendingReply.tsx' import { noParts } from '../../../../src/features/conversation/lib/reply-stream.ts' const d = (name: string) => `network.sharedcomputer.chat.defs#${name}` const blobUrl = (cid: string) => `/blob/${cid}` +/** A reply's record while it streams. */ +const streaming = { + role: 'assistant', + status: 'pending', + content: { $type: d('plainContent'), parts: [] }, +} const working = { role: 'assistant', @@ -338,48 +344,51 @@ describe('MessageView', () => { it('shows streamed text for a pending reply as Markdown', () => { const reply = { ...noParts, parts: [{ $type: d('textPart'), text: '*Hel*' }] } - render( - } - record={{ - role: 'assistant', - status: 'pending', - content: { $type: d('plainContent'), parts: [] }, - }} - />, - ) + render() expect(screen.getByText('Hel').tagName).toBe('EM') - expect(screen.queryByText(/Waiting for/)).toBeNull() }) it('says what a streaming reply is doing right now', () => { const call = { $type: d('toolCallPart'), callId: 'c', tool: 'fetch', input: '"https://x.test"' } const { container, rerender } = render( - , + , ) expect(container.querySelector('[data-steps] summary')).toHaveTextContent('Running fetch…') expect(container.querySelector('[data-step=tool]')).toHaveAttribute('data-state', 'running') // Once the reply starts, the work gives way to it. const text = { $type: d('textPart'), text: 'Here' } - rerender() + rerender( + , + ) expect(container.querySelector('[data-step=tool]')).not.toBeVisible() expect(screen.getByText('Here')).toBeVisible() - rerender() + rerender( + , + ) expect(screen.getByText(/In line/)).toBeInTheDocument() // Before anything arrives, the reply's header says it is waiting. - rerender() + rerender() expect(container).toBeEmptyDOMElement() }) it('announces a wait on a busy provider below what the reply streamed so far', () => { const text = { $type: d('textPart'), text: 'Here' } const { rerender } = render( - , + , ) expect(screen.getByText('Here')).toBeVisible() expect(screen.getByRole('status')).toBeInTheDocument() - rerender() + rerender( + , + ) expect(screen.queryByRole('status')).not.toBeInTheDocument() }) @@ -593,7 +602,9 @@ async function openToolSteps(container: HTMLElement) { describe('tool views', () => { it('shows every call still waiting for its result in a streaming reply as running', () => { const parts = [call('a', 'lookup', { q: 'one' }), call('b', 'lookup', { q: 'two' })] - const { container } = render() + const { container } = render( + , + ) expect(toolSteps(container).map((step) => step.dataset.state)).toEqual(['running', 'running']) expect(toolSteps(container).map((step) => step.querySelector('summary')?.textContent)).toEqual([ expect.stringContaining('running'), @@ -622,7 +633,11 @@ describe('tool views', () => { it('names a web search by what it did, on the live line, its row, and the summary', async () => { const search = call('a', 'web_search', { query: 'espresso grinders' }) const live = render( - , + , ) expect(live.container.querySelector('[data-steps] summary')).toHaveTextContent( 'Searching the web…', @@ -700,7 +715,11 @@ describe('tool views', () => { it('says a web search is searching while it runs, and when it found nothing', async () => { const search = call('a', 'web_search', { query: 'grinders' }) const live = render( - , + , ) await openToolSteps(live.container) expect(toolSteps(live.container)[0]).toHaveTextContent('Searching…') diff --git a/apps/web/tests/features/conversation/components/LiveAuthor.test.tsx b/apps/web/tests/features/conversation/components/PendingReply.test.tsx similarity index 87% rename from apps/web/tests/features/conversation/components/LiveAuthor.test.tsx rename to apps/web/tests/features/conversation/components/PendingReply.test.tsx index af8e097..759f056 100644 --- a/apps/web/tests/features/conversation/components/LiveAuthor.test.tsx +++ b/apps/web/tests/features/conversation/components/PendingReply.test.tsx @@ -1,6 +1,6 @@ import { act, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { WaitingAuthor } from '../../../../src/features/conversation/components/LiveAuthor.tsx' +import { WaitingAuthor } from '../../../../src/features/conversation/components/PendingReply.tsx' afterEach(() => vi.useRealTimers()) diff --git a/specs/tool-views.md b/specs/tool-views.md index d21bfde..6ccb984 100644 --- a/specs/tool-views.md +++ b/specs/tool-views.md @@ -139,7 +139,7 @@ Tool output comes from the web. Tool views show it as text, never as HTML or Mar ## Acceptance Criteria -Each is checked by rendering a reply through `MessageView`, or `StreamingReply` for a pending one, with tool call and result parts in its record. +Each is checked by rendering a reply through `MessageView`, or `StreamingReply` in `PendingReply.tsx` for a pending one, with tool call and result parts in its record. - [x] A tool call and its result render once, together, through the tool's view, or the default view when the tool has none. - [x] A result with no matching call renders alone with the default view. diff --git a/specs/web-ui.md b/specs/web-ui.md index d4ea8d4..214c22b 100644 --- a/specs/web-ui.md +++ b/specs/web-ui.md @@ -87,7 +87,7 @@ The root route's `beforeLoad` asks `/api/me` who is signed in, through the query - The footer's steps and each step start closed, and the reader opens what they want to see. - Sources are shown after the parts behind their count, such as "3 sources", a `
` whose summary is a grey chip shaped like the source rows, with a link icon before the count and a chevron after it, and which starts closed, so the sources stay out of the reply's way until asked for. Opening it shows them as a numbered list, one source to a line. Each is a link named by its title, followed by its site, or named by its site alone when it has no title, with the full URL on hover, and a long title is cut short to keep its line. A streaming reply's sources join the list as they arrive. The list is not folded, since opening the count already asks for every source. The search results in a web search step fold past seven through `useFold` in `src/parts/fold.ts`. - Images are shown inline, loading and decoding only as they near the screen. A file shows as the chip it was in the composer, `FileChip` in `src/shared/ui/`, with a file icon, its name, cut short to the chip's width and shown in full on hover, or "Attached file" without one, over its kind, from its extension or else its MIME type, and its size, as "PDF · 1.2 MB", and an icon at its end for what opening it does. The whole chip is a link: a PDF opens in a new tab in the browser's own viewer, with an arrow out, and any other file downloads under its name, with a download arrow. On hover it takes the sources chip's tint. Files side by side share a row that wraps toward the message's edge, and an image between them keeps its place. - - A pending reply shows its stream step by step, as the final record would show it. While the last block of steps is still going, its summary says what the model is doing now: "Thinking…", "Running web_search…", or "Reading the results…". A pending reply's header is its status, rather than a line repeating the model's name below it. Before any part arrives, and for a pending reply with no stream, as on the shared page, the model's name breathes. A reply just sent shows nothing for its first 400ms, so one that starts quickly never flashes a waiting state. Once a part streams in, `LiveAuthor` shows the name plainly. It reads the stream query like `ReplyStream`, so a delta rerenders only the header and the stream. While the turn waits for the rate limit, a line under the header says so. + - A pending reply shows its stream step by step, as the final record would show it. While the last block of steps is still going, its summary says what the model is doing now: "Thinking…", "Running web_search…", or "Reading the results…". A pending reply's header is its status, rather than a line repeating the model's name below it. Before any part arrives, and for a pending reply with no stream, as on the shared page, the model's name breathes. A reply just sent shows nothing for its first 400ms, so one that starts quickly never flashes a waiting state. Once a part streams in, the header shows the name plainly. It reads only whether the reply has started, so a delta rerenders only the stream. While the turn waits for the rate limit, a line under the header says so. - Steps are timed where time is spent, as chat apps time thinking. While reasoning streams, its step and the block's summary count up, as "Thinking… 8s", and when it ends the step reads "Thought for 12s" and the finished reply's footer button totals it, as "Thought for 12s · web_search". A running tool counts up the same way, and its time goes once it finishes. The record holds no times, so they are measured in the browser while the reply streams and kept in memory by reply and step, so they carry over from the stream to the finished record. A reply not watched while it streamed, as after a reload, reads "Thought". An errored reply says it failed and why, with a "Try again" button beside it that regenerates it with the same model, as Regenerate does, and is disabled while a regenerate is on its way. A cancelled one carries a small amber Stopped badge, with the Stop button's square, under where it broke off. - The message element carries `data-status`, and live steps `data-live`, `data-current`, and `data-state`, so a theme can style each state. - **Message actions.** On user messages, edit, which opens the composer prefilled and sends a sibling, copy, and details. Editing another message starts the composer over from that message. On assistant replies, copy, regenerate, which regenerates with the same model, and More, a menu holding the rest: quote, which adds the text selected inside the reply when the menu opened, or the whole reply when nothing in it was selected, to the composer as a quote chip and focuses it, and says which it will quote; regenerate with, which shows the models in the menu's place and regenerates with the one picked at once; and details. Details, as a button or from More, opens a card of what the message's record says: the model, effort, tokens, status, when it was written in full, its record key, and where it is stored. Regenerate with and details each take the menu's place under a button back to it, and each view starts with focus on its first control. Escape or a click outside closes the menu or card and returns focus to its button. While generating, stop. The actions are icon buttons named by their labels, grouped as the message's actions, and sit at the message's edge, so a reply's start its footer and the user's end theirs, with the most used nearest the edge. A reply's steps button comes after them. The actions show on hover and on keyboard focus, while a menu or card is open, always on the latest reply, since its actions are the ones most often wanted next, and always on a touch screen, which has no hover. The shared view shows details as each message's only action. @@ -123,9 +123,9 @@ The reply follower, in `features/conversation/reply-follower.ts`, follows each p Each followed reply has a stream query over the reply's stream endpoint, private to the follower. It reduces the stream's events into the reply's parts in the record's own shape, by index: `part-start` starts a text or reasoning part, `delta` appends to it, and `part` sets a finished tool call, tool result, or source. A `queued` event marks the reply as waiting until its first part arrives. The query ends after the `status` event. Since streamed parts have the record's shape, a pending reply and a finished one render through the same view. A finished status (`complete`, `error`, or `cancelled`) refreshes the conversation from the PDS, which brings the final record into the browser store. The events that arrive within one animation frame are reduced as one batch, so a reply streaming a word at a time renders at most once a frame, and the `status` event is passed on at once with whatever came before it. A stream runs once while the conversation is open. Leaving the conversation drops it, and coming back follows the reply again. -A reply's views read it from the follower without ever starting a stream, so each delta re-renders only the views that show it, never the conversation. `ReplyStream`, which `ConversationView` puts in `MessageView`'s `pending` slot, reads `useFollowedReply(skey, rkey)`: what has streamed so far, and why the reply is no longer live, if it isn't. `LiveAuthor` reads `useReplyStarted(skey, rkey)`, which re-renders the header only once the reply starts. A message with nothing in the slot, as on the shared page, shows "Thinking..." while pending. A change to the conversation, such as a message arriving or a reply finishing, renders again only the messages it changed: `BranchView` gives each message's row only its record, which the local copy keeps the same object while unchanged, its place among its versions, and functions that stay the same, and the reply running at the end of the branch reaches the Stop actions through the `RunningReply` context. +A reply's views read it from the follower without ever starting a stream, so each delta re-renders only the views that show it, never the conversation. A pending reply is drawn by `PendingReply.tsx`, which `MessageView` uses for its header and body while the record is `pending`, and which finds the conversation to follow through the `FollowedConversation` context that `ConversationView` provides. Its body reads `useFollowedReply(skey, rkey)`: what has streamed so far, and why the reply is no longer live, if it isn't. Its header reads `useReplyStarted(skey, rkey)`, which re-renders the header only once the reply starts. The stream's part views get the pending record itself. Without that context, as on the shared page, a pending reply shows only its model's name breathing. A change to the conversation, such as a message arriving or a reply finishing, renders again only the messages it changed: `BranchView` gives each message's row only its record, which the local copy keeps the same object while unchanged, its place among its versions, and functions that stay the same, and the reply running at the end of the branch reaches the Stop actions through the `RunningReply` context. -If a reply is still pending after its stream ends, because the stream dropped or the status says another server runs it, the follower polls, checking the conversation against the PDS as opening it does, with a backoff from two seconds to thirty until the reply leaves `pending`. The first poll comes one interval after the stream ends, and polling continues while the tab is in the background. Meanwhile `ReplyStream` keeps what streamed and says why the reply is no longer live: "Lost the live stream. Checking for the reply…" when the stream dropped or sent an event it couldn't read, and "Not streaming on this server. Checking for the reply…" when its status was not final. +If a reply is still pending after its stream ends, because the stream dropped or the status says another server runs it, the follower polls, checking the conversation against the PDS as opening it does, with a backoff from two seconds to thirty until the reply leaves `pending`. The first poll comes one interval after the stream ends, and polling continues while the tab is in the background. Meanwhile the pending reply keeps what streamed and says why the reply is no longer live: "Lost the live stream. Checking for the reply…" when the stream dropped or sent an event it couldn't read, and "Not streaming on this server. Checking for the reply…" when its status was not final. While the reader is at the bottom of the conversation, it stays scrolled to the bottom as replies stream and messages arrive, gliding down rather than jumping. A screen or more of content arriving at once, as when a chat's messages load after it opens, lands at its end at once, and under `prefers-reduced-motion` every move is a jump. Reaching to scroll, by wheel, touch, or the scrollbar, ends a glide at once. Once they scroll up it stops following, until they scroll back down or start a turn, by sending, editing, or regenerating, which always scrolls to the bottom, where the new message is. While they are away from the bottom, a button above the composer jumps back down and follows again. A conversation opened on a linked message, or a shared one, scrolls to that message instead. Switching siblings does not scroll. -- knotmirror 0.0.1