From 1074edd18ac75b3b8eafdb76518c8a35bc50321d Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 26 Sep 2026 13:21:39 -0400 Subject: [PATCH 1/7] perf(web): cut re-render storms and continuous repaints in bot chats Thread refs are memoized on ids so chat markdown stops re-parsing per render, message rows are memoized with content-visibility, spoken text is cached per reply, the roster reads derived per-bot atoms, working avatars tick at ~30fps with stepped shimmer, and drafts persist on a debounce. Co-Authored-By: Claude Fable 5.1 --- .changeset/perf-web-render.md | 5 + .../src/components/roster/BotAvatarView.tsx | 42 ++- .../roster/BotChatMessageRows.test.ts | 64 ++++ .../components/roster/BotChatMessageRows.tsx | 353 ++++++++++++++++++ .../components/roster/BotPromptComposer.tsx | 3 +- .../components/roster/BotRosterSidebar.tsx | 114 +++--- .../roster/BotThreadLanding.markdown.test.ts | 82 ++-- .../components/roster/BotThreadLanding.tsx | 312 ++++------------ .../components/roster/GroupThreadLanding.tsx | 237 +++--------- .../roster/ThreadLanding.delegations.test.tsx | 13 +- .../roster/ThreadLanding.hookOrder.test.tsx | 6 +- .../components/roster/botAvatarMotion.test.ts | 16 + .../src/components/roster/botAvatarMotion.ts | 5 + .../components/roster/botDraftStore.test.ts | 27 +- .../src/components/roster/botDraftStore.ts | 50 ++- apps/web/src/components/roster/botPresence.ts | 44 +-- .../src/components/roster/useBotThreadRef.ts | 42 ++- .../components/roster/useBotThreadRuntime.ts | 14 +- .../roster/useGroupThreadRuntime.ts | 11 +- apps/web/src/index.css | 18 +- apps/web/src/lib/replyPlaybackThread.ts | 20 +- apps/web/src/state/entities.ts | 27 ++ .../src/replyPlayback/session.test.ts | 20 + .../src/replyPlayback/session.ts | 24 +- .../src/state/threadShell.test.ts | 120 ++++++ .../client-runtime/src/state/threadShell.ts | 92 +++++ 26 files changed, 1126 insertions(+), 635 deletions(-) create mode 100644 .changeset/perf-web-render.md create mode 100644 apps/web/src/components/roster/BotChatMessageRows.test.ts create mode 100644 apps/web/src/components/roster/BotChatMessageRows.tsx create mode 100644 packages/client-runtime/src/state/threadShell.test.ts diff --git a/.changeset/perf-web-render.md b/.changeset/perf-web-render.md new file mode 100644 index 000000000000..020730701650 --- /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, working-bot animations use far less GPU, and message drafts save without slowing typing. diff --git a/apps/web/src/components/roster/BotAvatarView.tsx b/apps/web/src/components/roster/BotAvatarView.tsx index a3a060ff29ea..408993a5dad2 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,50 @@ 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 = ""; + const lastEyes: Array<{ transform: string | null; visible: boolean | null }> = [ + { transform: null, visible: 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; 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..378a48d1909f --- /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/BotPromptComposer.tsx b/apps/web/src/components/roster/BotPromptComposer.tsx index 106a3acf58de..a3f909bdb818 100644 --- a/apps/web/src/components/roster/BotPromptComposer.tsx +++ b/apps/web/src/components/roster/BotPromptComposer.tsx @@ -25,7 +25,7 @@ import { ComposerStashBadge } from "../chat/ComposerStashBadge"; import { ComposerStashMenu } from "../chat/ComposerStashMenu"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { toastManager } from "../ui/toast"; -import { clearBotDraft, readBotDraft, writeBotDraft } from "./botDraftStore"; +import { clearBotDraft, flushBotDrafts, readBotDraft, writeBotDraft } from "./botDraftStore"; import { BotPromptAttachments, buildBotPromptAttachmentPreview, @@ -589,6 +589,7 @@ export function BotPromptComposer({ expanded ? "min-h-16 px-4 pb-2 pt-3" : "min-h-13 px-14 py-[0.9rem]", )} onChange={(event) => persistDraft(event.currentTarget.value)} + onBlur={flushBotDrafts} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); 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 +67,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 +145,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 +179,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..efbf5546fce8 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/botDraftStore.test.ts b/apps/web/src/components/roster/botDraftStore.test.ts index 86b0a3c930f1..3bd5c4e74b21 100644 --- a/apps/web/src/components/roster/botDraftStore.test.ts +++ b/apps/web/src/components/roster/botDraftStore.test.ts @@ -1,6 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { clearBotDraft, readBotDraft, writeBotDraft } from "./botDraftStore"; +import { clearBotDraft, flushBotDrafts, readBotDraft, writeBotDraft } from "./botDraftStore"; const memory = new Map(); @@ -22,6 +22,8 @@ beforeEach(() => { }); afterEach(() => { + flushBotDrafts(); + vi.useRealTimers(); memory.clear(); }); @@ -43,4 +45,25 @@ describe("botDraftStore", () => { clearBotDraft("bot-1"); expect(readBotDraft("bot-2")).toBe("two"); }); + + it("keeps keystrokes in memory and persists once typing pauses", () => { + vi.useFakeTimers(); + writeBotDraft("bot-1", "h"); + writeBotDraft("bot-1", "he"); + writeBotDraft("bot-1", "hey"); + expect(memory.size).toBe(0); + expect(readBotDraft("bot-1")).toBe("hey"); + vi.advanceTimersByTime(500); + expect(JSON.parse(memory.get("akeru:bot-drafts:v1") ?? "{}")).toEqual({ "bot-1": "hey" }); + }); + + it("flushes on demand and keeps drafts another tab stored", () => { + memory.set("akeru:bot-drafts:v1", JSON.stringify({ "bot-2": "from another tab" })); + writeBotDraft("bot-1", "mine"); + flushBotDrafts(); + expect(JSON.parse(memory.get("akeru:bot-drafts:v1") ?? "{}")).toEqual({ + "bot-1": "mine", + "bot-2": "from another tab", + }); + }); }); diff --git a/apps/web/src/components/roster/botDraftStore.ts b/apps/web/src/components/roster/botDraftStore.ts index 329c3a7a5c56..57a63037e74a 100644 --- a/apps/web/src/components/roster/botDraftStore.ts +++ b/apps/web/src/components/roster/botDraftStore.ts @@ -37,21 +37,53 @@ function writeAll(drafts: Record): void { } } -export function readBotDraft(draftKey: string): string { - return readAll()[draftKey] ?? ""; +// Keystrokes land here first and reach localStorage after a short pause, on blur, or when +// the page hides. Flushing merges into the stored map so drafts from other tabs survive. +const pendingDrafts = new Map(); +const FLUSH_DELAY_MS = 400; +let flushTimer: ReturnType | null = null; +let unloadListenersInstalled = false; + +function installUnloadListeners(): void { + if (unloadListenersInstalled || typeof window === "undefined") return; + unloadListenersInstalled = true; + window.addEventListener("pagehide", flushBotDrafts); + window.addEventListener("beforeunload", flushBotDrafts); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flushBotDrafts(); + }); } -export function writeBotDraft(draftKey: string, text: string): void { +/** Writes pending drafts to storage now. Safe to call when nothing is pending. */ +export function flushBotDrafts(): void { + if (flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (pendingDrafts.size === 0) return; const drafts = readAll(); - const clipped = text.slice(0, MAX_DRAFT_CHARS); - if (clipped.length === 0) { - delete drafts[draftKey]; - } else { - drafts[draftKey] = clipped; + for (const [draftKey, text] of pendingDrafts) { + if (text.length === 0) delete drafts[draftKey]; + else drafts[draftKey] = text; } + pendingDrafts.clear(); writeAll(drafts); } +export function readBotDraft(draftKey: string): string { + return pendingDrafts.get(draftKey) ?? readAll()[draftKey] ?? ""; +} + +/** Records a draft in memory and persists it after typing pauses. */ +export function writeBotDraft(draftKey: string, text: string): void { + pendingDrafts.set(draftKey, text.slice(0, MAX_DRAFT_CHARS)); + installUnloadListeners(); + if (flushTimer !== null) clearTimeout(flushTimer); + flushTimer = setTimeout(flushBotDrafts, FLUSH_DELAY_MS); +} + +/** Clears a draft and persists the removal immediately. */ export function clearBotDraft(draftKey: string): void { - writeBotDraft(draftKey, ""); + pendingDrafts.set(draftKey, ""); + flushBotDrafts(); } 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..331ec8cb23ea 100644 --- a/apps/web/src/components/roster/useBotThreadRef.ts +++ b/apps/web/src/components/roster/useBotThreadRef.ts @@ -3,26 +3,40 @@ 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..bfb43f6fc838 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, 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,6 +36,9 @@ export function useReplyPlaybackThread(options: { }) { const session = useOptionalReplyPlayback(); const connection = useEnvironmentConnectionState(options.environmentId ?? null); + const [contextKey, setContextKey] = useState(null); + const messagesRef = useRef(options.messages); + messagesRef.current = options.messages; const signature = options.messages .map((message) => `${message.id}:${message.updatedAt}:${message.streaming}`) .join("|"); @@ -38,6 +46,7 @@ export function useReplyPlaybackThread(options: { if (!session) return; if (!options.environmentId || !options.threadId) { session.setContext(null); + setContextKey(null); return; } const environmentId = options.environmentId; @@ -50,11 +59,16 @@ export function useReplyPlaybackThread(options: { connected: !voiceEnvironmentConnectionLost(connection.data), mediaBlocked: options.mediaBlocked, }); + 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..efe08ee08484 100644 --- a/packages/client-runtime/src/replyPlayback/session.test.ts +++ b/packages/client-runtime/src/replyPlayback/session.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { STORED_REPLY_SYNTHESIS_UNAVAILABLE } from "./capability.ts"; +import { replyReadoutMessageAction } from "./messageAction.ts"; import { createReplyPlaybackSession } from "./session.ts"; +vi.mock("./messageAction.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { replyReadoutMessageAction: vi.fn(actual.replyReadoutMessageAction) }; +}); + const message = { id: "reply-1", role: "assistant" as const, @@ -41,6 +47,20 @@ 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("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..191c5b0bf33a 100644 --- a/packages/client-runtime/src/replyPlayback/session.ts +++ b/packages/client-runtime/src/replyPlayback/session.ts @@ -22,6 +22,10 @@ export interface ReplyPlaybackMessage { readonly updatedAt: string; } +type SpokenReply = NonNullable>; + +const SPOKEN_CACHE_LIMIT = 500; + export interface ReplyPlaybackAction { readonly request: ReplyPlaybackRequest; readonly disclosure?: string; @@ -50,6 +54,19 @@ 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; + if (spokenCache.size >= SPOKEN_CACHE_LIMIT) spokenCache.clear(); + spokenCache.set(message.id, { text: message.text, spoken }); + return spoken; + }; const identityBase = () => context ? { @@ -60,7 +77,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 +141,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)), }; } From f0607deaececa0471422a1772e4b825b1f06e9a4 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 26 Sep 2026 14:05:17 -0400 Subject: [PATCH 2/7] fix(web): drop unused botEnvironment import Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/roster/BotThreadLanding.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/web/src/components/roster/BotThreadLanding.tsx b/apps/web/src/components/roster/BotThreadLanding.tsx index 164274913380..058a9979fd62 100644 --- a/apps/web/src/components/roster/BotThreadLanding.tsx +++ b/apps/web/src/components/roster/BotThreadLanding.tsx @@ -12,7 +12,6 @@ import { deriveProviderInstanceEntries, sortProviderInstanceEntries, } from "../../providerInstances"; -import { botEnvironment } from "../../state/bots"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useThreadActivities } from "../../state/entities"; import { primaryServerProvidersAtom, serverEnvironment } from "../../state/server"; From b2b43e81b6ba7ff8a64177ed2c03ecb0521b62b9 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 26 Sep 2026 14:06:41 -0400 Subject: [PATCH 3/7] fix(web): keep reply playback baseline, fresh avatar eye cache, and cross-tab draft clears Observe messages whenever the playback context is set, so a fresh thread baseline is not taken from the first reply. Reset the avatar eye write cache when the eye nodes are replaced. Drop pending draft edits for keys another tab changed so a delayed flush cannot restore a cleared draft. Memoize the playback message signature. Co-Authored-By: Claude Fable 5.1 --- .../src/components/roster/BotAvatarView.tsx | 16 ++++++-- .../components/roster/botDraftStore.test.ts | 22 ++++++++++- .../src/components/roster/botDraftStore.ts | 37 +++++++++++++++++-- apps/web/src/lib/replyPlaybackThread.ts | 15 ++++++-- 4 files changed, 78 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/roster/BotAvatarView.tsx b/apps/web/src/components/roster/BotAvatarView.tsx index 408993a5dad2..4f5aa6cfe83d 100644 --- a/apps/web/src/components/roster/BotAvatarView.tsx +++ b/apps/web/src/components/roster/BotAvatarView.tsx @@ -194,9 +194,14 @@ function BlobAvatar({ let last = 0; // Last values written to the DOM, so settled or repeated poses cost no style work. let lastBody = ""; - const lastEyes: Array<{ transform: string | null; visible: boolean | null }> = [ - { transform: null, visible: null }, - { transform: null, visible: null }, + // 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) => { @@ -209,6 +214,11 @@ function BlobAvatar({ const eye = eyeRefs.current[index]; 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); const visible = transform !== null; if (visible !== written.visible) { diff --git a/apps/web/src/components/roster/botDraftStore.test.ts b/apps/web/src/components/roster/botDraftStore.test.ts index 3bd5c4e74b21..69f76f36308d 100644 --- a/apps/web/src/components/roster/botDraftStore.test.ts +++ b/apps/web/src/components/roster/botDraftStore.test.ts @@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { clearBotDraft, flushBotDrafts, readBotDraft, writeBotDraft } from "./botDraftStore"; +import { + clearBotDraft, + flushBotDrafts, + onBotDraftsStorageChange, + readBotDraft, + writeBotDraft, +} from "./botDraftStore"; const memory = new Map(); @@ -66,4 +72,18 @@ describe("botDraftStore", () => { "bot-2": "from another tab", }); }); + + it("does not restore a draft another tab cleared while an edit was pending", () => { + vi.useFakeTimers(); + const key = "akeru:bot-drafts:v1"; + memory.set(key, JSON.stringify({ "bot-1": "hello", "bot-2": "other" })); + writeBotDraft("bot-1", "hello there"); + writeBotDraft("bot-2", "other bot edit"); + const oldValue = memory.get(key) ?? null; + const newValue = JSON.stringify({ "bot-2": "other" }); + memory.set(key, newValue); + onBotDraftsStorageChange({ key, oldValue, newValue }); + vi.advanceTimersByTime(500); + expect(JSON.parse(memory.get(key) ?? "{}")).toEqual({ "bot-2": "other bot edit" }); + }); }); diff --git a/apps/web/src/components/roster/botDraftStore.ts b/apps/web/src/components/roster/botDraftStore.ts index 57a63037e74a..d5e11c45816d 100644 --- a/apps/web/src/components/roster/botDraftStore.ts +++ b/apps/web/src/components/roster/botDraftStore.ts @@ -9,11 +9,8 @@ function storage(): Storage | null { } } -function readAll(): Record { - const localStorage = storage(); - if (!localStorage) return {}; +function parseDrafts(raw: string | null): Record { try { - const raw = localStorage.getItem(DRAFTS_KEY); if (raw === null) return {}; const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; @@ -27,6 +24,16 @@ function readAll(): Record { } } +function readAll(): Record { + const localStorage = storage(); + if (!localStorage) return {}; + try { + return parseDrafts(localStorage.getItem(DRAFTS_KEY)); + } catch { + return {}; + } +} + function writeAll(drafts: Record): void { const localStorage = storage(); if (!localStorage) return; @@ -44,9 +51,31 @@ const FLUSH_DELAY_MS = 400; let flushTimer: ReturnType | null = null; let unloadListenersInstalled = false; +/** + * Another tab wrote drafts. A pending edit older than that write must not overwrite it, so + * drop pending entries for every key the other tab changed. This keeps last-write-wins, as + * when every keystroke was written straight to storage. + */ +export function onBotDraftsStorageChange( + event: Pick, +): void { + if (event.key !== DRAFTS_KEY && event.key !== null) return; + if (pendingDrafts.size === 0) return; + const before = parseDrafts(event.oldValue); + const after = parseDrafts(event.newValue); + for (const draftKey of pendingDrafts.keys()) { + if (before[draftKey] !== after[draftKey] || event.key === null) pendingDrafts.delete(draftKey); + } + if (pendingDrafts.size === 0 && flushTimer !== null) { + clearTimeout(flushTimer); + flushTimer = null; + } +} + function installUnloadListeners(): void { if (unloadListenersInstalled || typeof window === "undefined") return; unloadListenersInstalled = true; + window.addEventListener("storage", onBotDraftsStorageChange); window.addEventListener("pagehide", flushBotDrafts); window.addEventListener("beforeunload", flushBotDrafts); document.addEventListener("visibilitychange", () => { diff --git a/apps/web/src/lib/replyPlaybackThread.ts b/apps/web/src/lib/replyPlaybackThread.ts index bfb43f6fc838..ed20b225c2eb 100644 --- a/apps/web/src/lib/replyPlaybackThread.ts +++ b/apps/web/src/lib/replyPlaybackThread.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { EnvironmentId } from "@t3tools/contracts"; import type { ReplyPlaybackMessage, @@ -39,9 +39,13 @@ export function useReplyPlaybackThread(options: { const [contextKey, setContextKey] = useState(null); const messagesRef = useRef(options.messages); messagesRef.current = options.messages; - const signature = options.messages - .map((message) => `${message.id}:${message.updatedAt}:${message.streaming}`) - .join("|"); + 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) { @@ -59,6 +63,9 @@ 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}`, ); From 862c35e83e355bf4f9932b5a64b06b53f8044851 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 26 Sep 2026 14:16:48 -0400 Subject: [PATCH 4/7] fix(web): order cross-tab draft writes by edit time Dropping pending drafts on storage events trusted event delivery order, so a newer local edit could be lost to an older write from another tab. Store each draft's last edit or clear time and skip a pending flush only when storage already holds a newer one. Also format files the check flagged. Co-Authored-By: Claude Fable 5.1 --- .../components/roster/BotChatMessageRows.tsx | 8 +- .../components/roster/BotThreadLanding.tsx | 4 +- .../components/roster/GroupThreadLanding.tsx | 4 +- .../components/roster/botDraftStore.test.ts | 37 +++++---- .../src/components/roster/botDraftStore.ts | 82 ++++++++----------- .../src/components/roster/useBotThreadRef.ts | 3 +- 6 files changed, 66 insertions(+), 72 deletions(-) diff --git a/apps/web/src/components/roster/BotChatMessageRows.tsx b/apps/web/src/components/roster/BotChatMessageRows.tsx index 378a48d1909f..938ddc63fd2f 100644 --- a/apps/web/src/components/roster/BotChatMessageRows.tsx +++ b/apps/web/src/components/roster/BotChatMessageRows.tsx @@ -189,7 +189,9 @@ export const AssistantMessageRow = memo(function AssistantMessageRow({ const readAloud = replyPlaybackControlProps(playback, message); const copyText = message.text || "Attachment"; const label = author?.name ?? "Unavailable bot"; - const markdown = ; + const markdown = ( + + ); if (!author) { return (
@@ -248,9 +250,7 @@ function shallowEqual(a: T | null | undefined, b: T | null | u 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])) - ); + return keys.length === Object.keys(b).length && keys.every((key) => Object.is(a[key], b[key])); } function stepMetersEqual(a: BotStepMeterData | undefined, b: BotStepMeterData | undefined) { diff --git a/apps/web/src/components/roster/BotThreadLanding.tsx b/apps/web/src/components/roster/BotThreadLanding.tsx index 058a9979fd62..f9f2deab29f6 100644 --- a/apps/web/src/components/roster/BotThreadLanding.tsx +++ b/apps/web/src/components/roster/BotThreadLanding.tsx @@ -229,9 +229,7 @@ export function BotThreadLanding({ botId }: { readonly botId: string }) { testId="bot-provider-message" cwd={runtime.defaultProject?.workspaceRoot} threadRef={runtime.linkedThreadRef ?? undefined} - stepMeter={ - message.turnId === null ? undefined : stepMeters.get(message.turnId) - } + stepMeter={message.turnId === null ? undefined : stepMeters.get(message.turnId)} pluginResults={ message.turnId === null ? undefined : pluginResultsByTurn.get(message.turnId) } diff --git a/apps/web/src/components/roster/GroupThreadLanding.tsx b/apps/web/src/components/roster/GroupThreadLanding.tsx index efbf5546fce8..d5e080a0f9c9 100644 --- a/apps/web/src/components/roster/GroupThreadLanding.tsx +++ b/apps/web/src/components/roster/GroupThreadLanding.tsx @@ -158,9 +158,7 @@ export function GroupThreadLanding({ groupId }: { readonly groupId: string }) { testId="group-provider-message" cwd={runtime.defaultProject?.workspaceRoot} threadRef={runtime.linkedThreadRef ?? undefined} - stepMeter={ - message.turnId === null ? undefined : stepMeters.get(message.turnId) - } + stepMeter={message.turnId === null ? undefined : stepMeters.get(message.turnId)} pluginResults={undefined} currentPersonId={currentPersonId} playback={replyPlayback} diff --git a/apps/web/src/components/roster/botDraftStore.test.ts b/apps/web/src/components/roster/botDraftStore.test.ts index 69f76f36308d..b708287cda09 100644 --- a/apps/web/src/components/roster/botDraftStore.test.ts +++ b/apps/web/src/components/roster/botDraftStore.test.ts @@ -1,13 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { - clearBotDraft, - flushBotDrafts, - onBotDraftsStorageChange, - readBotDraft, - writeBotDraft, -} from "./botDraftStore"; +import { clearBotDraft, flushBotDrafts, readBotDraft, writeBotDraft } from "./botDraftStore"; +const DRAFTS = "akeru:bot-drafts:v1"; +const EDITED_AT = "akeru:bot-drafts:v1:edited-at"; const memory = new Map(); beforeEach(() => { @@ -73,17 +69,28 @@ describe("botDraftStore", () => { }); }); - it("does not restore a draft another tab cleared while an edit was pending", () => { + it("does not restore a draft another tab cleared after a pending edit", () => { vi.useFakeTimers(); - const key = "akeru:bot-drafts:v1"; - memory.set(key, JSON.stringify({ "bot-1": "hello", "bot-2": "other" })); + vi.setSystemTime(1_000); + memory.set(DRAFTS, JSON.stringify({ "bot-1": "hello", "bot-2": "other" })); writeBotDraft("bot-1", "hello there"); writeBotDraft("bot-2", "other bot edit"); - const oldValue = memory.get(key) ?? null; - const newValue = JSON.stringify({ "bot-2": "other" }); - memory.set(key, newValue); - onBotDraftsStorageChange({ key, oldValue, newValue }); + // Another tab sends bot-1's draft and clears it after this tab's edit. + memory.set(DRAFTS, JSON.stringify({ "bot-2": "other" })); + memory.set(EDITED_AT, JSON.stringify({ "bot-1": 1_200 })); vi.advanceTimersByTime(500); - expect(JSON.parse(memory.get(key) ?? "{}")).toEqual({ "bot-2": "other bot edit" }); + expect(JSON.parse(memory.get(DRAFTS) ?? "{}")).toEqual({ "bot-2": "other bot edit" }); + }); + + it("keeps a local edit made after another tab's write", () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000); + // Another tab wrote an older draft; its storage event may arrive after local typing. + memory.set(DRAFTS, JSON.stringify({ "bot-1": "remote older draft" })); + memory.set(EDITED_AT, JSON.stringify({ "bot-1": 1_500 })); + writeBotDraft("bot-1", "my newer local edit"); + vi.advanceTimersByTime(500); + expect(JSON.parse(memory.get(DRAFTS) ?? "{}")).toEqual({ "bot-1": "my newer local edit" }); + expect(JSON.parse(memory.get(EDITED_AT) ?? "{}")).toEqual({ "bot-1": 2_000 }); }); }); diff --git a/apps/web/src/components/roster/botDraftStore.ts b/apps/web/src/components/roster/botDraftStore.ts index d5e11c45816d..2356ed509ce5 100644 --- a/apps/web/src/components/roster/botDraftStore.ts +++ b/apps/web/src/components/roster/botDraftStore.ts @@ -1,5 +1,10 @@ const DRAFTS_KEY = "akeru:bot-drafts:v1"; +// Per-draft time of the last persisted edit or clear. A delayed flush from another tab +// compares against it so an older edit never overwrites a newer write or clear. +const EDITED_AT_KEY = "akeru:bot-drafts:v1:edited-at"; const MAX_DRAFT_CHARS = 20_000; +// Clear markers only need to outlive other tabs' pending flushes. +const CLEAR_MARKER_TTL_MS = 24 * 60 * 60 * 1000; function storage(): Storage | null { try { @@ -9,73 +14,49 @@ function storage(): Storage | null { } } -function parseDrafts(raw: string | null): Record { +function readRecord( + key: string, + kind: "string" | "number", +): Record { + const localStorage = storage(); + if (!localStorage) return {}; try { + const raw = localStorage.getItem(key); if (raw === null) return {}; const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), + Object.entries(parsed).filter((entry): entry is [string, T] => typeof entry[1] === kind), ); } catch { return {}; } } -function readAll(): Record { - const localStorage = storage(); - if (!localStorage) return {}; - try { - return parseDrafts(localStorage.getItem(DRAFTS_KEY)); - } catch { - return {}; - } -} - -function writeAll(drafts: Record): void { +function writeRecord(key: string, record: Record): void { const localStorage = storage(); if (!localStorage) return; try { - localStorage.setItem(DRAFTS_KEY, JSON.stringify(drafts)); + localStorage.setItem(key, JSON.stringify(record)); } catch { // Quota or private mode. Draft recovery is best-effort. } } +function readAll(): Record { + return readRecord(DRAFTS_KEY, "string"); +} + // Keystrokes land here first and reach localStorage after a short pause, on blur, or when // the page hides. Flushing merges into the stored map so drafts from other tabs survive. -const pendingDrafts = new Map(); +const pendingDrafts = new Map(); const FLUSH_DELAY_MS = 400; let flushTimer: ReturnType | null = null; let unloadListenersInstalled = false; -/** - * Another tab wrote drafts. A pending edit older than that write must not overwrite it, so - * drop pending entries for every key the other tab changed. This keeps last-write-wins, as - * when every keystroke was written straight to storage. - */ -export function onBotDraftsStorageChange( - event: Pick, -): void { - if (event.key !== DRAFTS_KEY && event.key !== null) return; - if (pendingDrafts.size === 0) return; - const before = parseDrafts(event.oldValue); - const after = parseDrafts(event.newValue); - for (const draftKey of pendingDrafts.keys()) { - if (before[draftKey] !== after[draftKey] || event.key === null) pendingDrafts.delete(draftKey); - } - if (pendingDrafts.size === 0 && flushTimer !== null) { - clearTimeout(flushTimer); - flushTimer = null; - } -} - function installUnloadListeners(): void { if (unloadListenersInstalled || typeof window === "undefined") return; unloadListenersInstalled = true; - window.addEventListener("storage", onBotDraftsStorageChange); window.addEventListener("pagehide", flushBotDrafts); window.addEventListener("beforeunload", flushBotDrafts); document.addEventListener("visibilitychange", () => { @@ -91,21 +72,30 @@ export function flushBotDrafts(): void { } if (pendingDrafts.size === 0) return; const drafts = readAll(); - for (const [draftKey, text] of pendingDrafts) { - if (text.length === 0) delete drafts[draftKey]; - else drafts[draftKey] = text; + const editedAt = readRecord(EDITED_AT_KEY, "number"); + for (const [draftKey, pending] of pendingDrafts) { + // Another tab wrote or cleared this draft after the pending edit was made. + if ((editedAt[draftKey] ?? 0) > pending.editedAt) continue; + editedAt[draftKey] = pending.editedAt; + if (pending.text.length === 0) delete drafts[draftKey]; + else drafts[draftKey] = pending.text; } pendingDrafts.clear(); - writeAll(drafts); + const staleBefore = Date.now() - CLEAR_MARKER_TTL_MS; + for (const [draftKey, at] of Object.entries(editedAt)) { + if (drafts[draftKey] === undefined && at < staleBefore) delete editedAt[draftKey]; + } + writeRecord(DRAFTS_KEY, drafts); + writeRecord(EDITED_AT_KEY, editedAt); } export function readBotDraft(draftKey: string): string { - return pendingDrafts.get(draftKey) ?? readAll()[draftKey] ?? ""; + return pendingDrafts.get(draftKey)?.text ?? readAll()[draftKey] ?? ""; } /** Records a draft in memory and persists it after typing pauses. */ export function writeBotDraft(draftKey: string, text: string): void { - pendingDrafts.set(draftKey, text.slice(0, MAX_DRAFT_CHARS)); + pendingDrafts.set(draftKey, { text: text.slice(0, MAX_DRAFT_CHARS), editedAt: Date.now() }); installUnloadListeners(); if (flushTimer !== null) clearTimeout(flushTimer); flushTimer = setTimeout(flushBotDrafts, FLUSH_DELAY_MS); @@ -113,6 +103,6 @@ export function writeBotDraft(draftKey: string, text: string): void { /** Clears a draft and persists the removal immediately. */ export function clearBotDraft(draftKey: string): void { - pendingDrafts.set(draftKey, ""); + pendingDrafts.set(draftKey, { text: "", editedAt: Date.now() }); flushBotDrafts(); } diff --git a/apps/web/src/components/roster/useBotThreadRef.ts b/apps/web/src/components/roster/useBotThreadRef.ts index 331ec8cb23ea..42b42c18d667 100644 --- a/apps/web/src/components/roster/useBotThreadRef.ts +++ b/apps/web/src/components/roster/useBotThreadRef.ts @@ -19,7 +19,8 @@ export function useBotThreadCandidate( const environmentId = usePrimaryEnvironmentId(); const latestThreadId = useLatestBotThreadId(environmentId, botId); const rememberedPath = useRosterStore((state) => state.chatPathByBotId[botId]); - const remembered = latestThreadId === null && rememberedPath ? parseChatPath(rememberedPath) : null; + const remembered = + latestThreadId === null && rememberedPath ? parseChatPath(rememberedPath) : null; const rememberedUsable = remembered !== null && (options?.rememberedInPrimaryOnly !== true || remembered.environmentId === environmentId); From e56287c058b1afc51624cf36383b2faad0d3ba84 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 26 Sep 2026 14:26:51 -0400 Subject: [PATCH 5/7] fix(web): order cross-tab draft writes by stored version, not clock Wall-clock stamps broke on clock rollback and same-millisecond writes. Each pending edit now remembers the storage version it saw when typed, and a flush writes only if storage still holds that version. Versions come from a stored counter that only grows. Co-Authored-By: Claude Fable 5.1 --- .../components/roster/botDraftStore.test.ts | 25 ++++-- .../src/components/roster/botDraftStore.ts | 84 ++++++++++++------- 2 files changed, 70 insertions(+), 39 deletions(-) diff --git a/apps/web/src/components/roster/botDraftStore.test.ts b/apps/web/src/components/roster/botDraftStore.test.ts index b708287cda09..9bdf8db6796c 100644 --- a/apps/web/src/components/roster/botDraftStore.test.ts +++ b/apps/web/src/components/roster/botDraftStore.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { clearBotDraft, flushBotDrafts, readBotDraft, writeBotDraft } from "./botDraftStore"; const DRAFTS = "akeru:bot-drafts:v1"; -const EDITED_AT = "akeru:bot-drafts:v1:edited-at"; +const VERSIONS = "akeru:bot-drafts:v1:versions"; const memory = new Map(); beforeEach(() => { @@ -71,26 +71,33 @@ describe("botDraftStore", () => { it("does not restore a draft another tab cleared after a pending edit", () => { vi.useFakeTimers(); - vi.setSystemTime(1_000); memory.set(DRAFTS, JSON.stringify({ "bot-1": "hello", "bot-2": "other" })); + memory.set(VERSIONS, JSON.stringify({ seq: 2, versions: { "bot-1": 1, "bot-2": 2 } })); writeBotDraft("bot-1", "hello there"); writeBotDraft("bot-2", "other bot edit"); - // Another tab sends bot-1's draft and clears it after this tab's edit. + // Another tab sends bot-1's draft and clears it before this tab flushes. memory.set(DRAFTS, JSON.stringify({ "bot-2": "other" })); - memory.set(EDITED_AT, JSON.stringify({ "bot-1": 1_200 })); + memory.set(VERSIONS, JSON.stringify({ seq: 2, versions: { "bot-2": 2 } })); vi.advanceTimersByTime(500); expect(JSON.parse(memory.get(DRAFTS) ?? "{}")).toEqual({ "bot-2": "other bot edit" }); }); - it("keeps a local edit made after another tab's write", () => { + it("keeps a local edit typed after another tab's write", () => { vi.useFakeTimers(); - vi.setSystemTime(2_000); - // Another tab wrote an older draft; its storage event may arrive after local typing. + // Another tab already wrote; its storage event may not have arrived yet. memory.set(DRAFTS, JSON.stringify({ "bot-1": "remote older draft" })); - memory.set(EDITED_AT, JSON.stringify({ "bot-1": 1_500 })); + memory.set(VERSIONS, JSON.stringify({ seq: 7, versions: { "bot-1": 7 } })); writeBotDraft("bot-1", "my newer local edit"); vi.advanceTimersByTime(500); expect(JSON.parse(memory.get(DRAFTS) ?? "{}")).toEqual({ "bot-1": "my newer local edit" }); - expect(JSON.parse(memory.get(EDITED_AT) ?? "{}")).toEqual({ "bot-1": 2_000 }); + expect(JSON.parse(memory.get(VERSIONS) ?? "{}")).toEqual({ seq: 8, versions: { "bot-1": 8 } }); + }); + + it("clears a draft this tab saved earlier", () => { + writeBotDraft("bot-1", "sent prompt"); + flushBotDrafts(); + clearBotDraft("bot-1"); + expect(JSON.parse(memory.get(DRAFTS) ?? "{}")).toEqual({}); + expect(readBotDraft("bot-1")).toBe(""); }); }); diff --git a/apps/web/src/components/roster/botDraftStore.ts b/apps/web/src/components/roster/botDraftStore.ts index 2356ed509ce5..3a7d69d1ed6b 100644 --- a/apps/web/src/components/roster/botDraftStore.ts +++ b/apps/web/src/components/roster/botDraftStore.ts @@ -1,10 +1,9 @@ const DRAFTS_KEY = "akeru:bot-drafts:v1"; -// Per-draft time of the last persisted edit or clear. A delayed flush from another tab -// compares against it so an older edit never overwrites a newer write or clear. -const EDITED_AT_KEY = "akeru:bot-drafts:v1:edited-at"; +// Which write each stored draft came from. `seq` only grows, so a version is never reused. +// A pending edit remembers the version it saw when typed; a flush writes it only if storage +// still holds that version, so an older edit never overwrites another tab's newer write or clear. +const VERSIONS_KEY = "akeru:bot-drafts:v1:versions"; const MAX_DRAFT_CHARS = 20_000; -// Clear markers only need to outlive other tabs' pending flushes. -const CLEAR_MARKER_TTL_MS = 24 * 60 * 60 * 1000; function storage(): Storage | null { try { @@ -14,10 +13,7 @@ function storage(): Storage | null { } } -function readRecord( - key: string, - kind: "string" | "number", -): Record { +function readJson(key: string): Record { const localStorage = storage(); if (!localStorage) return {}; try { @@ -25,31 +21,55 @@ function readRecord( if (raw === null) return {}; const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; - return Object.fromEntries( - Object.entries(parsed).filter((entry): entry is [string, T] => typeof entry[1] === kind), - ); + return parsed as Record; } catch { return {}; } } -function writeRecord(key: string, record: Record): void { +function writeJson(key: string, value: unknown): void { const localStorage = storage(); if (!localStorage) return; try { - localStorage.setItem(key, JSON.stringify(record)); + localStorage.setItem(key, JSON.stringify(value)); } catch { // Quota or private mode. Draft recovery is best-effort. } } function readAll(): Record { - return readRecord(DRAFTS_KEY, "string"); + return Object.fromEntries( + Object.entries(readJson(DRAFTS_KEY)).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); +} + +interface DraftVersions { + seq: number; + versions: Record; +} + +function readVersions(): DraftVersions { + const stored = readJson(VERSIONS_KEY); + const versions = + typeof stored.versions === "object" && stored.versions !== null ? stored.versions : {}; + return { + seq: typeof stored.seq === "number" ? stored.seq : 0, + versions: Object.fromEntries( + Object.entries(versions).filter( + (entry): entry is [string, number] => typeof entry[1] === "number", + ), + ), + }; } // Keystrokes land here first and reach localStorage after a short pause, on blur, or when // the page hides. Flushing merges into the stored map so drafts from other tabs survive. -const pendingDrafts = new Map(); +const pendingDrafts = new Map< + string, + { readonly text: string; readonly seenVersion: number | undefined } +>(); const FLUSH_DELAY_MS = 400; let flushTimer: ReturnType | null = null; let unloadListenersInstalled = false; @@ -72,21 +92,22 @@ export function flushBotDrafts(): void { } if (pendingDrafts.size === 0) return; const drafts = readAll(); - const editedAt = readRecord(EDITED_AT_KEY, "number"); + const stored = readVersions(); for (const [draftKey, pending] of pendingDrafts) { - // Another tab wrote or cleared this draft after the pending edit was made. - if ((editedAt[draftKey] ?? 0) > pending.editedAt) continue; - editedAt[draftKey] = pending.editedAt; - if (pending.text.length === 0) delete drafts[draftKey]; - else drafts[draftKey] = pending.text; + // Another tab wrote or cleared this draft after the pending edit was typed. + if (stored.versions[draftKey] !== pending.seenVersion) continue; + if (pending.text.length === 0) { + delete drafts[draftKey]; + delete stored.versions[draftKey]; + } else { + drafts[draftKey] = pending.text; + stored.seq += 1; + stored.versions[draftKey] = stored.seq; + } } pendingDrafts.clear(); - const staleBefore = Date.now() - CLEAR_MARKER_TTL_MS; - for (const [draftKey, at] of Object.entries(editedAt)) { - if (drafts[draftKey] === undefined && at < staleBefore) delete editedAt[draftKey]; - } - writeRecord(DRAFTS_KEY, drafts); - writeRecord(EDITED_AT_KEY, editedAt); + writeJson(DRAFTS_KEY, drafts); + writeJson(VERSIONS_KEY, stored); } export function readBotDraft(draftKey: string): string { @@ -95,7 +116,10 @@ export function readBotDraft(draftKey: string): string { /** Records a draft in memory and persists it after typing pauses. */ export function writeBotDraft(draftKey: string, text: string): void { - pendingDrafts.set(draftKey, { text: text.slice(0, MAX_DRAFT_CHARS), editedAt: Date.now() }); + pendingDrafts.set(draftKey, { + text: text.slice(0, MAX_DRAFT_CHARS), + seenVersion: readVersions().versions[draftKey], + }); installUnloadListeners(); if (flushTimer !== null) clearTimeout(flushTimer); flushTimer = setTimeout(flushBotDrafts, FLUSH_DELAY_MS); @@ -103,6 +127,6 @@ export function writeBotDraft(draftKey: string, text: string): void { /** Clears a draft and persists the removal immediately. */ export function clearBotDraft(draftKey: string): void { - pendingDrafts.set(draftKey, { text: "", editedAt: Date.now() }); + pendingDrafts.set(draftKey, { text: "", seenVersion: readVersions().versions[draftKey] }); flushBotDrafts(); } From 0f96b9b320bbf73868b337a3656c6661f65aa062 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 26 Sep 2026 14:37:23 -0400 Subject: [PATCH 6/7] fix(web): save each bot draft under its own key as it is typed Debouncing draft writes kept edits in memory, and every cross-tab ordering scheme on top of that left a way for an older edit to beat a newer write or clear. Store each draft under its own key and write it synchronously: a keystroke now costs one small write instead of parsing and rewriting every draft, and the last write wins as before. Drafts in the old shared map move to per-draft keys once per page load. Co-Authored-By: Claude Fable 5.1 --- .../components/roster/BotPromptComposer.tsx | 3 +- .../components/roster/botDraftStore.test.ts | 76 +++------ .../src/components/roster/botDraftStore.ts | 148 ++++++------------ 3 files changed, 65 insertions(+), 162 deletions(-) diff --git a/apps/web/src/components/roster/BotPromptComposer.tsx b/apps/web/src/components/roster/BotPromptComposer.tsx index a3f909bdb818..106a3acf58de 100644 --- a/apps/web/src/components/roster/BotPromptComposer.tsx +++ b/apps/web/src/components/roster/BotPromptComposer.tsx @@ -25,7 +25,7 @@ import { ComposerStashBadge } from "../chat/ComposerStashBadge"; import { ComposerStashMenu } from "../chat/ComposerStashMenu"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { toastManager } from "../ui/toast"; -import { clearBotDraft, flushBotDrafts, readBotDraft, writeBotDraft } from "./botDraftStore"; +import { clearBotDraft, readBotDraft, writeBotDraft } from "./botDraftStore"; import { BotPromptAttachments, buildBotPromptAttachmentPreview, @@ -589,7 +589,6 @@ export function BotPromptComposer({ expanded ? "min-h-16 px-4 pb-2 pt-3" : "min-h-13 px-14 py-[0.9rem]", )} onChange={(event) => persistDraft(event.currentTarget.value)} - onBlur={flushBotDrafts} onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); diff --git a/apps/web/src/components/roster/botDraftStore.test.ts b/apps/web/src/components/roster/botDraftStore.test.ts index 9bdf8db6796c..6399341e890e 100644 --- a/apps/web/src/components/roster/botDraftStore.test.ts +++ b/apps/web/src/components/roster/botDraftStore.test.ts @@ -1,13 +1,17 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { clearBotDraft, flushBotDrafts, readBotDraft, writeBotDraft } from "./botDraftStore"; +import { + clearBotDraft, + readBotDraft, + resetBotDraftMigrationForTests, + writeBotDraft, +} from "./botDraftStore"; -const DRAFTS = "akeru:bot-drafts:v1"; -const VERSIONS = "akeru:bot-drafts:v1:versions"; const memory = new Map(); beforeEach(() => { memory.clear(); + resetBotDraftMigrationForTests(); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { @@ -23,15 +27,10 @@ beforeEach(() => { }); }); -afterEach(() => { - flushBotDrafts(); - vi.useRealTimers(); - memory.clear(); -}); - describe("botDraftStore", () => { it("restores a typed draft after a simulated restart", () => { writeBotDraft("bot-1", "yo what tool calls u got?"); + resetBotDraftMigrationForTests(); expect(readBotDraft("bot-1")).toBe("yo what tool calls u got?"); }); @@ -39,6 +38,7 @@ describe("botDraftStore", () => { writeBotDraft("bot-1", "half a sentence"); clearBotDraft("bot-1"); expect(readBotDraft("bot-1")).toBe(""); + expect(memory.size).toBe(0); }); it("keeps drafts for other bots", () => { @@ -48,56 +48,20 @@ describe("botDraftStore", () => { expect(readBotDraft("bot-2")).toBe("two"); }); - it("keeps keystrokes in memory and persists once typing pauses", () => { - vi.useFakeTimers(); - writeBotDraft("bot-1", "h"); - writeBotDraft("bot-1", "he"); + it("writes only the edited draft's key", () => { + writeBotDraft("bot-2", "untouched"); writeBotDraft("bot-1", "hey"); - expect(memory.size).toBe(0); - expect(readBotDraft("bot-1")).toBe("hey"); - vi.advanceTimersByTime(500); - expect(JSON.parse(memory.get("akeru:bot-drafts:v1") ?? "{}")).toEqual({ "bot-1": "hey" }); - }); - - it("flushes on demand and keeps drafts another tab stored", () => { - memory.set("akeru:bot-drafts:v1", JSON.stringify({ "bot-2": "from another tab" })); - writeBotDraft("bot-1", "mine"); - flushBotDrafts(); - expect(JSON.parse(memory.get("akeru:bot-drafts:v1") ?? "{}")).toEqual({ - "bot-1": "mine", - "bot-2": "from another tab", - }); - }); - - it("does not restore a draft another tab cleared after a pending edit", () => { - vi.useFakeTimers(); - memory.set(DRAFTS, JSON.stringify({ "bot-1": "hello", "bot-2": "other" })); - memory.set(VERSIONS, JSON.stringify({ seq: 2, versions: { "bot-1": 1, "bot-2": 2 } })); - writeBotDraft("bot-1", "hello there"); - writeBotDraft("bot-2", "other bot edit"); - // Another tab sends bot-1's draft and clears it before this tab flushes. - memory.set(DRAFTS, JSON.stringify({ "bot-2": "other" })); - memory.set(VERSIONS, JSON.stringify({ seq: 2, versions: { "bot-2": 2 } })); - vi.advanceTimersByTime(500); - expect(JSON.parse(memory.get(DRAFTS) ?? "{}")).toEqual({ "bot-2": "other bot edit" }); - }); - - it("keeps a local edit typed after another tab's write", () => { - vi.useFakeTimers(); - // Another tab already wrote; its storage event may not have arrived yet. - memory.set(DRAFTS, JSON.stringify({ "bot-1": "remote older draft" })); - memory.set(VERSIONS, JSON.stringify({ seq: 7, versions: { "bot-1": 7 } })); - writeBotDraft("bot-1", "my newer local edit"); - vi.advanceTimersByTime(500); - expect(JSON.parse(memory.get(DRAFTS) ?? "{}")).toEqual({ "bot-1": "my newer local edit" }); - expect(JSON.parse(memory.get(VERSIONS) ?? "{}")).toEqual({ seq: 8, versions: { "bot-1": 8 } }); + expect([...memory.keys()].sort()).toEqual([ + "akeru:bot-draft:v2:bot-1", + "akeru:bot-draft:v2:bot-2", + ]); }); - it("clears a draft this tab saved earlier", () => { - writeBotDraft("bot-1", "sent prompt"); - flushBotDrafts(); + it("moves legacy drafts to per-draft keys once", () => { + memory.set("akeru:bot-drafts:v1", JSON.stringify({ "bot-1": "old draft", "bot-2": 3 })); + expect(readBotDraft("bot-1")).toBe("old draft"); + expect(memory.has("akeru:bot-drafts:v1")).toBe(false); clearBotDraft("bot-1"); - expect(JSON.parse(memory.get(DRAFTS) ?? "{}")).toEqual({}); expect(readBotDraft("bot-1")).toBe(""); }); }); diff --git a/apps/web/src/components/roster/botDraftStore.ts b/apps/web/src/components/roster/botDraftStore.ts index 3a7d69d1ed6b..2c1352e26572 100644 --- a/apps/web/src/components/roster/botDraftStore.ts +++ b/apps/web/src/components/roster/botDraftStore.ts @@ -1,8 +1,8 @@ -const DRAFTS_KEY = "akeru:bot-drafts:v1"; -// Which write each stored draft came from. `seq` only grows, so a version is never reused. -// A pending edit remembers the version it saw when typed; a flush writes it only if storage -// still holds that version, so an older edit never overwrites another tab's newer write or clear. -const VERSIONS_KEY = "akeru:bot-drafts:v1:versions"; +// Each draft lives under its own key and is written as it is typed, so a keystroke costs one +// small write instead of rewriting every draft, and the last write across tabs wins. +const DRAFT_KEY_PREFIX = "akeru:bot-draft:v2:"; +// Drafts saved before per-draft keys, moved over once per page load. +const LEGACY_DRAFTS_KEY = "akeru:bot-drafts:v1"; const MAX_DRAFT_CHARS = 20_000; function storage(): Storage | null { @@ -13,120 +13,60 @@ function storage(): Storage | null { } } -function readJson(key: string): Record { - const localStorage = storage(); - if (!localStorage) return {}; +let legacyMigrated = false; + +function migrateLegacyDrafts(localStorage: Storage): void { + if (legacyMigrated) return; + legacyMigrated = true; try { - const raw = localStorage.getItem(key); - if (raw === null) return {}; + const raw = localStorage.getItem(LEGACY_DRAFTS_KEY); + if (raw === null) return; const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; - return parsed as Record; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + for (const [draftKey, text] of Object.entries(parsed)) { + const key = DRAFT_KEY_PREFIX + draftKey; + if (typeof text === "string" && text.length > 0 && localStorage.getItem(key) === null) { + localStorage.setItem(key, text.slice(0, MAX_DRAFT_CHARS)); + } + } + } + localStorage.removeItem(LEGACY_DRAFTS_KEY); } catch { - return {}; + // Unreadable legacy drafts are dropped. Draft recovery is best-effort. } } -function writeJson(key: string, value: unknown): void { +function draftStorage(): Storage | null { const localStorage = storage(); - if (!localStorage) return; + if (localStorage) migrateLegacyDrafts(localStorage); + return localStorage; +} + +export function readBotDraft(draftKey: string): string { try { - localStorage.setItem(key, JSON.stringify(value)); + return draftStorage()?.getItem(DRAFT_KEY_PREFIX + draftKey) ?? ""; } catch { - // Quota or private mode. Draft recovery is best-effort. + return ""; } } -function readAll(): Record { - return Object.fromEntries( - Object.entries(readJson(DRAFTS_KEY)).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", - ), - ); -} - -interface DraftVersions { - seq: number; - versions: Record; -} - -function readVersions(): DraftVersions { - const stored = readJson(VERSIONS_KEY); - const versions = - typeof stored.versions === "object" && stored.versions !== null ? stored.versions : {}; - return { - seq: typeof stored.seq === "number" ? stored.seq : 0, - versions: Object.fromEntries( - Object.entries(versions).filter( - (entry): entry is [string, number] => typeof entry[1] === "number", - ), - ), - }; -} - -// Keystrokes land here first and reach localStorage after a short pause, on blur, or when -// the page hides. Flushing merges into the stored map so drafts from other tabs survive. -const pendingDrafts = new Map< - string, - { readonly text: string; readonly seenVersion: number | undefined } ->(); -const FLUSH_DELAY_MS = 400; -let flushTimer: ReturnType | null = null; -let unloadListenersInstalled = false; - -function installUnloadListeners(): void { - if (unloadListenersInstalled || typeof window === "undefined") return; - unloadListenersInstalled = true; - window.addEventListener("pagehide", flushBotDrafts); - window.addEventListener("beforeunload", flushBotDrafts); - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "hidden") flushBotDrafts(); - }); -} - -/** Writes pending drafts to storage now. Safe to call when nothing is pending. */ -export function flushBotDrafts(): void { - if (flushTimer !== null) { - clearTimeout(flushTimer); - flushTimer = null; - } - if (pendingDrafts.size === 0) return; - const drafts = readAll(); - const stored = readVersions(); - for (const [draftKey, pending] of pendingDrafts) { - // Another tab wrote or cleared this draft after the pending edit was typed. - if (stored.versions[draftKey] !== pending.seenVersion) continue; - if (pending.text.length === 0) { - delete drafts[draftKey]; - delete stored.versions[draftKey]; - } else { - drafts[draftKey] = pending.text; - stored.seq += 1; - stored.versions[draftKey] = stored.seq; - } +export function writeBotDraft(draftKey: string, text: string): void { + const localStorage = draftStorage(); + if (!localStorage) return; + const clipped = text.slice(0, MAX_DRAFT_CHARS); + try { + if (clipped.length === 0) localStorage.removeItem(DRAFT_KEY_PREFIX + draftKey); + else localStorage.setItem(DRAFT_KEY_PREFIX + draftKey, clipped); + } catch { + // Quota or private mode. Draft recovery is best-effort. } - pendingDrafts.clear(); - writeJson(DRAFTS_KEY, drafts); - writeJson(VERSIONS_KEY, stored); -} - -export function readBotDraft(draftKey: string): string { - return pendingDrafts.get(draftKey)?.text ?? readAll()[draftKey] ?? ""; } -/** Records a draft in memory and persists it after typing pauses. */ -export function writeBotDraft(draftKey: string, text: string): void { - pendingDrafts.set(draftKey, { - text: text.slice(0, MAX_DRAFT_CHARS), - seenVersion: readVersions().versions[draftKey], - }); - installUnloadListeners(); - if (flushTimer !== null) clearTimeout(flushTimer); - flushTimer = setTimeout(flushBotDrafts, FLUSH_DELAY_MS); +export function clearBotDraft(draftKey: string): void { + writeBotDraft(draftKey, ""); } -/** Clears a draft and persists the removal immediately. */ -export function clearBotDraft(draftKey: string): void { - pendingDrafts.set(draftKey, { text: "", seenVersion: readVersions().versions[draftKey] }); - flushBotDrafts(); +/** Resets the once-per-page legacy migration. Tests only. */ +export function resetBotDraftMigrationForTests(): void { + legacyMigrated = false; } From ec8cd60a0c295bbc9e7d9ca29cc8709112f2f03b Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 26 Sep 2026 14:47:08 -0400 Subject: [PATCH 7/7] fix(web): restore synchronous draft store and keep long chats' speech cached Every change to how drafts reach storage opened a new cross-tab or upgrade ordering hole, and the saving was small. Restore the base draft store so drafts keep their existing, correct write-through behavior. The spoken-text cache cleared itself when full, so a pass over a chat with more replies than the limit missed on every revisit. Evict only the oldest entry and raise the limit to 2,000 replies. Co-Authored-By: Claude Fable 5.1 --- .changeset/perf-web-render.md | 2 +- .../components/roster/botDraftStore.test.ts | 33 ++------- .../src/components/roster/botDraftStore.ts | 69 ++++++++----------- .../src/replyPlayback/session.test.ts | 31 ++++++++- .../src/replyPlayback/session.ts | 10 ++- 5 files changed, 72 insertions(+), 73 deletions(-) diff --git a/.changeset/perf-web-render.md b/.changeset/perf-web-render.md index 020730701650..af6c847a48ec 100644 --- a/.changeset/perf-web-render.md +++ b/.changeset/perf-web-render.md @@ -2,4 +2,4 @@ "@t3tools/web": patch --- -Smoother web chats: long conversations stop re-rendering on every update, working-bot animations use far less GPU, and message drafts save without slowing typing. +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/botDraftStore.test.ts b/apps/web/src/components/roster/botDraftStore.test.ts index 6399341e890e..86b0a3c930f1 100644 --- a/apps/web/src/components/roster/botDraftStore.test.ts +++ b/apps/web/src/components/roster/botDraftStore.test.ts @@ -1,17 +1,11 @@ -import { beforeEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; -import { - clearBotDraft, - readBotDraft, - resetBotDraftMigrationForTests, - writeBotDraft, -} from "./botDraftStore"; +import { clearBotDraft, readBotDraft, writeBotDraft } from "./botDraftStore"; const memory = new Map(); beforeEach(() => { memory.clear(); - resetBotDraftMigrationForTests(); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { @@ -27,10 +21,13 @@ beforeEach(() => { }); }); +afterEach(() => { + memory.clear(); +}); + describe("botDraftStore", () => { it("restores a typed draft after a simulated restart", () => { writeBotDraft("bot-1", "yo what tool calls u got?"); - resetBotDraftMigrationForTests(); expect(readBotDraft("bot-1")).toBe("yo what tool calls u got?"); }); @@ -38,7 +35,6 @@ describe("botDraftStore", () => { writeBotDraft("bot-1", "half a sentence"); clearBotDraft("bot-1"); expect(readBotDraft("bot-1")).toBe(""); - expect(memory.size).toBe(0); }); it("keeps drafts for other bots", () => { @@ -47,21 +43,4 @@ describe("botDraftStore", () => { clearBotDraft("bot-1"); expect(readBotDraft("bot-2")).toBe("two"); }); - - it("writes only the edited draft's key", () => { - writeBotDraft("bot-2", "untouched"); - writeBotDraft("bot-1", "hey"); - expect([...memory.keys()].sort()).toEqual([ - "akeru:bot-draft:v2:bot-1", - "akeru:bot-draft:v2:bot-2", - ]); - }); - - it("moves legacy drafts to per-draft keys once", () => { - memory.set("akeru:bot-drafts:v1", JSON.stringify({ "bot-1": "old draft", "bot-2": 3 })); - expect(readBotDraft("bot-1")).toBe("old draft"); - expect(memory.has("akeru:bot-drafts:v1")).toBe(false); - clearBotDraft("bot-1"); - expect(readBotDraft("bot-1")).toBe(""); - }); }); diff --git a/apps/web/src/components/roster/botDraftStore.ts b/apps/web/src/components/roster/botDraftStore.ts index 2c1352e26572..329c3a7a5c56 100644 --- a/apps/web/src/components/roster/botDraftStore.ts +++ b/apps/web/src/components/roster/botDraftStore.ts @@ -1,8 +1,4 @@ -// Each draft lives under its own key and is written as it is typed, so a keystroke costs one -// small write instead of rewriting every draft, and the last write across tabs wins. -const DRAFT_KEY_PREFIX = "akeru:bot-draft:v2:"; -// Drafts saved before per-draft keys, moved over once per page load. -const LEGACY_DRAFTS_KEY = "akeru:bot-drafts:v1"; +const DRAFTS_KEY = "akeru:bot-drafts:v1"; const MAX_DRAFT_CHARS = 20_000; function storage(): Storage | null { @@ -13,60 +9,49 @@ function storage(): Storage | null { } } -let legacyMigrated = false; - -function migrateLegacyDrafts(localStorage: Storage): void { - if (legacyMigrated) return; - legacyMigrated = true; +function readAll(): Record { + const localStorage = storage(); + if (!localStorage) return {}; try { - const raw = localStorage.getItem(LEGACY_DRAFTS_KEY); - if (raw === null) return; + const raw = localStorage.getItem(DRAFTS_KEY); + if (raw === null) return {}; const parsed: unknown = JSON.parse(raw); - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { - for (const [draftKey, text] of Object.entries(parsed)) { - const key = DRAFT_KEY_PREFIX + draftKey; - if (typeof text === "string" && text.length > 0 && localStorage.getItem(key) === null) { - localStorage.setItem(key, text.slice(0, MAX_DRAFT_CHARS)); - } - } - } - localStorage.removeItem(LEGACY_DRAFTS_KEY); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[1] === "string", + ), + ); } catch { - // Unreadable legacy drafts are dropped. Draft recovery is best-effort. + return {}; } } -function draftStorage(): Storage | null { +function writeAll(drafts: Record): void { const localStorage = storage(); - if (localStorage) migrateLegacyDrafts(localStorage); - return localStorage; -} - -export function readBotDraft(draftKey: string): string { + if (!localStorage) return; try { - return draftStorage()?.getItem(DRAFT_KEY_PREFIX + draftKey) ?? ""; + localStorage.setItem(DRAFTS_KEY, JSON.stringify(drafts)); } catch { - return ""; + // Quota or private mode. Draft recovery is best-effort. } } +export function readBotDraft(draftKey: string): string { + return readAll()[draftKey] ?? ""; +} + export function writeBotDraft(draftKey: string, text: string): void { - const localStorage = draftStorage(); - if (!localStorage) return; + const drafts = readAll(); const clipped = text.slice(0, MAX_DRAFT_CHARS); - try { - if (clipped.length === 0) localStorage.removeItem(DRAFT_KEY_PREFIX + draftKey); - else localStorage.setItem(DRAFT_KEY_PREFIX + draftKey, clipped); - } catch { - // Quota or private mode. Draft recovery is best-effort. + if (clipped.length === 0) { + delete drafts[draftKey]; + } else { + drafts[draftKey] = clipped; } + writeAll(drafts); } export function clearBotDraft(draftKey: string): void { writeBotDraft(draftKey, ""); } - -/** Resets the once-per-page legacy migration. Tests only. */ -export function resetBotDraftMigrationForTests(): void { - legacyMigrated = false; -} diff --git a/packages/client-runtime/src/replyPlayback/session.test.ts b/packages/client-runtime/src/replyPlayback/session.test.ts index efe08ee08484..a4de0fbe6bd9 100644 --- a/packages/client-runtime/src/replyPlayback/session.test.ts +++ b/packages/client-runtime/src/replyPlayback/session.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { STORED_REPLY_SYNTHESIS_UNAVAILABLE } from "./capability.ts"; import { replyReadoutMessageAction } from "./messageAction.ts"; -import { createReplyPlaybackSession } from "./session.ts"; +import { createReplyPlaybackSession, SPOKEN_CACHE_LIMIT } from "./session.ts"; vi.mock("./messageAction.ts", async (importOriginal) => { const actual = await importOriginal(); @@ -61,6 +61,35 @@ describe("reply playback session", () => { 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 191c5b0bf33a..d9d230835070 100644 --- a/packages/client-runtime/src/replyPlayback/session.ts +++ b/packages/client-runtime/src/replyPlayback/session.ts @@ -24,7 +24,8 @@ export interface ReplyPlaybackMessage { type SpokenReply = NonNullable>; -const SPOKEN_CACHE_LIMIT = 500; +// 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; @@ -63,8 +64,13 @@ export function createReplyPlaybackSession(options: { if (cached && cached.text === message.text) return cached.spoken; const spoken = replyReadoutMessageAction(message); if (!spoken) return null; - if (spokenCache.size >= SPOKEN_CACHE_LIMIT) spokenCache.clear(); + // 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 = () =>