From 171fb03a06a3d4e922d2c4a5dcb9fb844dacc361 Mon Sep 17 00:00:00 2001 From: pdelfan Date: Thu, 15 Oct 2026 11:21:28 -0700 Subject: [PATCH] A change to the open conversation, such as a message arriving or a reply finishing, renders again only the messages it changed, rather than every message and its actions. BranchView gives each message's row only its record, which the local copy keeps the same object while unchanged, its place among its versions as numbers and the rkeys on either side, and functions that stay the same from render to render: useChat's stop and regenerate call the mutations' own stable functions, and useModels gives the same empty list until models arrive. The reply running at the end of the branch, whose Stop the composer offers instead, reaches the Stop actions through a RunningReply context rather than the operations every action gets, so a reply starting or finishing rerenders only those. The web UI spec says so, and a ConversationView test counts each message's renders as a reply finishes. --- apps/web/src/features/conversation/chat.ts | 8 +- .../conversation/components/BranchView.tsx | 76 +++++++++++++++---- .../components/ConversationView.tsx | 69 ++++++++--------- .../conversation/components/MessageView.tsx | 4 +- .../conversation/components/SiblingPicker.tsx | 17 +++-- .../components/message-actions.tsx | 14 +++- .../src/features/models/hooks/useModels.ts | 5 +- .../components/ConversationView.test.tsx | 37 +++++++++ .../components/MessageView.test.tsx | 6 +- specs/web-ui.md | 2 +- 10 files changed, 165 insertions(+), 73 deletions(-) diff --git a/apps/web/src/features/conversation/chat.ts b/apps/web/src/features/conversation/chat.ts index 1c8240d..cc76bd7 100644 --- a/apps/web/src/features/conversation/chat.ts +++ b/apps/web/src/features/conversation/chat.ts @@ -90,6 +90,9 @@ export function useChat(skey: string, options: ChatOptions = {}) { return refresh(skey) }, }) + // The stable functions alone, so what calls them stays the same from render to render. + const { mutate: stop } = stopping + const { mutate: regenerate } = regenerating const leaf = branch.at(-1)?.message // The later of the two, so a later success clears an earlier failure. const outcome = [stopping, regenerating].reduce((a, b) => (b.submittedAt > a.submittedAt ? b : a)) @@ -114,10 +117,9 @@ export function useChat(skey: string, options: ChatOptions = {}) { running: leaf?.record.status === 'pending' ? leaf.rkey : null, send, /** Ask again for the reply to a user message, by its key, with a model or the same one. */ - regenerate: (parent: string | null, model: ModelRef | null) => - regenerating.mutate({ parent, model }), + regenerate: (parent: string | null, model: ModelRef | null) => regenerate({ parent, model }), regenerating: regenerating.isPending, - stop: (replyRkey: string) => stopping.mutate(replyRkey), + stop: (replyRkey: string) => stop(replyRkey), /** How the last regeneration or stop went, for the conversation's one alert. */ outcome: { error: outcome.error, submittedAt: outcome.submittedAt }, } diff --git a/apps/web/src/features/conversation/components/BranchView.tsx b/apps/web/src/features/conversation/components/BranchView.tsx index b109344..55c26b1 100644 --- a/apps/web/src/features/conversation/components/BranchView.tsx +++ b/apps/web/src/features/conversation/components/BranchView.tsx @@ -1,10 +1,10 @@ -import type { ReactNode } from 'react' +import { memo, type ReactNode } from 'react' import type { BlobUrl } from '../../../parts/types.ts' import type { BranchMessage, BranchStep } from '../lib/branch.ts' import { MessageView } from './MessageView.tsx' -interface Props { - branch: BranchStep[] +/** What the conversation gives every message on the branch. */ +interface RowOptions { blobUrl: BlobUrl /** Show another version of a message, by its rkey. */ onPick: (rkey: string) => void @@ -20,25 +20,71 @@ interface Props { arrived?: (message: BranchMessage) => boolean } +interface Props extends RowOptions { + branch: BranchStep[] +} + /** The messages on the branch on screen, each with its versions, and the id `m-`. */ export function BranchView(props: Props) { return ( <> - {props.branch.map(({ message, siblings, index }) => ( - ( + ))} ) } + +interface RowProps extends RowOptions { + message: BranchMessage + /** Its place among its versions, and how many there are. */ + index: number + count: number + /** The versions on either side, by rkey. */ + previous?: string + next?: string +} + +/** + * One message on the branch. It renders again only when its own record or its place among its + * versions changes, since the local copy keeps an unchanged message the same object, so a message + * arriving or a reply finishing doesn't render every other message again. + */ +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 3615421..7ccea77 100644 --- a/apps/web/src/features/conversation/components/ConversationView.tsx +++ b/apps/web/src/features/conversation/components/ConversationView.tsx @@ -19,7 +19,7 @@ import { ConversationHeader } from './ConversationHeader.tsx' import { ConversationSkeleton } from './ConversationSkeleton.tsx' import { EmptyConversation } from './EmptyConversation.tsx' import { LiveAuthor } from './LiveAuthor.tsx' -import { type ConversationOperations, MessageActions } from './message-actions.tsx' +import { type ConversationOperations, MessageActions, RunningReply } from './message-actions.tsx' import { ReplyStream } from './ReplyStream.tsx' interface Props { @@ -56,7 +56,6 @@ export function ConversationView(props: Props) { const operations: ConversationOperations = { edit: setEditingRkey, quote: (text) => composer.current?.quote(text), - running: stoppable, regenerate: chat.regenerate, stop: chat.stop, models, @@ -88,38 +87,40 @@ 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) => ( - - )} - arrived={(message) => !opened?.has(message.rkey)} - /> + + ( + + )} + live={(message) => ( + + )} + actions={(message) => } + // A failed reply tries again with the same model. + retry={(message) => ( + + )} + arrived={(message) => !opened?.has(message.rkey)} + /> + - {props.siblings && props.siblings.messages.length > 1 && ( - - )} + {props.siblings && props.siblings.count > 1 && } {record.status === 'pending' && props.pending} diff --git a/apps/web/src/features/conversation/components/SiblingPicker.tsx b/apps/web/src/features/conversation/components/SiblingPicker.tsx index 70f6c4f..c64f340 100644 --- a/apps/web/src/features/conversation/components/SiblingPicker.tsx +++ b/apps/web/src/features/conversation/components/SiblingPicker.tsx @@ -1,10 +1,13 @@ import { ChevronLeftIcon, ChevronRightIcon } from '../../../shared/icons.tsx' -import type { BranchMessage } from '../lib/branch.ts' /** A message's alternative versions, and which one is shown. */ export interface Siblings { - messages: BranchMessage[] + /** The place of the one shown, and how many there are. */ index: number + count: number + /** The versions on either side, by rkey. */ + previous?: string + next?: string /** Show another version, by its rkey. */ onPick: (rkey: string) => void } @@ -19,27 +22,27 @@ const arrow = /** Arrows to step between alternative versions of a message, around a count that keeps its width. */ export function SiblingPicker(props: Props) { - const previous = props.siblings.messages[props.siblings.index - 1] - const next = props.siblings.messages[props.siblings.index + 1] + const previous = props.siblings.previous + const next = props.siblings.next return ( - {props.siblings.index + 1}/{props.siblings.messages.length} + {props.siblings.index + 1}/{props.siblings.count}