From 46d945935c7614b2e8364e954d78822dc213ac72 Mon Sep 17 00:00:00 2001 From: pdelfan Date: Fri, 16 Oct 2026 22:22:14 -0700 Subject: [PATCH] The web app's message records are typed as the lexicon's MessageRecord, and a conversation's info as InfoRecord, from the local copy, which holds only records the server validated, through BranchMessage to the message, status, details, part, and tool views, so their reads need no casts. src/shared/message.ts holds what a status means, isPending and isFinished, which counts a status another client wrote that this app doesn't know as neither, along with a message's parts, whether it is encrypted, and who wrote it, in place of the checks written out in the message view, the message actions, the chat, the reply follower, and tool status. Tests build records with messageRecord in tests/helpers/records.ts, and the status rules are tested once. --- CHANGELOG.md | 1 + apps/web/src/features/conversation/chat.ts | 3 +- .../components/BranchComposer.tsx | 2 +- .../components/ConversationView.tsx | 10 +- .../conversation/components/MessageView.tsx | 39 ++++---- .../conversation/components/PartsView.tsx | 5 +- .../conversation/components/PendingReply.tsx | 7 +- .../conversation/components/RecordDetails.tsx | 22 ++--- .../conversation/components/Status.tsx | 7 +- .../components/message-actions.tsx | 12 +-- .../src/features/conversation/lib/branch.ts | 14 ++- .../features/conversation/reply-follower.ts | 11 +-- .../src/features/sharing/pages/SharedPage.tsx | 3 +- apps/web/src/parts/WorkSteps.tsx | 17 ++-- apps/web/src/parts/tool-status.ts | 5 +- apps/web/src/parts/types.ts | 5 +- apps/web/src/shared/message.ts | 30 ++++++ apps/web/src/store/core.ts | 10 +- .../tests/features/conversation/chat.test.tsx | 9 +- .../components/MessageView.test.tsx | 98 ++++++++++--------- .../components/RecordDetails.test.tsx | 6 +- .../features/conversation/lib/branch.test.ts | 7 +- .../features/conversation/lib/outline.test.ts | 5 +- .../conversation/reply-follower.test.tsx | 5 +- apps/web/tests/helpers/records.ts | 16 +++ apps/web/tests/shared/message.test.ts | 35 +++++++ specs/extending.md | 2 +- 27 files changed, 244 insertions(+), 142 deletions(-) create mode 100644 apps/web/src/shared/message.ts create mode 100644 apps/web/tests/helpers/records.ts create mode 100644 apps/web/tests/shared/message.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2382ab4..859426b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ What changed in each release of SCN Chat, for the people who use it, run it, or - 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. +- For forkers: the web app's message records are typed as the lexicon's `MessageRecord`, from the local copy through `BranchMessage` to the part and tool views, and `src/shared/message.ts` holds what a status means (`isPending`, `isFinished`), a message's parts, and who wrote it. ## [0.2.1] - 2026-10-16 diff --git a/apps/web/src/features/conversation/chat.ts b/apps/web/src/features/conversation/chat.ts index cc76bd7..93a5f12 100644 --- a/apps/web/src/features/conversation/chat.ts +++ b/apps/web/src/features/conversation/chat.ts @@ -2,6 +2,7 @@ import type { ModelRef } from '@scn-chat/lexicons' import { useMutation } from '@tanstack/react-query' import { useRef } from 'react' import { api, json, read } from '../../shared/api.ts' +import { isPending } from '../../shared/message.ts' import { useConversation, useRefreshConversation } from '../../store/react.tsx' import { useBranch } from './hooks/useBranch.ts' import { useReplyFollower } from './reply-follower.ts' @@ -114,7 +115,7 @@ export function useChat(skey: string, options: ChatOptions = {}) { /** Why the conversation couldn't be read, if it couldn't. */ loadError: error, /** The reply running at the end of the branch, by key, if one is. */ - running: leaf?.record.status === 'pending' ? leaf.rkey : null, + running: leaf && isPending(leaf.record) ? 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) => regenerate({ parent, model }), diff --git a/apps/web/src/features/conversation/components/BranchComposer.tsx b/apps/web/src/features/conversation/components/BranchComposer.tsx index fec0244..f4064ce 100644 --- a/apps/web/src/features/conversation/components/BranchComposer.tsx +++ b/apps/web/src/features/conversation/components/BranchComposer.tsx @@ -26,7 +26,7 @@ interface Props { // The ref is kept out of `props`, as in the composer, so React Compiler still memoizes this. export function BranchComposer({ ref, ...props }: Props) { if (props.editing) { - const parent = props.editing.record.parent as string | undefined + const parent = props.editing.record.parent return ( (null) // Every chat has an info record from its creation, so until it arrives, from the local copy or // the PDS, the chat's title and instructions are unknown rather than empty. - const title = waiting && !info ? undefined : ((info?.title as string | undefined) ?? 'New chat') + const title = waiting && !info ? undefined : (info?.title ?? 'New chat') useDocumentTitle(title) // The messages there when the chat first loaded, which show at once. Only later ones fade in. const [opened, setOpened] = useState | null>(null) @@ -81,7 +81,7 @@ export function ConversationView(props: Props) { outlineOf(branch)} onRename={actions.rename} onSetInstructions={actions.setInstructions} @@ -100,11 +100,7 @@ export function ConversationView(props: Props) { actions={(message) => } // A failed reply tries again with the same model. retry={(message) => ( - - regenerate((message.record.parent as string | undefined) ?? null, null) - } - /> + regenerate(message.record.parent ?? null, null)} /> )} arrived={(message) => !opened?.has(message.rkey)} /> diff --git a/apps/web/src/features/conversation/components/MessageView.tsx b/apps/web/src/features/conversation/components/MessageView.tsx index 356f498..caf9860 100644 --- a/apps/web/src/features/conversation/components/MessageView.tsx +++ b/apps/web/src/features/conversation/components/MessageView.tsx @@ -1,8 +1,15 @@ import { type ReactNode, useId, useState } from 'react' import { stepsOf } from '../../../parts/steps.ts' -import type { BlobUrl, Part } from '../../../parts/types.ts' +import type { BlobUrl } from '../../../parts/types.ts' import { WorkButton, WorkList } from '../../../parts/WorkSteps.tsx' import { cn } from '../../../shared/cn.ts' +import { + authorOf, + isEncrypted, + isPending, + type MessageRecord, + partsOf, +} from '../../../shared/message.ts' import { PartsView } from './PartsView.tsx' import { PendingAuthor, PendingBody } from './PendingReply.tsx' import { SiblingPicker, type Siblings } from './SiblingPicker.tsx' @@ -12,7 +19,7 @@ export interface MessageViewProps { id?: string /** The message's record key, which its steps' times are kept by. */ rkey?: string - record: Record + record: MessageRecord blobUrl: BlobUrl siblings?: Siblings actions?: ReactNode @@ -22,24 +29,20 @@ export interface MessageViewProps { arrived?: boolean } -type ModelRef = { provider: string; id: string } - -/** Who wrote a message: the user, or the model that wrote the reply. */ -function author(record: Record): string { - if (record.role === 'user') return 'You' - return (record.model as ModelRef | undefined)?.id ?? 'Assistant' -} - interface AuthorProps { rkey?: string - record: Record + record: MessageRecord } /** 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 (!isPending(props.record)) return {authorOf(props.record)} return ( - + ) } @@ -51,11 +54,9 @@ function Author(props: AuthorProps) { */ export function MessageView(props: MessageViewProps) { const record = props.record - const content = record.content as { $type: string; parts?: Part[] } - const parts = content.parts ?? [] - const encrypted = content.$type.endsWith('#encryptedContent') + const parts = partsOf(record) // A pending reply shows its stream until its finished record replaces it. - const pending = record.status === 'pending' + const pending = isPending(record) const steps = pending ? [] : stepsOf(parts) const user = record.role === 'user' const workId = useId() @@ -64,7 +65,7 @@ export function MessageView(props: MessageViewProps) {
{pending && } - {encrypted &&

This message is encrypted.

} + {isEncrypted(record) &&

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, and a reply's steps button comes after them. They stay quiet until the message is diff --git a/apps/web/src/features/conversation/components/PartsView.tsx b/apps/web/src/features/conversation/components/PartsView.tsx index 84ffeb2..86eb3b0 100644 --- a/apps/web/src/features/conversation/components/PartsView.tsx +++ b/apps/web/src/features/conversation/components/PartsView.tsx @@ -2,12 +2,13 @@ import { StepsView, viewFor } from '../../../parts/index.ts' import { type Block, groupParts } from '../../../parts/steps.ts' import { type BlobUrl, kind, type Part } from '../../../parts/types.ts' import { ChevronDownIcon, LinkIcon } from '../../../shared/icons.tsx' +import type { MessageRecord } from '../../../shared/message.ts' import { plural } from '../../../shared/plural.ts' interface Props { parts: Part[] /** The message the parts belong to, which each part's view gets. */ - record: Record + record: MessageRecord /** Whether the reply is still streaming, so its last block of steps is still going. */ live?: boolean blobUrl: BlobUrl @@ -98,7 +99,7 @@ export function PartsView(props: Props) { interface SourcesProps { sources: Part[] - record: Record + record: MessageRecord blobUrl: BlobUrl } diff --git a/apps/web/src/features/conversation/components/PendingReply.tsx b/apps/web/src/features/conversation/components/PendingReply.tsx index b786a19..a287a9c 100644 --- a/apps/web/src/features/conversation/components/PendingReply.tsx +++ b/apps/web/src/features/conversation/components/PendingReply.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useState } from 'react' import { formatSeconds } from '../../../parts/step-clock.ts' import type { BlobUrl } from '../../../parts/types.ts' +import type { MessageRecord } from '../../../shared/message.ts' import { parseDate } from '../../../shared/time.ts' import type { ReplyParts } from '../lib/reply-stream.ts' import { useFollowedReply, useReplyStarted } from '../reply-follower.ts' @@ -92,7 +93,7 @@ function LiveAuthor(props: LiveAuthorProps) { interface BodyProps { rkey?: string /** The pending record, which the part views read as the stream's. */ - record: Record + record: MessageRecord blobUrl: BlobUrl } @@ -111,7 +112,7 @@ export function PendingBody(props: BodyProps) { interface StreamProps { skey: string rkey: string - record: Record + record: MessageRecord blobUrl: BlobUrl } @@ -139,7 +140,7 @@ function ReplyStream(props: StreamProps) { interface StreamingReplyProps { reply: ReplyParts - record: Record + record: MessageRecord blobUrl: BlobUrl /** The reply's key, so its steps' times carry over to the finished record. */ rkey?: string diff --git a/apps/web/src/features/conversation/components/RecordDetails.tsx b/apps/web/src/features/conversation/components/RecordDetails.tsx index 98b2999..cee4523 100644 --- a/apps/web/src/features/conversation/components/RecordDetails.tsx +++ b/apps/web/src/features/conversation/components/RecordDetails.tsx @@ -1,15 +1,14 @@ -import type { ModelRef } from '@scn-chat/lexicons' +import type { Usage } from '@scn-chat/lexicons' import { useId, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { cn } from '../../../shared/cn.ts' import { InfoIcon } from '../../../shared/icons.tsx' +import type { MessageRecord } from '../../../shared/message.ts' import { iconAction } from '../../../shared/ui/actions.ts' import { floating } from '../../../shared/ui/menu.ts' import { useDismiss } from '../../../shared/useDismiss.ts' import { usePlacement } from '../../../shared/usePlacement.ts' -type Usage = { inputTokens?: number; outputTokens?: number; reasoningTokens?: number } - const count = (n: number) => n.toLocaleString() function usageLine(usage: Usage): string | null { @@ -24,7 +23,7 @@ function usageLine(usage: Usage): string | null { interface RecordRowsProps { /** The message's record key. */ rkey?: string - record: Record + record: MessageRecord /** Where the record lives, such as the user's PDS. */ storedIn?: string } @@ -35,19 +34,16 @@ interface RecordRowsProps { */ export function recordRows(props: RecordRowsProps): [string, string][] { const record = props.record - const model = (record.model ?? (record.generation as { model?: ModelRef } | undefined)?.model) as - | ModelRef - | undefined - const effort = record.effort ?? (record.generation as { effort?: string } | undefined)?.effort - const usage = record.usage ? usageLine(record.usage as Usage) : null + const model = record.model ?? record.generation?.model + const effort = record.effort ?? record.generation?.effort + const usage = record.usage ? usageLine(record.usage) : null const rows: [string, string][] = [] if (model) rows.push([record.role === 'user' ? 'Asked for' : 'Model', `${model.provider} / ${model.id}`]) - if (effort) rows.push(['Effort', String(effort)]) + if (effort) rows.push(['Effort', effort]) if (usage) rows.push(['Tokens', usage]) - if (record.status) rows.push(['Status', String(record.status)]) - if (record.createdAt) - rows.push(['Written', new Date(record.createdAt as string).toLocaleString()]) + if (record.status) rows.push(['Status', record.status]) + if (record.createdAt) rows.push(['Written', new Date(record.createdAt).toLocaleString()]) if (props.rkey) rows.push(['Record key', props.rkey]) if (props.storedIn) rows.push(['Stored in', props.storedIn]) return rows diff --git a/apps/web/src/features/conversation/components/Status.tsx b/apps/web/src/features/conversation/components/Status.tsx index 67d8742..bef3607 100644 --- a/apps/web/src/features/conversation/components/Status.tsx +++ b/apps/web/src/features/conversation/components/Status.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' import { AlertIcon, StopIcon } from '../../../shared/icons.tsx' +import type { MessageRecord } from '../../../shared/message.ts' import { Badge } from '../../../shared/ui/Badge.tsx' /** @@ -10,7 +11,7 @@ export const statusLine = 'animate-breathe font-medium text-muted text-ui motion-reduce:animate-none' interface Props { - record: Record + record: MessageRecord /** What a failed reply offers beside its error, such as a button to try again. */ retry?: ReactNode } @@ -26,9 +27,9 @@ export function Status(props: Props) {
The reply failed - {Boolean(props.record.error) && ( + {props.record.error && (

- {props.record.error as string} + {props.record.error}

)} {props.retry} diff --git a/apps/web/src/features/conversation/components/message-actions.tsx b/apps/web/src/features/conversation/components/message-actions.tsx index d8164c3..59a0350 100644 --- a/apps/web/src/features/conversation/components/message-actions.tsx +++ b/apps/web/src/features/conversation/components/message-actions.tsx @@ -1,6 +1,7 @@ import type { ModelRef } from '@scn-chat/lexicons' import { type ComponentType, createContext, useContext } from 'react' import { PencilIcon, RetryIcon } from '../../../shared/icons.tsx' +import { isPending } from '../../../shared/message.ts' import { action, iconAction } from '../../../shared/ui/actions.ts' import { CopyButton } from '../../../shared/ui/CopyButton.tsx' import { messageText } from '../../../store/core.ts' @@ -44,8 +45,7 @@ export interface MessageAction { } const isUser = (message: BranchMessage) => message.record.role === 'user' -const isPending = (message: BranchMessage) => message.record.status === 'pending' -const parentOf = (message: BranchMessage) => (message.record.parent as string | undefined) ?? null +const parentOf = (message: BranchMessage) => message.record.parent ?? null const rowsOf = (props: MessageActionProps) => recordRows({ rkey: props.message.rkey, @@ -69,13 +69,13 @@ function EditAction(props: MessageActionProps) { } function CopyAction(props: MessageActionProps) { - if (isPending(props.message)) return null + if (isPending(props.message.record)) return null return } /** Regenerate a finished reply with the same model. */ function RegenerateAction(props: MessageActionProps) { - if (isUser(props.message) || isPending(props.message)) return null + if (isUser(props.message) || isPending(props.message.record)) return null return (