diff --git a/.changeset/perf-web-render.md b/.changeset/perf-web-render.md new file mode 100644 index 000000000000..af6c847a48ec --- /dev/null +++ b/.changeset/perf-web-render.md @@ -0,0 +1,5 @@ +--- +"@t3tools/web": patch +--- + +Smoother web chats: long conversations stop re-rendering on every update, and working-bot animations use far less GPU. diff --git a/apps/web/src/components/roster/BotAvatarView.tsx b/apps/web/src/components/roster/BotAvatarView.tsx index a3a060ff29ea..4f5aa6cfe83d 100644 --- a/apps/web/src/components/roster/BotAvatarView.tsx +++ b/apps/web/src/components/roster/BotAvatarView.tsx @@ -54,6 +54,9 @@ const EYES = [ { x: 13, y: -1, rotate: -20 }, ] as const; +/** Minimum frame spacing for the working pose, about 30fps with rAF jitter headroom. */ +const WORKING_FRAME_MS = 1000 / 30 - 2; + /** Radius the face travels on when it spins around the body. */ const BELT_RADIUS = 44; @@ -189,19 +192,60 @@ function BlobAvatar({ let visible = true; let frameId: number | null = null; let last = 0; + // Last values written to the DOM, so settled or repeated poses cost no style work. + let lastBody = ""; + // Switching between ink and cutout eyes replaces the eye nodes, so the cache is per node. + const lastEyes: Array<{ + node: SVGRectElement | null; + transform: string | null; + visible: boolean | null; + }> = [ + { node: null, transform: null, visible: null }, + { node: null, transform: null, visible: null }, + ]; const render = (frame: MotionFrame) => { - body.style.transform = bodyTransform(frame); + const nextBody = bodyTransform(frame); + if (nextBody !== lastBody) { + body.style.transform = nextBody; + lastBody = nextBody; + } for (const index of [0, 1] as const) { const eye = eyeRefs.current[index]; - if (!eye) continue; + const written = lastEyes[index]; + if (!eye || !written) continue; + if (written.node !== eye) { + written.node = eye; + written.transform = null; + written.visible = null; + } const transform = eyeTransform(shape, frame, index); - eye.setAttribute("visibility", transform ? "visible" : "hidden"); - if (transform) eye.setAttribute("transform", transform); + const visible = transform !== null; + if (visible !== written.visible) { + eye.setAttribute("visibility", visible ? "visible" : "hidden"); + written.visible = visible; + } + if (transform && transform !== written.transform) { + eye.setAttribute("transform", transform); + written.transform = transform; + } } }; const tick = (time: number) => { + // The continuous working bob reads fine at 30fps. Hover tracking and the spin keep + // the full display rate. + if ( + last !== 0 && + workingRef.current && + !hovered && + !motion.spinning && + time - last < WORKING_FRAME_MS + ) { + frameId = visible ? requestAnimationFrame(tick) : null; + if (frameId === null) last = 0; + return; + } const dt = last === 0 ? 1 / 60 : (time - last) / 1000; last = time; const { frame, active } = motion.tick(dt, { diff --git a/apps/web/src/components/roster/BotChatMessageRows.test.ts b/apps/web/src/components/roster/BotChatMessageRows.test.ts new file mode 100644 index 000000000000..d9cf3fcd9119 --- /dev/null +++ b/apps/web/src/components/roster/BotChatMessageRows.test.ts @@ -0,0 +1,64 @@ +import { EnvironmentId, MessageId, ThreadId, type OrchestrationMessage } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { assistantRowPropsEqual } from "./BotChatMessageRows"; + +type RowProps = Parameters[0]; + +const message: OrchestrationMessage = { + id: MessageId.make("reply-1"), + role: "assistant", + text: "Done.", + turnId: null, + streaming: false, + createdAt: "2026-09-26T00:00:00.000Z", + updatedAt: "2026-09-26T00:00:00.000Z", +}; +const engine = { provider: "codex", model: "gpt" } as const; +const result = { kind: "plugin-search-results" } as unknown as NonNullable< + RowProps["pluginResults"] +>[number]["result"]; + +function props(overrides: Partial = {}): RowProps { + return { + message, + author: { name: "Akeru", avatar: { kind: "dither", seed: "a" } }, + testId: "row", + cwd: "/work", + threadRef: { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }, + stepMeter: { engine, tokens: 10, costUsd: null, hardStopReached: false }, + pluginResults: [{ id: "entry-1", result }], + currentPersonId: "person", + playback: null, + playbackKey: "env/thread", + channelApproval: null, + onReply: vi.fn(), + onReactionChange: vi.fn(), + ...overrides, + } as RowProps; +} + +describe("assistant row memoization", () => { + it("skips re-rendering when per-turn data is rebuilt with the same content", () => { + const previous = props(); + const next = { + ...previous, + stepMeter: { ...previous.stepMeter!, engine: { ...engine } }, + pluginResults: previous.pluginResults!.map((entry) => ({ ...entry })), + }; + expect(assistantRowPropsEqual(previous, next)).toBe(true); + }); + + it("re-renders when the message, meter, playback context, or handlers change", () => { + const previous = props(); + expect(assistantRowPropsEqual(previous, { ...previous, message: { ...message } })).toBe(false); + expect( + assistantRowPropsEqual(previous, { + ...previous, + stepMeter: { ...previous.stepMeter!, tokens: 11 }, + }), + ).toBe(false); + expect(assistantRowPropsEqual(previous, { ...previous, playbackKey: null })).toBe(false); + expect(assistantRowPropsEqual(previous, { ...previous, onReply: vi.fn() })).toBe(false); + }); +}); diff --git a/apps/web/src/components/roster/BotChatMessageRows.tsx b/apps/web/src/components/roster/BotChatMessageRows.tsx new file mode 100644 index 000000000000..938ddc63fd2f --- /dev/null +++ b/apps/web/src/components/roster/BotChatMessageRows.tsx @@ -0,0 +1,353 @@ +import type { + AkeruPluginSearchResult, + BotId, + ChannelMessageOrigin, + EnvironmentId, + MessageId, + OrchestrationMessage, + ScopedThreadRef, + ThreadId, +} from "@t3tools/contracts"; +import type { ReplyPlaybackSession } from "@t3tools/client-runtime/reply-playback"; +import { memo, useCallback, useState } from "react"; + +import { replyPlaybackControlProps } from "~/lib/replyPlaybackThread"; +import { botEnvironment } from "../../state/bots"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; +import ChatMarkdown from "../ChatMarkdown"; +import { + MessageControls, + type MessageReactionOption, + selectedReactionForPerson, +} from "../chat/MessageControls"; +import { MessageReactions } from "../chat/MessageReactions"; +import { PluginSearchResultCard } from "../chat/PluginSearchResultCard"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { BotAvatarView } from "./BotAvatarView"; +import { BotMessageAttachments } from "./BotMessageAttachments"; +import { BotStepMeter } from "./BotStepMeter"; +import type { BotStepMeterData } from "./botStepMeter.logic"; +import { channelOriginLabel, channelProviderLabel } from "./botConversationPresentation"; +import type { Bot } from "./types"; + +const NO_ENVIRONMENT = "" as EnvironmentId; +const HOVER_CONTROLS_CLASS = + "opacity-0 transition-opacity pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/message:opacity-100 max-md:opacity-100"; +// Offscreen rows skip layout and paint; the intrinsic size keeps the scrollbar steady. +const ROW_VISIBILITY_CLASS = "[content-visibility:auto] [contain-intrinsic-size:auto_96px]"; + +export type MessageReplyHandler = (messageId: MessageId, label: string, text: string) => void; +export type MessageReactionHandler = ( + messageId: MessageId, + current: MessageReactionOption | null, + next: MessageReactionOption | null, +) => void; + +export interface PluginResultEntry { + readonly id: string; + readonly result: AkeruPluginSearchResult; +} + +export interface ChannelApprovalTarget { + readonly environmentId: EnvironmentId; + readonly botId: BotId; + readonly threadId: ThreadId; + readonly origin: ChannelMessageOrigin; + readonly sent: boolean; +} + +/** A stable reaction updater for memoized rows; it only changes with the linked thread. */ +export function useMessageReactionUpdater(threadRef: ScopedThreadRef | null) { + const setMessageReaction = useAtomCommand(threadEnvironment.setMessageReaction, { + reportFailure: false, + }); + return useCallback( + (messageId, current, next) => { + if (!threadRef) return; + const dispatch = (emoji: MessageReactionOption, present: boolean) => + setMessageReaction({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, messageId, emoji, present }, + }); + void (async () => { + if (current && current !== next) { + const removed = await dispatch(current, false); + if (removed._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not update reaction" }); + return; + } + } + if (next) { + const added = await dispatch(next, true); + if (added._tag === "Failure") { + toastManager.add({ type: "error", title: "Could not update reaction" }); + } + } + })(); + }, + [setMessageReaction, threadRef], + ); +} + +function userMessageCopyText(message: OrchestrationMessage) { + return ( + message.text || + message.attachments?.map((attachment) => attachment.name).join(", ") || + "Attachment" + ); +} + +export function ChannelSendApproval({ + environmentId, + botId, + origin, + threadId, + messageId, + sent, +}: { + readonly environmentId: EnvironmentId; + readonly botId: BotId; + readonly origin: ChannelMessageOrigin; + readonly threadId: ThreadId; + readonly messageId: MessageId; + readonly sent: boolean; +}) { + const send = useAtomCommand(botEnvironment.channels.send, { reportFailure: false }); + const [busy, setBusy] = useState(false); + const [submitted, setSubmitted] = useState(false); + const delivered = sent || submitted; + const label = channelProviderLabel(origin.provider); + return ( +
+ + {delivered ? `Sent to ${label}` : `Send this reply to ${label}?`} + + {!delivered ? ( + + ) : null} +
+ ); +} + +interface AssistantMessageRowProps { + readonly message: OrchestrationMessage; + /** The replying bot, or null when it is no longer available. */ + readonly author: Pick | null; + readonly testId: string; + readonly cwd: string | undefined; + readonly threadRef: ScopedThreadRef | undefined; + readonly stepMeter: BotStepMeterData | undefined; + readonly pluginResults: ReadonlyArray | undefined; + readonly currentPersonId: string | null | undefined; + readonly playback: ReplyPlaybackSession | null; + /** Changes when the playback context changes, so the read-aloud action is recomputed. */ + readonly playbackKey: string | null | undefined; + readonly channelApproval: ChannelApprovalTarget | null; + readonly onReply: MessageReplyHandler; + readonly onReactionChange: MessageReactionHandler; +} + +/** + * One assistant reply. Memoized with prop comparison that looks inside derived per-turn + * objects, because those are rebuilt whenever any thread activity arrives. + */ +export const AssistantMessageRow = memo(function AssistantMessageRow({ + message, + author, + testId, + cwd, + threadRef, + stepMeter, + pluginResults, + currentPersonId, + playback, + channelApproval, + onReply, + onReactionChange, +}: AssistantMessageRowProps) { + const readAloud = replyPlaybackControlProps(playback, message); + const copyText = message.text || "Attachment"; + const label = author?.name ?? "Unavailable bot"; + const markdown = ( + + ); + if (!author) { + return ( +
+
{label}
+ {markdown} +
+ onReply(message.id, label, copyText)} + /> +
+
+ ); + } + const selectedReaction = selectedReactionForPerson(message.reactions, currentPersonId); + return ( +
+ +
+
{author.name}
+ + {markdown} + {pluginResults?.map(({ id, result }) => ( + + ))} +
+ onReply(message.id, label, copyText)} + onReactionChange={(next) => onReactionChange(message.id, selectedReaction, next)} + /> +
+ + {channelApproval ? ( + + ) : null} +
+
+ ); +}, assistantRowPropsEqual); + +function shallowEqual(a: T | null | undefined, b: T | null | undefined) { + if (a === b) return true; + if (!a || !b) return false; + const keys = Object.keys(a) as (keyof T)[]; + return keys.length === Object.keys(b).length && keys.every((key) => Object.is(a[key], b[key])); +} + +function stepMetersEqual(a: BotStepMeterData | undefined, b: BotStepMeterData | undefined) { + if (a === b) return true; + if (!a || !b) return false; + return ( + a.tokens === b.tokens && + a.costUsd === b.costUsd && + a.hardStopReached === b.hardStopReached && + shallowEqual(a.engine, b.engine) + ); +} + +function pluginResultsEqual( + a: ReadonlyArray | undefined, + b: ReadonlyArray | undefined, +) { + if (a === b) return true; + if (!a || !b || a.length !== b.length) return false; + return a.every((entry, index) => entry.id === b[index]?.id && entry.result === b[index]?.result); +} + +export function assistantRowPropsEqual( + previous: AssistantMessageRowProps, + next: AssistantMessageRowProps, +) { + const keys = Object.keys(next) as (keyof AssistantMessageRowProps)[]; + if (keys.length !== Object.keys(previous).length) return false; + return keys.every((key) => { + switch (key) { + case "stepMeter": + return stepMetersEqual(previous.stepMeter, next.stepMeter); + case "pluginResults": + return pluginResultsEqual(previous.pluginResults, next.pluginResults); + case "channelApproval": + return shallowEqual(previous.channelApproval, next.channelApproval); + default: + return Object.is(previous[key], next[key]); + } + }); +} + +/** One message from a person. `replyLabel` names the author in reply previews. */ +export const UserMessageRow = memo(function UserMessageRow({ + message, + testId, + replyLabel, + showChannelOrigin, + environmentId, + currentPersonId, + onReply, + onReactionChange, +}: { + readonly message: OrchestrationMessage; + readonly testId: string; + readonly replyLabel: string; + readonly showChannelOrigin: boolean; + readonly environmentId: EnvironmentId | null; + readonly currentPersonId: string | null | undefined; + readonly onReply: MessageReplyHandler; + readonly onReactionChange: MessageReactionHandler; +}) { + const copyText = userMessageCopyText(message); + const selectedReaction = selectedReactionForPerson(message.reactions, currentPersonId); + return ( +
+
+ onReply(message.id, replyLabel, copyText)} + onReactionChange={(next) => onReactionChange(message.id, selectedReaction, next)} + /> +
+
+
+ {showChannelOrigin && message.channelOrigin ? ( +
+ {channelOriginLabel(message.channelOrigin, message.authorDisplayName)} +
+ ) : null} + {message.text ?

{message.text}

: null} + {message.attachments?.length ? ( +
+ +
+ ) : null} +
+ +
+
+ ); +}); diff --git a/apps/web/src/components/roster/BotRosterSidebar.tsx b/apps/web/src/components/roster/BotRosterSidebar.tsx index e62ca4fea40d..b2bff8bfac08 100644 --- a/apps/web/src/components/roster/BotRosterSidebar.tsx +++ b/apps/web/src/components/roster/BotRosterSidebar.tsx @@ -10,8 +10,7 @@ import { restrictToFirstScrollableAncestor } from "@dnd-kit/modifiers"; import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useAtomValue } from "@effect/atom-react"; -import { scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { BotId, EnvironmentId, GroupId, ThreadId } from "@t3tools/contracts"; +import { BotId, GroupId } from "@t3tools/contracts"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { ArrowDownIcon, @@ -45,7 +44,7 @@ import { cn, randomUUID } from "../../lib/utils"; import { isModelPickerOpen } from "../../modelPickerVisibility"; import { selectActiveRightPanel, useRightPanelStore } from "../../rightPanelStore"; import { botEnvironment } from "../../state/bots"; -import { useThreadMessages, useThreadShells } from "../../state/entities"; +import { useThreadMessages } from "../../state/entities"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { primaryServerKeybindingsAtom } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -61,7 +60,6 @@ import { BotAvatarView } from "./BotAvatarView"; import { DEFAULT_BOT_RUNTIME_MODE } from "./botSandbox"; import { visibleBotChatMessages } from "./botConversationPresentation"; import { useBotPresence } from "./botPresence"; -import { findLatestBotThreadTarget } from "./botThreadRuntime.logic"; import { NewBotDialog } from "./NewBotDialog"; import { NewGroupDialog, type NewGroupInput } from "./NewGroupDialog"; import { GroupMemberStack } from "./GroupMemberStack"; @@ -71,7 +69,6 @@ import { filterRosterGroups, formatRosterTimestamp, isRecordableChatPath, - parseChatPath, planRosterDrop, resolveLatestRosterMessage, resolveRosterDropTarget, @@ -100,7 +97,7 @@ import { createRosterListMotion } from "./roster.motion"; import { RosterDragLifecycle, RosterPointerSensor } from "./roster.pointer"; import { useRosterStore } from "./rosterStore"; import type { Bot, BotAvatar, Group } from "./types"; -import { useBotThreadRef } from "./useBotThreadRef"; +import { useBotThreadCandidate, useBotThreadRef } from "./useBotThreadRef"; /** Avatar with a yellow needs-you light and a green working light. */ function RosterAvatar({ @@ -200,18 +197,7 @@ function useLatestBotMessage( botId: string, fallback: RosterLastMessage | null, ): RosterLastMessage | null { - const rememberedPath = useRosterStore((state) => state.chatPathByBotId[botId]); - const environmentId = usePrimaryEnvironmentId(); - const threadShells = useThreadShells(); - const threadRef = useMemo(() => { - const durableTarget = environmentId - ? findLatestBotThreadTarget(botId, environmentId, threadShells) - : null; - const target = durableTarget ?? (rememberedPath ? parseChatPath(rememberedPath) : null); - return target - ? scopeThreadRef(EnvironmentId.make(target.environmentId), ThreadId.make(target.threadId)) - : null; - }, [botId, environmentId, rememberedPath, threadShells]); + const threadRef = useBotThreadCandidate(botId); const messages = useThreadMessages(threadRef); const visibleMessages = useMemo(() => visibleBotChatMessages(messages), [messages]); return useMemo( @@ -257,6 +243,12 @@ function sortableRootProps(sortable: SortableRosterRowBag) { const ROSTER_DRAG_LABEL_HEIGHT = 24; +/** Stable row actions, so memoized rows skip re-rendering when the sidebar does. */ +const setRosterItemPinned = (item: RosterItemRef, pinned: boolean) => + useRosterStore.getState().setItemPinned(item, pinned); +const nudgeRosterItem = (item: RosterItemRef, delta: -1 | 1) => + useRosterStore.getState().nudgeRosterItem(item, delta); + const BotRosterRow = memo(function BotRosterRow({ bot, lastMessage, @@ -276,13 +268,14 @@ const BotRosterRow = memo(function BotRosterRow({ onSelect: (bot: Bot) => void; onOpenSettings: (bot: Bot) => void; pinned: boolean; - onPin: (pinned: boolean) => void; + onPin: (item: RosterItemRef, pinned: boolean) => void; canMoveUp: boolean; canMoveDown: boolean; - onNudge: (delta: -1 | 1) => void; + onNudge: (item: RosterItemRef, delta: -1 | 1) => void; sortable: SortableRosterRowBag; }) { const menuTriggerRef = useRef(null); + const item = useMemo(() => ({ kind: "bot" as const, id: bot.id }), [bot.id]); const timestampFormat = useClientSettings((s) => s.timestampFormat); const presence = useBotPresence(bot.id); const latestMessage = useLatestBotMessage(bot.id, lastMessage); @@ -365,15 +358,15 @@ const BotRosterRow = memo(function BotRosterRow({ Bot settings - onPin(!pinned)}> + onPin(item, !pinned)}> {pinned ? "Unpin" : "Pin"} - onNudge(-1)}> + onNudge(item, -1)}> Move up - onNudge(1)}> + onNudge(item, 1)}> Move down @@ -423,7 +416,7 @@ function RailBotButton({ ); } -function GroupRosterRow({ +const GroupRosterRow = memo(function GroupRosterRow({ group, bots, isActive, @@ -438,15 +431,16 @@ function GroupRosterRow({ group: Group; bots: readonly Bot[]; isActive: boolean; - onSelect: () => void; + onSelect: (group: Group) => void; pinned: boolean; - onPin: (pinned: boolean) => void; + onPin: (item: RosterItemRef, pinned: boolean) => void; canMoveUp: boolean; canMoveDown: boolean; - onNudge: (delta: -1 | 1) => void; + onNudge: (item: RosterItemRef, delta: -1 | 1) => void; sortable: SortableRosterRowBag; }) { const menuTriggerRef = useRef(null); + const item = useMemo(() => ({ kind: "group" as const, id: group.id }), [group.id]); const members = group.members.filter( (member) => member.kind === "bot" && bots.some((bot) => bot.id === member.botId && !bot.archivedAt), @@ -474,7 +468,7 @@ function GroupRosterRow({ - ) : null} - - ); -} - export function BotThreadLanding({ botId }: { readonly botId: string }) { const navigate = useNavigate(); const environmentId = usePrimaryEnvironmentId(); @@ -134,9 +66,6 @@ export function BotThreadLanding({ botId }: { readonly botId: string }) { const canManageChannelBindings = canManageChannels(channelSession.data); const settings = usePrimarySettings(); const providers = useAtomValue(primaryServerProvidersAtom); - const setMessageReaction = useAtomCommand(threadEnvironment.setMessageReaction, { - reportFailure: false, - }); const bots = useRosterStore((state) => state.bots); const bot = bots.find((candidate) => candidate.id === botId); const [replyTarget, setReplyTarget] = useState(null); @@ -215,14 +144,22 @@ export function BotThreadLanding({ botId }: { readonly botId: string }) { respondingToUserInput: runtime.respondingRequestIds.length > 0, presence, }); - const messages = visibleBotChatMessages(runtime.messages, working); + const messages = useMemo( + () => visibleBotChatMessages(runtime.messages, working), + [runtime.messages, working], + ); const available = bot?.archivedAt === null; - useReplyPlaybackThread({ + const playbackKey = useReplyPlaybackThread({ environmentId: available ? (runtime.linkedThreadRef?.environmentId ?? environmentId) : null, threadId: available ? runtime.linkedThreadRef?.threadId : null, messages: available ? messages : [], mediaBlocked: Boolean(voiceCall.activeCall || voiceCall.startingBotId), }); + const updateReaction = useMessageReactionUpdater(runtime.linkedThreadRef); + const replyTo = useCallback( + (messageId, label, text) => setReplyTarget({ messageId, label, text }), + [], + ); if (!bot || bot.archivedAt !== null) return null; const assistantTurnIds = new Set( @@ -241,38 +178,21 @@ export function BotThreadLanding({ botId }: { readonly botId: string }) { ) ?? []) : []; const currentPersonId = snapshot?.currentPersonId; - const updateReaction = async ( - messageId: MessageId, - current: MessageReactionOption | null, - next: MessageReactionOption | null, - ) => { - const threadRef = runtime.linkedThreadRef; - if (!threadRef) return; - const dispatch = (emoji: MessageReactionOption, present: boolean) => - setMessageReaction({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - messageId, - emoji, - present, - }, - }); - if (current && current !== next) { - const removed = await dispatch(current, false); - if (removed._tag === "Failure") { - toastManager.add({ type: "error", title: "Could not update reaction" }); - return; - } - } - if (next) { - const added = await dispatch(next, true); - if (added._tag === "Failure") { - toastManager.add({ type: "error", title: "Could not update reaction" }); - } - } + const linkedThreadId = runtime.linkedThreadRef?.threadId; + const channelApprovalFor = (messageIndex: number): ChannelApprovalTarget | null => { + if (!canManageChannelBindings || !environmentId || !linkedThreadId) return null; + const message = messages[messageIndex]; + const origin = channelOriginForAssistantMessage(messages, messageIndex); + const binding = origin ? connectedChannelBinding(bot.channelBindings, origin.provider) : null; + if (!message || !origin || !binding) return null; + return { + environmentId, + botId: BotId.make(bot.id), + threadId: linkedThreadId, + origin, + sent: binding.sentMessageIds.includes(message.id), + }; }; - return ( message.role === "assistant" ? ( -
- -
-
{bot.name}
- - - {message.turnId === null - ? null - : pluginResultsByTurn - .get(message.turnId) - ?.map(({ id, result }) => ( - - ))} -
- { - const readAloud = replyPlaybackControlProps(replyPlayback, message); - return readAloud ? { readAloud } : {}; - })()} - selectedReaction={selectedReactionForPerson( - message.reactions, - currentPersonId, - )} - onReply={() => - setReplyTarget({ - messageId: message.id, - label: bot.name, - text: message.text || "Attachment", - }) - } - onReactionChange={(next) => - void updateReaction( - message.id, - selectedReactionForPerson(message.reactions, currentPersonId), - next, - ) - } - /> -
- - {canManageChannelBindings && environmentId && runtime.linkedThreadRef - ? (() => { - const origin = channelOriginForAssistantMessage(messages, messageIndex); - const binding = origin - ? connectedChannelBinding(bot.channelBindings, origin.provider) - : undefined; - return origin && binding ? ( - - ) : null; - })() - : null} -
-
+ message={message} + author={bot} + testId="bot-provider-message" + cwd={runtime.defaultProject?.workspaceRoot} + threadRef={runtime.linkedThreadRef ?? undefined} + stepMeter={message.turnId === null ? undefined : stepMeters.get(message.turnId)} + pluginResults={ + message.turnId === null ? undefined : pluginResultsByTurn.get(message.turnId) + } + currentPersonId={currentPersonId} + playback={replyPlayback} + playbackKey={playbackKey} + channelApproval={channelApprovalFor(messageIndex)} + onReply={replyTo} + onReactionChange={updateReaction} + /> ) : ( -
-
- attachment.name).join(", ") || - "Attachment" - } - selectedReaction={selectedReactionForPerson( - message.reactions, - currentPersonId, - )} - onReply={() => - setReplyTarget({ - messageId: message.id, - label: "you", - text: - message.text || - message.attachments - ?.map((attachment) => attachment.name) - .join(", ") || - "Attachment", - }) - } - onReactionChange={(next) => - void updateReaction( - message.id, - selectedReactionForPerson(message.reactions, currentPersonId), - next, - ) - } - /> -
-
-
- {message.channelOrigin ? ( -
- {channelOriginLabel(message.channelOrigin, message.authorDisplayName)} -
- ) : null} - {message.text ? ( -

{message.text}

- ) : null} - {message.attachments?.length ? ( -
- -
- ) : null} -
- -
-
+ message={message} + testId="bot-user-message" + replyLabel="you" + showChannelOrigin + environmentId={environmentId} + currentPersonId={currentPersonId} + onReply={replyTo} + onReactionChange={updateReaction} + /> ), ) )} diff --git a/apps/web/src/components/roster/GroupThreadLanding.tsx b/apps/web/src/components/roster/GroupThreadLanding.tsx index 839ff9943e3a..d5e080a0f9c9 100644 --- a/apps/web/src/components/roster/GroupThreadLanding.tsx +++ b/apps/web/src/components/roster/GroupThreadLanding.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; -import { type EnvironmentId, type MessageId } from "@t3tools/contracts"; -import { useEffect, useMemo, useState } from "react"; +import { type EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { selectOpenBotInboxItems } from "../../botInbox"; import { openSettings } from "../../settingsDialogStore"; @@ -10,22 +10,11 @@ import { useEnvironmentQuery } from "../../state/query"; import { useThreadActivities } from "../../state/entities"; import { serverEnvironment } from "../../state/server"; import { environmentSnapshotAtom } from "../../state/shell"; -import { threadEnvironment } from "../../state/threads"; -import { useAtomCommand } from "../../state/use-atom-command"; import { SidebarInset } from "../ui/sidebar"; -import { toastManager } from "../ui/toast"; -import ChatMarkdown from "../ChatMarkdown"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; -import { - buildReplyPrompt, - MessageControls, - type MessageReactionOption, - type MessageReplyTarget, - selectedReactionForPerson, -} from "../chat/MessageControls"; -import { MessageReactions } from "../chat/MessageReactions"; +import { buildReplyPrompt, type MessageReplyTarget } from "../chat/MessageControls"; import { useOptionalReplyPlayback } from "../chat/ReplyPlaybackProvider"; -import { replyPlaybackControlProps, useReplyPlaybackThread } from "~/lib/replyPlaybackThread"; +import { useReplyPlaybackThread } from "~/lib/replyPlaybackThread"; import { ThreadErrorBanner } from "../chat/ThreadErrorBanner"; import { useOptionalVoiceCall } from "../voice/VoiceCall"; import { BotActivityStatus } from "./BotActivityStatus"; @@ -38,8 +27,12 @@ import { DelegationCard } from "./DelegationCard"; import { GroupMemberStack } from "./GroupMemberStack"; import { visibleBotChatMessages } from "./botConversationPresentation"; import { BotPromptComposer } from "./BotPromptComposer"; -import { BotMessageAttachments } from "./BotMessageAttachments"; -import { BotStepMeter } from "./BotStepMeter"; +import { + AssistantMessageRow, + type MessageReplyHandler, + UserMessageRow, + useMessageReactionUpdater, +} from "./BotChatMessageRows"; import { buildBotStepMeters } from "./botStepMeter.logic"; import { useGroupPresence } from "./botPresence"; import { groupBotMembers, isCurrentGroupPerson } from "./roster.logic"; @@ -70,9 +63,6 @@ export function GroupThreadLanding({ groupId }: { readonly groupId: string }) { const runtime = useGroupThreadRuntime(groupId); const replyPlayback = useOptionalReplyPlayback(); const voiceCall = useOptionalVoiceCall(); - const setMessageReaction = useAtomCommand(threadEnvironment.setMessageReaction, { - reportFailure: false, - }); const [replyTarget, setReplyTarget] = useState(null); const approvalState = useRosterPendingApproval(runtime.linkedThreadRef); const activities = useThreadActivities(runtime.linkedThreadRef); @@ -93,15 +83,21 @@ export function GroupThreadLanding({ groupId }: { readonly groupId: string }) { setReplyTarget(null); }, [groupId, runtime.linkedThreadRef?.environmentId, runtime.linkedThreadRef?.threadId]); - const messages = visibleBotChatMessages(runtime.messages); - useReplyPlaybackThread({ + const messages = useMemo(() => visibleBotChatMessages(runtime.messages), [runtime.messages]); + const playbackKey = useReplyPlaybackThread({ environmentId: group ? (runtime.linkedThreadRef?.environmentId ?? environmentId) : null, threadId: group ? runtime.linkedThreadRef?.threadId : null, messages: group ? messages : [], mediaBlocked: Boolean(voiceCall?.activeCall || voiceCall?.startingBotId), }); + const updateReaction = useMessageReactionUpdater(runtime.linkedThreadRef); + const replyTo = useCallback( + (messageId, label, text) => setReplyTarget({ messageId, label, text }), + [], + ); if (!group) return null; + const currentPersonId = peopleIdentity.current?.id; const members = groupBotMembers(group, bots).filter((bot) => bot.archivedAt === null); const boss = resolveAvailableGroupBoss(members, group.bossBotId); const working = @@ -118,38 +114,6 @@ export function GroupThreadLanding({ groupId }: { readonly groupId: string }) { (delegation) => delegation.parentThreadId === runtime.linkedThreadRef?.threadId, ) ?? []) : []; - const updateReaction = async ( - messageId: MessageId, - current: MessageReactionOption | null, - next: MessageReactionOption | null, - ) => { - const threadRef = runtime.linkedThreadRef; - if (!threadRef) return; - const dispatch = (emoji: MessageReactionOption, present: boolean) => - setMessageReaction({ - environmentId: threadRef.environmentId, - input: { - threadId: threadRef.threadId, - messageId, - emoji, - present, - }, - }); - if (current && current !== next) { - const removed = await dispatch(current, false); - if (removed._tag === "Failure") { - toastManager.add({ type: "error", title: "Could not update reaction" }); - return; - } - } - if (next) { - const added = await dispatch(next, true); - if (added._tag === "Failure") { - toastManager.add({ type: "error", title: "Could not update reaction" }); - } - } - }; - return ( bot.id === message.respondingBotId) : boss; - if (!respondingBot) { - return ( -
-
Unavailable bot
- -
- { - const readAloud = replyPlaybackControlProps(replyPlayback, message); - return readAloud ? { readAloud } : {}; - })()} - onReply={() => - setReplyTarget({ - messageId: message.id, - label: "Unavailable bot", - text: message.text || "Attachment", - }) - } - /> -
-
- ); - } return ( -
- -
-
{respondingBot.name}
- - -
- { - const readAloud = replyPlaybackControlProps(replyPlayback, message); - return readAloud ? { readAloud } : {}; - })()} - selectedReaction={selectedReactionForPerson( - message.reactions, - peopleIdentity.current?.id, - )} - onReply={() => - setReplyTarget({ - messageId: message.id, - label: respondingBot.name, - text: message.text || "Attachment", - }) - } - onReactionChange={(next) => - void updateReaction( - message.id, - selectedReactionForPerson( - message.reactions, - peopleIdentity.current?.id, - ), - next, - ) - } - /> -
- -
-
+ message={message} + author={respondingBot ?? null} + testId="group-provider-message" + cwd={runtime.defaultProject?.workspaceRoot} + threadRef={runtime.linkedThreadRef ?? undefined} + stepMeter={message.turnId === null ? undefined : stepMeters.get(message.turnId)} + pluginResults={undefined} + currentPersonId={currentPersonId} + playback={replyPlayback} + playbackKey={playbackKey} + channelApproval={null} + onReply={replyTo} + onReactionChange={updateReaction} + /> ); } const current = isCurrentGroupPerson( message.authorPersonId, - peopleIdentity.current?.id, + currentPersonId, peopleIdentity.host?.id, ); return ( -
-
- attachment.name).join(", ") || - "Attachment" - } - selectedReaction={selectedReactionForPerson( - message.reactions, - peopleIdentity.current?.id, - )} - onReply={() => - setReplyTarget({ - messageId: message.id, - label: current ? "you" : "participant", - text: - message.text || - message.attachments?.map((attachment) => attachment.name).join(", ") || - "Attachment", - }) - } - onReactionChange={(next: MessageReactionOption | null) => - void updateReaction( - message.id, - selectedReactionForPerson(message.reactions, peopleIdentity.current?.id), - next, - ) - } - /> -
-
-
- {message.text ?

{message.text}

: null} - {message.attachments?.length ? ( -
- -
- ) : null} -
- -
-
+ message={message} + testId="group-user-message" + replyLabel={current ? "you" : "participant"} + showChannelOrigin={false} + environmentId={environmentId} + currentPersonId={currentPersonId} + onReply={replyTo} + onReactionChange={updateReaction} + /> ); }) )} diff --git a/apps/web/src/components/roster/ThreadLanding.delegations.test.tsx b/apps/web/src/components/roster/ThreadLanding.delegations.test.tsx index 60b09b05579e..1c291786070c 100644 --- a/apps/web/src/components/roster/ThreadLanding.delegations.test.tsx +++ b/apps/web/src/components/roster/ThreadLanding.delegations.test.tsx @@ -164,9 +164,9 @@ vi.mock("./useGroupThreadRuntime", () => ({ }), })); +import { AssistantMessageRow } from "./BotChatMessageRows"; import { BotThreadLanding } from "./BotThreadLanding"; import { ComposerPendingUserInputPanel } from "../chat/ComposerPendingUserInputPanel"; -import { PluginSearchResultCard } from "../chat/PluginSearchResultCard"; import { DelegationCard } from "./DelegationCard"; import { GroupThreadLanding } from "./GroupThreadLanding"; import { BotUserInputPrompt } from "./BotUserInputPrompt"; @@ -335,13 +335,14 @@ describe("thread landing delegations", () => { ]; hooks.beginRender(); - const card = visitElements( + const row = visitElements( BotThreadLanding({ botId: parentBot.id }), - (element) => element.type === PluginSearchResultCard, - ) as ReactElement[0]> | null; + (element) => element.type === AssistantMessageRow, + ) as ReactElement[0]> | null; + const result = row?.props.pluginResults?.[0]?.result; - expect(card?.props.result.query).toBe("email"); - expect(card?.props.result.recommendations[0]?.name).toBe("Gmail"); + expect(result?.query).toBe("email"); + expect(result?.recommendations[0]?.name).toBe("Gmail"); }); it("renders provider questions inside the bot conversation", () => { diff --git a/apps/web/src/components/roster/ThreadLanding.hookOrder.test.tsx b/apps/web/src/components/roster/ThreadLanding.hookOrder.test.tsx index 67415438cf0f..a7b95d5e4cf3 100644 --- a/apps/web/src/components/roster/ThreadLanding.hookOrder.test.tsx +++ b/apps/web/src/components/roster/ThreadLanding.hookOrder.test.tsx @@ -30,8 +30,10 @@ vi.mock("../../state/session", () => ({ useEnvironmentSessionState: () => ({ data: null, isPending: false }), })); vi.mock("./botEngineSelection", () => ({ resolveStickyBotEngine: () => null })); -vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); -vi.mock("../../state/bots", () => ({ environmentPeopleAtom: () => null })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: unknown) => (atom === "people" ? { current: null, host: null } : null), +})); +vi.mock("../../state/bots", () => ({ environmentPeopleAtom: () => "people" })); vi.mock("../../state/environments", () => ({ usePrimaryEnvironmentId: () => EnvironmentId.make("environment-1"), useEnvironmentConnectionState: () => ({ data: null }), diff --git a/apps/web/src/components/roster/botAvatarMotion.test.ts b/apps/web/src/components/roster/botAvatarMotion.test.ts index b9afa3d640b5..3d8cc8d5bb5b 100644 --- a/apps/web/src/components/roster/botAvatarMotion.test.ts +++ b/apps/web/src/components/roster/botAvatarMotion.test.ts @@ -30,6 +30,22 @@ describe("BotMotion", () => { expect(after.frame.spin).toBe(0); }); + it("reports the working spin while it plays so the view can render it at full rate", () => { + const motion = new BotMotion(0.3); + const working = { ...rest, working: true }; + let sawSpin = false; + for (let frame = 0; frame < 60 * 8 && !sawSpin; frame++) { + const { frame: pose } = motion.tick(1 / 60, working); + if (pose.spin !== 0) { + sawSpin = true; + expect(motion.spinning).toBe(true); + } + } + expect(sawSpin).toBe(true); + run(motion, rest, 6); + expect(motion.spinning).toBe(false); + }); + it("turns the eyes toward the pointer and enlarges them on hover", () => { const motion = new BotMotion(0.3); const { frame } = run(motion, { ...rest, hovered: true, pointer: { x: 1, y: 0 } }, 1.5); diff --git a/apps/web/src/components/roster/botAvatarMotion.ts b/apps/web/src/components/roster/botAvatarMotion.ts index 1c1d652c18b1..529ec79b443d 100644 --- a/apps/web/src/components/roster/botAvatarMotion.ts +++ b/apps/web/src/components/roster/botAvatarMotion.ts @@ -127,6 +127,11 @@ export class BotMotion { this.scheduleIdle(); } + /** True while the belt spin plays; callers render it at the full frame rate. */ + get spinning() { + return this.spin !== null; + } + /** Plays a short lively moment on a resting avatar. */ beat(lengthMs: number) { this.beatUntil = this.clock + lengthMs / 1000; diff --git a/apps/web/src/components/roster/botPresence.ts b/apps/web/src/components/roster/botPresence.ts index ab70baf73d4b..f66d24cd55b0 100644 --- a/apps/web/src/components/roster/botPresence.ts +++ b/apps/web/src/components/roster/botPresence.ts @@ -1,48 +1,26 @@ -import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type ScopedThreadRef } from "@t3tools/contracts"; import { useMemo } from "react"; -import { useThreadShell, useThreadShells } from "../../state/entities"; +import { useLatestGroupThreadId, useThreadShell } from "../../state/entities"; import { usePrimaryEnvironmentId } from "../../state/environments"; -import { findLatestBotThreadTarget, findLatestGroupThreadTarget } from "./botThreadRuntime.logic"; -import { parseChatPath, resolveBotPresence, type RosterPresence } from "./roster.logic"; -import { useRosterStore } from "./rosterStore"; +import { resolveBotPresence, type RosterPresence } from "./roster.logic"; +import { useBotThreadCandidate } from "./useBotThreadRef"; /** * Live presence for one bot, derived from its latest durable server thread. */ export function useBotPresence(botId: string): RosterPresence { - const chatPath = useRosterStore((state) => state.chatPathByBotId[botId] ?? null); - const environmentId = usePrimaryEnvironmentId(); - const threadShells = useThreadShells(); - const ref = useMemo(() => { - const durableTarget = environmentId - ? findLatestBotThreadTarget(botId, environmentId, threadShells) - : null; - const target = durableTarget ?? (chatPath === null ? null : parseChatPath(chatPath)); - return target - ? { - environmentId: EnvironmentId.make(target.environmentId), - threadId: ThreadId.make(target.threadId), - } - : null; - }, [botId, chatPath, environmentId, threadShells]); - return resolveBotPresence(useThreadShell(ref)); + return resolveBotPresence(useThreadShell(useBotThreadCandidate(botId))); } /** Live presence for a group, derived from its latest durable server thread. */ export function useGroupPresence(groupId: string): RosterPresence { const environmentId = usePrimaryEnvironmentId(); - const threadShells = useThreadShells(); - const ref = useMemo(() => { - const target = environmentId - ? findLatestGroupThreadTarget(groupId, environmentId, threadShells) - : null; - return target - ? { - environmentId: EnvironmentId.make(target.environmentId), - threadId: ThreadId.make(target.threadId), - } - : null; - }, [environmentId, groupId, threadShells]); + const threadId = useLatestGroupThreadId(environmentId, groupId); + const ref = useMemo( + () => (environmentId && threadId ? scopeThreadRef(environmentId, threadId) : null), + [environmentId, threadId], + ); return resolveBotPresence(useThreadShell(ref)); } diff --git a/apps/web/src/components/roster/useBotThreadRef.ts b/apps/web/src/components/roster/useBotThreadRef.ts index c85cafc10b71..42b42c18d667 100644 --- a/apps/web/src/components/roster/useBotThreadRef.ts +++ b/apps/web/src/components/roster/useBotThreadRef.ts @@ -3,26 +3,41 @@ import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contract import { useMemo } from "react"; import { usePrimaryEnvironmentId } from "../../state/environments"; -import { useThreadShell, useThreadShells } from "../../state/entities"; -import { resolveBotThreadTarget } from "./botThreadRuntime.logic"; +import { useLatestBotThreadId, useThreadShell } from "../../state/entities"; +import { parseChatPath } from "./roster.logic"; import { useRosterStore } from "./rosterStore"; -export function useBotThreadRef(botId: string): ScopedThreadRef | null { +/** + * The bot's latest durable thread in the primary environment, falling back to + * the chat path the roster remembered for it. Subscribes to that one bot's + * latest thread id, not the whole thread list. + */ +export function useBotThreadCandidate( + botId: string, + options?: { readonly rememberedInPrimaryOnly?: boolean }, +): ScopedThreadRef | null { const environmentId = usePrimaryEnvironmentId(); - const threads = useThreadShells(); + const latestThreadId = useLatestBotThreadId(environmentId, botId); const rememberedPath = useRosterStore((state) => state.chatPathByBotId[botId]); - const candidate = environmentId - ? resolveBotThreadTarget(botId, environmentId, threads, rememberedPath) - : null; - const ref = useMemo( + const remembered = + latestThreadId === null && rememberedPath ? parseChatPath(rememberedPath) : null; + const rememberedUsable = + remembered !== null && + (options?.rememberedInPrimaryOnly !== true || remembered.environmentId === environmentId); + const targetEnvironmentId = + latestThreadId !== null ? environmentId : rememberedUsable ? remembered.environmentId : null; + const targetThreadId = + latestThreadId !== null ? latestThreadId : rememberedUsable ? remembered.threadId : null; + return useMemo( () => - candidate - ? scopeThreadRef( - EnvironmentId.make(candidate.environmentId), - ThreadId.make(candidate.threadId), - ) + targetEnvironmentId && targetThreadId + ? scopeThreadRef(EnvironmentId.make(targetEnvironmentId), ThreadId.make(targetThreadId)) : null, - [candidate?.environmentId, candidate?.threadId], + [targetEnvironmentId, targetThreadId], ); +} + +export function useBotThreadRef(botId: string): ScopedThreadRef | null { + const ref = useBotThreadCandidate(botId, { rememberedInPrimaryOnly: true }); return useThreadShell(ref) ? ref : null; } diff --git a/apps/web/src/components/roster/useBotThreadRuntime.ts b/apps/web/src/components/roster/useBotThreadRuntime.ts index 221c3408c9bb..233d2e356c15 100644 --- a/apps/web/src/components/roster/useBotThreadRuntime.ts +++ b/apps/web/src/components/roster/useBotThreadRuntime.ts @@ -100,11 +100,15 @@ export function useBotThreadRuntime(botId: string, effectiveModelSelection: Mode const target = primaryEnvironmentId ? resolveBotThreadTarget(botId, primaryEnvironmentId, primaryThreadShells, rememberedPath) : null; - const rememberedThreadRef = useMemo(() => { - return target - ? scopeThreadRef(EnvironmentId.make(target.environmentId), ThreadId.make(target.threadId)) - : null; - }, [target]); + const targetEnvironmentId = target?.environmentId; + const targetThreadId = target?.threadId; + const rememberedThreadRef = useMemo( + () => + targetEnvironmentId && targetThreadId + ? scopeThreadRef(EnvironmentId.make(targetEnvironmentId), ThreadId.make(targetThreadId)) + : null, + [targetEnvironmentId, targetThreadId], + ); const rememberedThread = useThreadShell(rememberedThreadRef); const linkedThreadRef = rememberedThread ? rememberedThreadRef : null; const retainedThreadRef = useRef<{ botId: string; threadRef: ScopedThreadRef | null }>({ diff --git a/apps/web/src/components/roster/useGroupThreadRuntime.ts b/apps/web/src/components/roster/useGroupThreadRuntime.ts index 3c59e90e700e..054fd435c788 100644 --- a/apps/web/src/components/roster/useGroupThreadRuntime.ts +++ b/apps/web/src/components/roster/useGroupThreadRuntime.ts @@ -98,15 +98,14 @@ export function useGroupThreadRuntime(groupId: string) { const serverTarget = primaryEnvironmentId ? findLatestGroupThreadTarget(groupId, primaryEnvironmentId, primaryThreadShells) : null; + const targetEnvironmentId = serverTarget?.environmentId; + const targetThreadId = serverTarget?.threadId; const rememberedThreadRef = useMemo( () => - serverTarget - ? scopeThreadRef( - EnvironmentId.make(serverTarget.environmentId), - ThreadId.make(serverTarget.threadId), - ) + targetEnvironmentId && targetThreadId + ? scopeThreadRef(EnvironmentId.make(targetEnvironmentId), ThreadId.make(targetThreadId)) : null, - [serverTarget], + [targetEnvironmentId, targetThreadId], ); const rememberedThread = useThreadShell(rememberedThreadRef); const linkedThreadRef = rememberedThread ? rememberedThreadRef : null; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 298f33aec4f7..3f95091f79b2 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -2503,7 +2503,9 @@ code { linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude; - animation: ultrathink-rainbow 10s linear infinite; + /* Stepped to about 10fps: the slow drift reads the same and stops a + per-frame repaint of the masked ring. */ + animation: ultrathink-rainbow 10s steps(100) infinite; } .ultrathink-frame > * { @@ -2511,7 +2513,14 @@ code { } .ultrathink-chroma { - animation: ultrathink-chroma-shift 10s linear infinite; + animation: ultrathink-chroma-shift 10s steps(60) infinite; +} + +@media (prefers-reduced-motion: reduce) { + .ultrathink-frame::before, + .ultrathink-chroma { + animation: none; + } } @keyframes preview-loading-progress { @@ -2577,7 +2586,10 @@ code { background-position: 120% 0; background-size: 240% 100%; background-clip: text; - animation: bot-status-shimmer 1.8s linear infinite; + /* background-clip: text cannot move on the compositor, so each frame + repaints the text. Stepping to about 15fps keeps the sweep smooth enough + at a quarter of the paint work on high-refresh displays. */ + animation: bot-status-shimmer 1.8s steps(27) infinite; } @keyframes bot-status-shimmer { diff --git a/apps/web/src/lib/replyPlaybackThread.ts b/apps/web/src/lib/replyPlaybackThread.ts index 433dd09f5884..ed20b225c2eb 100644 --- a/apps/web/src/lib/replyPlaybackThread.ts +++ b/apps/web/src/lib/replyPlaybackThread.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import type { ReplyPlaybackMessage, @@ -23,6 +23,11 @@ export function replyPlaybackControlProps( }; } +/** + * Binds the reply playback session to one thread and feeds it settled messages. + * Returns a key that changes once the session context is ready, so memoized rows that call + * `replyPlaybackControlProps` can take it as a prop and re-render when their actions change. + */ export function useReplyPlaybackThread(options: { readonly environmentId: EnvironmentId | null | undefined; readonly threadId: string | null | undefined; @@ -31,13 +36,21 @@ export function useReplyPlaybackThread(options: { }) { const session = useOptionalReplyPlayback(); const connection = useEnvironmentConnectionState(options.environmentId ?? null); - const signature = options.messages - .map((message) => `${message.id}:${message.updatedAt}:${message.streaming}`) - .join("|"); + const [contextKey, setContextKey] = useState(null); + const messagesRef = useRef(options.messages); + messagesRef.current = options.messages; + const signature = useMemo( + () => + options.messages + .map((message) => `${message.id}:${message.updatedAt}:${message.streaming}`) + .join("|"), + [options.messages], + ); useEffect(() => { if (!session) return; if (!options.environmentId || !options.threadId) { session.setContext(null); + setContextKey(null); return; } const environmentId = options.environmentId; @@ -50,11 +63,19 @@ export function useReplyPlaybackThread(options: { connected: !voiceEnvironmentConnectionLost(connection.data), mediaBlocked: options.mediaBlocked, }); + // A fresh context has no baseline, and the signature effect below may not re-run when the + // visible messages look the same, so establish the baseline here. + session.observe(messagesRef.current); + setContextKey( + `${environmentId}/${threadId}/${session.synthesis.provider}/${session.synthesis.voice}`, + ); return () => { session.clearContextIf(environmentId, threadId); }; }, [session, options.environmentId, options.threadId, options.mediaBlocked, connection.data]); + // Keyed on the message signature, not array identity, so unrelated re-renders skip the scan. useEffect(() => { - session?.observe(options.messages); - }, [session, options.messages, signature]); + session?.observe(messagesRef.current); + }, [session, signature]); + return contextKey; } diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 63b68cb0b80b..9c84f641be85 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -44,6 +44,9 @@ const EMPTY_THREAD_REFS_ATOM = Atom.make(EMPTY_THREAD_REFS).pipe( const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-shell:empty"), ); +const EMPTY_THREAD_ID_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-thread-id:empty"), +); const EMPTY_THREAD_DETAIL_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-detail:empty"), ); @@ -140,6 +143,30 @@ export function useThreadShell(ref: ScopedThreadRef | null): EnvironmentThreadSh ); } +/** Latest live thread id for a bot. Re-renders only when that bot's latest thread changes. */ +export function useLatestBotThreadId( + environmentId: EnvironmentId | null, + botId: string, +): ThreadId | null { + return useAtomValue( + environmentId === null || botId === "" + ? EMPTY_THREAD_ID_ATOM + : environmentThreadShells.latestBotThreadIdAtom(environmentId, botId), + ); +} + +/** Latest live thread id for a group. Re-renders only when that group's latest thread changes. */ +export function useLatestGroupThreadId( + environmentId: EnvironmentId | null, + groupId: string, +): ThreadId | null { + return useAtomValue( + environmentId === null || groupId === "" + ? EMPTY_THREAD_ID_ATOM + : environmentThreadShells.latestGroupThreadIdAtom(environmentId, groupId), + ); +} + export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread | null { return useAtomValue( ref === null ? EMPTY_THREAD_DETAIL_ATOM : environmentThreadDetails.detailAtom(ref), diff --git a/packages/client-runtime/src/replyPlayback/session.test.ts b/packages/client-runtime/src/replyPlayback/session.test.ts index 6432c0920ad3..a4de0fbe6bd9 100644 --- a/packages/client-runtime/src/replyPlayback/session.test.ts +++ b/packages/client-runtime/src/replyPlayback/session.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { STORED_REPLY_SYNTHESIS_UNAVAILABLE } from "./capability.ts"; -import { createReplyPlaybackSession } from "./session.ts"; +import { replyReadoutMessageAction } from "./messageAction.ts"; +import { createReplyPlaybackSession, SPOKEN_CACHE_LIMIT } from "./session.ts"; + +vi.mock("./messageAction.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { replyReadoutMessageAction: vi.fn(actual.replyReadoutMessageAction) }; +}); const message = { id: "reply-1", @@ -41,6 +47,49 @@ function setup(available = false) { } describe("reply playback session", () => { + it("derives spoken text once per stored reply text", async () => { + const { session } = setup(true); + await session.preference.load(); + vi.mocked(replyReadoutMessageAction).mockClear(); + session.actionFor(message); + session.actionFor(message); + session.observe([message]); + session.observe([message]); + expect(replyReadoutMessageAction).toHaveBeenCalledTimes(1); + const edited = { ...message, text: "Edited answer", updatedAt: later.updatedAt }; + expect(session.actionFor(edited)?.request.text).toBe("Edited answer"); + expect(replyReadoutMessageAction).toHaveBeenCalledTimes(2); + }); + + it("keeps a long chat cached across repeated passes", async () => { + const { session } = setup(true); + await session.preference.load(); + const replies = Array.from({ length: 501 }, (_, index) => ({ + ...message, + id: `reply-${index}`, + })); + for (const reply of replies) session.actionFor(reply); + vi.mocked(replyReadoutMessageAction).mockClear(); + for (const reply of replies) session.actionFor(reply); + expect(replyReadoutMessageAction).not.toHaveBeenCalled(); + }); + + it("evicts only the oldest reply past the cache limit", async () => { + const { session } = setup(true); + await session.preference.load(); + const replies = Array.from({ length: SPOKEN_CACHE_LIMIT + 1 }, (_, index) => ({ + ...message, + id: `reply-${index}`, + })); + for (const reply of replies) session.actionFor(reply); + vi.mocked(replyReadoutMessageAction).mockClear(); + session.actionFor(replies[1]!); + session.actionFor(replies.at(-1)!); + expect(replyReadoutMessageAction).not.toHaveBeenCalled(); + session.actionFor(replies[0]!); + expect(replyReadoutMessageAction).toHaveBeenCalledTimes(1); + }); + it("exposes settled assistant readout without starting a turn", () => { const { session, prepare } = setup(); expect(session.actionFor(message)).toMatchObject({ diff --git a/packages/client-runtime/src/replyPlayback/session.ts b/packages/client-runtime/src/replyPlayback/session.ts index f7efcff461db..d9d230835070 100644 --- a/packages/client-runtime/src/replyPlayback/session.ts +++ b/packages/client-runtime/src/replyPlayback/session.ts @@ -22,6 +22,11 @@ export interface ReplyPlaybackMessage { readonly updatedAt: string; } +type SpokenReply = NonNullable>; + +// Large enough that a full pass over a long chat fits, so revisits reuse cached speech. +export const SPOKEN_CACHE_LIMIT = 2_000; + export interface ReplyPlaybackAction { readonly request: ReplyPlaybackRequest; readonly disclosure?: string; @@ -50,6 +55,24 @@ export function createReplyPlaybackSession(options: { let seen = new Set(); let sequence = 0; let baseline: string | null = null; + // Rows and the observer ask for the same reply on every render, so spoken text is cached per + // message and reused while its stored text is unchanged. + const spokenCache = new Map(); + const spokenFor = (message: ReplyPlaybackMessage): SpokenReply | null => { + if (message.role !== "assistant" || message.streaming) return null; + const cached = spokenCache.get(message.id); + if (cached && cached.text === message.text) return cached.spoken; + const spoken = replyReadoutMessageAction(message); + if (!spoken) return null; + // Re-insert so the map stays in write order, then evict the oldest entry past the limit. + spokenCache.delete(message.id); + spokenCache.set(message.id, { text: message.text, spoken }); + if (spokenCache.size > SPOKEN_CACHE_LIMIT) { + const oldest = spokenCache.keys().next(); + if (!oldest.done) spokenCache.delete(oldest.value); + } + return spoken; + }; const identityBase = () => context ? { @@ -60,7 +83,7 @@ export function createReplyPlaybackSession(options: { } : null; const actionFor = (message: ReplyPlaybackMessage): ReplyPlaybackAction | null => { - const spoken = replyReadoutMessageAction(message); + const spoken = spokenFor(message); const base = identityBase(); if (!spoken || !base) return null; const request: ReplyPlaybackRequest = { @@ -124,8 +147,9 @@ export function createReplyPlaybackSession(options: { for (const message of messages) { if (message.role !== "assistant") continue; versions.set(message.id, message.updatedAt); - const spoken = replyReadoutMessageAction(message); - if (!spoken?.speakable || seen.has(message.id)) continue; + if (seen.has(message.id)) continue; + const spoken = spokenFor(message); + if (!spoken?.speakable) continue; live.push({ messageId: message.id, contentVersion: message.updatedAt, diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts new file mode 100644 index 000000000000..bdb6598a1722 --- /dev/null +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -0,0 +1,120 @@ +import { + BotId, + EnvironmentId, + GroupId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { EMPTY_ENVIRONMENT_CATALOG_STATE } from "./connections.ts"; +import { createEnvironmentThreadShellAtoms, latestOwnerThreadIds } from "./threadShell.ts"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +function thread( + id: string, + updatedAt: string, + owner: { readonly botId?: string; readonly groupId?: string; readonly archived?: boolean }, +): OrchestrationThreadShell { + return { + id: ThreadId.make(id), + projectId: ProjectId.make("project-1"), + ...(owner.botId ? { botId: BotId.make(owner.botId) } : {}), + ...(owner.groupId ? { groupId: GroupId.make(owner.groupId) } : {}), + title: id, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt, + archivedAt: owner.archived ? updatedAt : null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + } as OrchestrationThreadShell; +} + +function snapshot(threads: ReadonlyArray): OrchestrationShellSnapshot { + return { + snapshotSequence: 1, + updatedAt: "2026-06-01T00:00:00.000Z", + bots: [], + groups: [], + delegations: [], + projects: [], + threads: [...threads], + }; +} + +describe("latestOwnerThreadIds", () => { + it("picks the newest unarchived thread per bot and group", () => { + const latest = latestOwnerThreadIds([ + thread("a-old", "2026-06-01T00:00:00.000Z", { botId: "a" }), + thread("a-new", "2026-06-02T00:00:00.000Z", { botId: "a" }), + thread("a-archived", "2026-06-03T00:00:00.000Z", { botId: "a", archived: true }), + thread("g-1", "2026-06-01T00:00:00.000Z", { groupId: "g" }), + thread("plain", "2026-06-04T00:00:00.000Z", {}), + ]); + expect(latest.byBot.get("a")).toBe("a-new"); + expect(latest.byGroup.get("g")).toBe("g-1"); + expect(latest.byBot.size).toBe(1); + }); +}); + +describe("latest owner thread atoms", () => { + function harness(initial: ReadonlyArray) { + const snapshotAtom = Atom.make(snapshot(initial)); + const atoms = createEnvironmentThreadShellAtoms({ + catalogValueAtom: Atom.make(EMPTY_ENVIRONMENT_CATALOG_STATE), + snapshotAtom: () => snapshotAtom, + }); + return { registry: AtomRegistry.make(), snapshotAtom, atoms }; + } + + it("keeps identities and skips notifications when an unrelated thread changes", () => { + const first = thread("a-1", "2026-06-01T00:00:00.000Z", { botId: "a" }); + const other = thread("b-1", "2026-06-01T00:00:00.000Z", { botId: "b" }); + const { registry, snapshotAtom, atoms } = harness([first, other]); + const mapAtom = atoms.environmentLatestOwnerThreadIdsAtom(ENVIRONMENT_ID); + const botAtom = atoms.latestBotThreadIdAtom(ENVIRONMENT_ID, "a"); + const before = registry.get(mapAtom); + expect(registry.get(botAtom)).toBe("a-1"); + let notifications = 0; + const unsubscribe = registry.subscribe(botAtom, () => { + notifications += 1; + }); + + registry.set( + snapshotAtom, + snapshot([first, { ...other, updatedAt: "2026-06-05T00:00:00.000Z", title: "Renamed" }]), + ); + expect(registry.get(mapAtom)).toBe(before); + expect(registry.get(botAtom)).toBe("a-1"); + expect(notifications).toBe(0); + + registry.set( + snapshotAtom, + snapshot([first, other, thread("a-2", "2026-06-06T00:00:00.000Z", { botId: "a" })]), + ); + expect(registry.get(botAtom)).toBe("a-2"); + expect(notifications).toBe(1); + unsubscribe(); + }); + + it("returns null for a group without threads", () => { + const { registry, atoms } = harness([]); + expect(registry.get(atoms.latestGroupThreadIdAtom(ENVIRONMENT_ID, "g"))).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 65cee0427eb0..679f41ee3547 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -29,6 +29,64 @@ const EMPTY_THREAD_REFS_BY_PROJECT: ReadonlyMap< ReadonlyArray > = new Map(); +/** Latest live thread per bot and per group in one environment. */ +export interface LatestOwnerThreadIds { + readonly byBot: ReadonlyMap; + readonly byGroup: ReadonlyMap; +} + +const EMPTY_LATEST_OWNER_THREAD_IDS: LatestOwnerThreadIds = { + byBot: new Map(), + byGroup: new Map(), +}; + +function isNewerThread(candidate: OrchestrationThreadShell, current: OrchestrationThreadShell) { + return ( + (candidate.updatedAt.localeCompare(current.updatedAt) || + candidate.id.localeCompare(current.id)) > 0 + ); +} + +function threadIdMapsEqual( + left: ReadonlyMap, + right: ReadonlyMap, +): boolean { + if (left.size !== right.size) return false; + for (const [key, value] of right) { + if (left.get(key) !== value) return false; + } + return true; +} + +/** + * Picks the most recently updated unarchived thread for each bot and group. + * Ties break on thread id so the choice is stable. + */ +export function latestOwnerThreadIds( + threads: ReadonlyArray, +): LatestOwnerThreadIds { + const byBot = new Map(); + const byGroup = new Map(); + for (const thread of threads) { + if (thread.archivedAt !== null) continue; + if (thread.botId) { + const current = byBot.get(thread.botId); + if (current === undefined || isNewerThread(thread, current)) byBot.set(thread.botId, thread); + } + if (thread.groupId) { + const current = byGroup.get(thread.groupId); + if (current === undefined || isNewerThread(thread, current)) { + byGroup.set(thread.groupId, thread); + } + } + } + const ids = (source: Map) => + new Map([...source].map(([owner, thread]) => [owner, thread.id] as const)); + return { byBot: ids(byBot), byGroup: ids(byGroup) }; +} + +const OWNER_KEY_SEPARATOR = "\u0000"; + export function createEnvironmentThreadShellAtoms(input: { readonly catalogValueAtom: Atom.Atom; readonly snapshotAtom: ( @@ -100,6 +158,33 @@ export function createEnvironmentThreadShellAtoms(input: { }).pipe(Atom.withLabel(`environment-thread-refs-by-project:${environmentId}`)); }); + const environmentLatestOwnerThreadIdsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous = EMPTY_LATEST_OWNER_THREAD_IDS; + return Atom.make((get) => { + const next = latestOwnerThreadIds(get(environmentThreadsAtom(environmentId))); + const byBot = threadIdMapsEqual(previous.byBot, next.byBot) ? previous.byBot : next.byBot; + const byGroup = threadIdMapsEqual(previous.byGroup, next.byGroup) + ? previous.byGroup + : next.byGroup; + if (byBot !== previous.byBot || byGroup !== previous.byGroup) { + previous = { byBot, byGroup }; + } + return previous; + }).pipe(Atom.withLabel(`environment-latest-owner-threads:${environmentId}`)); + }); + + const latestOwnerThreadIdAtomFamily = Atom.family((key: string) => { + const [kind, environmentId, ownerId] = key.split(OWNER_KEY_SEPARATOR) as [ + "bot" | "group", + EnvironmentId, + string, + ]; + return Atom.make((get): ThreadId | null => { + const latest = get(environmentLatestOwnerThreadIdsAtom(environmentId)); + return (kind === "bot" ? latest.byBot : latest.byGroup).get(ownerId) ?? null; + }).pipe(Atom.withLabel(`environment-latest-owner-thread:${kind}:${environmentId}:${ownerId}`)); + }); + const threadShellAtomFamily = Atom.family((key: string) => { const ref = parseThreadKey(key); let previousSource: OrchestrationThreadShell | null = null; @@ -182,5 +267,12 @@ export function createEnvironmentThreadShellAtoms(input: { threadShellsForProjectRefsAtom: (refs: ReadonlyArray) => threadShellsForProjectRefsAtomFamily(projectRefCollectionKey(refs)), threadShellAtom: (ref: ScopedThreadRef) => threadShellAtomFamily(threadKey(ref)), + environmentLatestOwnerThreadIdsAtom, + /** Latest live thread id for one bot. Changes only when that bot's latest thread changes. */ + latestBotThreadIdAtom: (environmentId: EnvironmentId, botId: string) => + latestOwnerThreadIdAtomFamily(["bot", environmentId, botId].join(OWNER_KEY_SEPARATOR)), + /** Latest live thread id for one group. Changes only when that group's latest thread changes. */ + latestGroupThreadIdAtom: (environmentId: EnvironmentId, groupId: string) => + latestOwnerThreadIdAtomFamily(["group", environmentId, groupId].join(OWNER_KEY_SEPARATOR)), }; }