diff --git a/apps/web/src/components/MessageView.tsx b/apps/web/src/components/MessageView.tsx index 63cb960..1457422 100644 --- a/apps/web/src/components/MessageView.tsx +++ b/apps/web/src/components/MessageView.tsx @@ -18,6 +18,23 @@ export type MessageViewProps = { const kind = (part: Part) => part.$type.split('#')[1] +/** What tells a part apart within its message, since only tool parts carry an id. */ +function partIdentity(part: Part): string { + const blob = (part.image ?? part.file) as Blob | undefined + return `${part.$type}:${(part.callId ?? blob?.ref.$link ?? part.text ?? '') as string}` +} + +/** A message's parts with stable keys, counting repeats of the same identity. */ +function withKeys(parts: Part[]): { part: Part; key: string }[] { + const seen = new Map() + return parts.map((part) => { + const identity = partIdentity(part) + const count = seen.get(identity) ?? 0 + seen.set(identity, count + 1) + return { part, key: `${identity}#${count}` } + }) +} + /** An image from model output, loaded only on click, since loading it could leak the chat through its URL. */ function MarkdownImage({ src, alt }: { src: string; alt?: string }) { const [shown, setShown] = useState(false) @@ -127,6 +144,7 @@ export function MessageView({ const encrypted = content.$type.endsWith('#encryptedContent') const parts = content.parts ?? [] const sources = parts.filter((part) => kind(part) === 'sourcePart') + const shown = withKeys(parts.filter((part) => kind(part) !== 'sourcePart')) return (
@@ -151,12 +169,9 @@ export function MessageView({ )}
- {parts - .filter((part) => kind(part) !== 'sourcePart') - .map((part, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: a record's parts keep their order - - ))} + {shown.map(({ part, key }) => ( + + ))} {streaming !== undefined && record.status === 'pending' && ( <> {streaming.reasoning && ( diff --git a/apps/web/src/components/SearchResults.tsx b/apps/web/src/components/SearchResults.tsx index a5df292..a9d11ea 100644 --- a/apps/web/src/components/SearchResults.tsx +++ b/apps/web/src/components/SearchResults.tsx @@ -4,16 +4,15 @@ import { MATCH_END, MATCH_START, type SearchResult } from '../store/search.ts' /** A snippet with its marked matches as highlight elements. */ export function Snippet({ text }: { text: string }) { const pieces = text.split(new RegExp(`(${MATCH_START}[^${MATCH_END}]*${MATCH_END})`)) + // Each piece is keyed by where it starts in the text, which no other piece shares. + let start = 0 return ( <> - {pieces.map((piece, i) => - piece.startsWith(MATCH_START) ? ( - // biome-ignore lint/suspicious/noArrayIndexKey: pieces keep their order - {piece.slice(1, -1)} - ) : ( - piece - ), - )} + {pieces.map((piece) => { + const at = start + start += piece.length + return piece.startsWith(MATCH_START) ? {piece.slice(1, -1)} : piece + })} ) }