From f6adbfba285c6b24352322d32922dde5488d95b8 Mon Sep 17 00:00:00 2001 From: Pearce Kieser <5055971+Pearcekieser@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:17:45 +0000 Subject: [PATCH] perf(web): run fewer selectors on each store notification Every mounted useAppSelector runs on every store notification. In the s1x5 render benchmark that was about 600 selector calls per notification, and two row components made up most of them: each transcript tool row held 13 subscriptions and each sidebar session row held 5. - ToolCallLine reads every store fact it draws through one row-view selector, compared field by field with the old equality rule for each field (sameRows for run members, shallowEqual for run outputs and the wait state, identity for the rest). - SessionRow reads its slot-scoped status, automation, queue and workflow facts through one selector with shallowEqual over a flat result. - slotRowByKey replaces dashboard.slots.find(key) in selectors with a key-to-row Map built once per slots array identity (WeakMap cache, first row wins like find). --- website/src/components/ChatHeaderChips.tsx | 3 +- website/src/components/ChatPane.tsx | 5 +- website/src/components/InboundLinkChip.tsx | 3 +- .../src/components/LinkedSurfacesSection.tsx | 3 +- website/src/components/SessionActionsMenu.tsx | 3 +- website/src/components/SlotTagPopover.tsx | 3 +- website/src/components/SourceLinksSubmenu.tsx | 3 +- .../src/components/chat-input/busySend.tsx | 3 +- website/src/components/chat-input/voice.ts | 3 +- .../providers/actionsProvider.ts | 3 +- .../components/library/ArtifactScopeChips.tsx | 3 +- website/src/components/useChatPanePickers.ts | 3 +- website/src/hooks/useCrewAvatarState.ts | 5 +- website/src/lib/closeBusyGate.ts | 3 +- website/src/pages/PopoutFrame.tsx | 3 +- website/src/pages/chat-sidebar/SessionRow.tsx | 39 +- website/src/pages/chat/ActivityViewer.tsx | 5 +- .../src/pages/chat/SessionTitleControl.tsx | 5 +- website/src/pages/chat/ToolCallLine.tsx | 458 +++++++++--------- .../command-center/CommandCenterPanel.tsx | 3 +- .../command-center/SessionStatusFrame.tsx | 3 +- .../src/pages/chat/page/activeSlotState.ts | 3 +- .../src/pages/members/MembersPageThread.tsx | 3 +- website/src/shell/appShell.tsx | 3 +- website/src/shell/boot/startupVideo.tsx | 3 +- website/src/store/chat/selectors.ts | 11 +- website/src/store/slotIndex.ts | 29 ++ website/src/test/slotIndex.test.ts | 38 ++ 28 files changed, 394 insertions(+), 258 deletions(-) create mode 100644 website/src/store/slotIndex.ts create mode 100644 website/src/test/slotIndex.test.ts diff --git a/website/src/components/ChatHeaderChips.tsx b/website/src/components/ChatHeaderChips.tsx index 96736f7a130..a73f0adafe5 100644 --- a/website/src/components/ChatHeaderChips.tsx +++ b/website/src/components/ChatHeaderChips.tsx @@ -1,6 +1,7 @@ import { useAppSelector } from '../store' import ConcurrencyCutChip, { SessionQueuedMarker } from './ConcurrencyCutChip' import InboundLinkChip from './InboundLinkChip' +import { slotRowByKey } from '../store/slotIndex' /** * The status chips the chat header shows after the session title, in order: @@ -15,7 +16,7 @@ import InboundLinkChip from './InboundLinkChip' export default function ChatHeaderChips({ slotKey }: { slotKey?: string }) { // The same test InboundLinkChip renders on: a two-way link on this slot. const crowded = useAppSelector(s => !!slotKey - && !!s.dashboard.slots.find(x => x.key === slotKey)?.links?.some(link => link.direction === 'both')) + && !!slotRowByKey(s.dashboard.slots, slotKey)?.links?.some(link => link.direction === 'both')) return ( <> diff --git a/website/src/components/ChatPane.tsx b/website/src/components/ChatPane.tsx index c5450a4390e..5ecf7d77985 100644 --- a/website/src/components/ChatPane.tsx +++ b/website/src/components/ChatPane.tsx @@ -67,6 +67,7 @@ import { useChatPaneSend } from './useChatPaneSend' import { useChatPaneStop, useChatPaneRunSync } from './useChatPaneTurnControl' import { useChatPaneContinue } from './useChatPaneContinue' import { useChatPaneRenderers, ChatPaneAboveRows } from './ChatPaneTranscript' +import { slotRowByKey } from '../store/slotIndex' export type { PaneLeading } from './ChatPaneProps' @@ -183,7 +184,7 @@ export default function ChatPane({ // Prefer the warm's value: this pane's own query is staleTime:Infinity, so its // has_more freezes at mount while a later bounded warm can truncate the cache. const warmHasMore = useAppSelector((s) => s.chat.slotPaneHasMore?.[slotKey]) - const paneSlot = useAppSelector((s) => s.dashboard.slots.find((x) => x.key === slotKey)) + const paneSlot = useAppSelector((s) => slotRowByKey(s.dashboard.slots, slotKey)) // A stop is under way on this pane's turn: the stream state's own `stopping` // phase, or a Stop pressed from another tab that reaches this pane only as // the slots frame's `stopping` flag. This is the footer's and composer's @@ -243,7 +244,7 @@ export default function ChatPane({ // history key (dashboard:); strip the prefix to match the bare slot key. const parentKey = paneSlot?.forked_from ? paneSlot.forked_from.replace(/^dashboard:/, '') : null const parentSlot = useAppSelector((s) => - parentKey ? s.dashboard.slots.find((x) => x.key === parentKey) : undefined, + parentKey ? slotRowByKey(s.dashboard.slots, parentKey) : undefined, ) // An untitled parent reads "Untitled session", never its slot key; the tag's // hover keeps the key so two untitled parents stay tellable apart. A parent diff --git a/website/src/components/InboundLinkChip.tsx b/website/src/components/InboundLinkChip.tsx index 9e01d20bb40..b3a245ca059 100644 --- a/website/src/components/InboundLinkChip.tsx +++ b/website/src/components/InboundLinkChip.tsx @@ -4,6 +4,7 @@ import { useAppSelector } from '../store' import { ChannelBrandIcon } from './ChannelBrandIcon' import LinkedSurfacesSection from './LinkedSurfacesSection' import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from './ui/dropdown-menu' +import { slotRowByKey } from '../store/slotIndex' /** * Header chip for a session that is being DRIVEN from another channel. @@ -57,7 +58,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from './ui/dro * state and inert in the other is the dead click again, on the other state. */ export default function InboundLinkChip({ slotKey }: { slotKey?: string }) { - const slot = useAppSelector(s => s.dashboard.slots.find(x => x.key === slotKey)) + const slot = useAppSelector(s => slotRowByKey(s.dashboard.slots, slotKey)) const inbound = slot?.links?.find(link => link.direction === 'both') if (!slotKey || !inbound) return null diff --git a/website/src/components/LinkedSurfacesSection.tsx b/website/src/components/LinkedSurfacesSection.tsx index f8cf5dd8303..37d335cc4de 100644 --- a/website/src/components/LinkedSurfacesSection.tsx +++ b/website/src/components/LinkedSurfacesSection.tsx @@ -13,6 +13,7 @@ import { ChannelBrandIcon } from './ChannelBrandIcon' import ErrorNotice, { ErrorNoticeMenuItem } from './ErrorNotice' import { ContextMenuItem } from './ui/context-menu' import { DropdownMenuItem } from './ui/dropdown-menu' +import { slotRowByKey } from '../store/slotIndex' /** * One row per channel, and the row's LABEL is the action. @@ -165,7 +166,7 @@ export default function LinkedSurfacesSection({ slotKey, variant, channel }: { const targetsErrorId = useId() const rowErrorIdPrefix = useId() const dispatch = useAppDispatch() - const slot = useAppSelector(s => s.dashboard.slots.find(x => x.key === slotKey)) + const slot = useAppSelector(s => slotRowByKey(s.dashboard.slots, slotKey)) // No synthesized Slack row. The wire emits a Slack row on exactly the condition // it reports `slack_linked`, and the row is what carries `paused` — a row // invented here could not know the disconnect, so it rendered a disconnected diff --git a/website/src/components/SessionActionsMenu.tsx b/website/src/components/SessionActionsMenu.tsx index 57975818fe6..0aa130680ab 100644 --- a/website/src/components/SessionActionsMenu.tsx +++ b/website/src/components/SessionActionsMenu.tsx @@ -25,6 +25,7 @@ import { useChatPopouts } from '../hooks/useChatPopouts' import { uiLocation } from '../uiLocations/uiLocation' import { i18nT } from '../i18n/t' +import { slotRowByKey } from '../store/slotIndex' export interface SessionActionsMenuProps { /** Chooses the Radix primitive family; must match the enclosing menu. */ variant: 'dropdown' | 'context' @@ -161,7 +162,7 @@ export default function SessionActionsMenu({ // its behaviour). `unread` comes from dashboard.unreadSlots — the same source // toggleRead reads — and pin/folder/colour from the slot itself. const isUnread = useAppSelector(s => s.dashboard.unreadSlots.includes(slotKey)) - const slot = useAppSelector(s => s.dashboard.slots.find(x => x.key === slotKey)) + const slot = useAppSelector(s => slotRowByKey(s.dashboard.slots, slotKey)) const isPinned = !!slot?.pinned const isMutesOpened = !!slot?.mutes_opened const isMuted = !!slot?.muted diff --git a/website/src/components/SlotTagPopover.tsx b/website/src/components/SlotTagPopover.tsx index 02dac2d42d3..0a66da7d51a 100644 --- a/website/src/components/SlotTagPopover.tsx +++ b/website/src/components/SlotTagPopover.tsx @@ -12,6 +12,7 @@ import ErrorNotice from './ErrorNotice' import { crudErrorMessage } from './tagMutationError' import { i18nT } from '../i18n/t' +import { slotRowByKey } from '../store/slotIndex' /** * The topmost element at viewport point (x, y) that is NOT `backdrop` or one of @@ -128,7 +129,7 @@ const readRejection = (error: unknown): RejectionPayload => { */ export default function SlotTagPopover() { const { slotKey, close } = useTagPopover() - const slot = useAppSelector(s => (slotKey ? s.dashboard.slots.find(x => x.key === slotKey) : undefined)) + const slot = useAppSelector(s => (slotKey ? slotRowByKey(s.dashboard.slots, slotKey) : undefined)) const queryClient = useQueryClient() const ime = useImeGuard() const listRef = useRef(null) diff --git a/website/src/components/SourceLinksSubmenu.tsx b/website/src/components/SourceLinksSubmenu.tsx index a7e9cbef0e4..921d4038942 100644 --- a/website/src/components/SourceLinksSubmenu.tsx +++ b/website/src/components/SourceLinksSubmenu.tsx @@ -11,6 +11,7 @@ import { findReport } from '../utils/errorReport' import ErrorNotice, { ErrorNoticeMenuItem } from './ErrorNotice' import { DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuItem, DropdownMenuSeparator } from './ui/dropdown-menu' import { ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent, ContextMenuItem, ContextMenuSeparator } from './ui/context-menu' +import { slotRowByKey } from '../store/slotIndex' type SourceLink = NonNullable[number] const unlinkKey = (slotKey: string) => ['unlink-source-link', slotKey] @@ -90,7 +91,7 @@ export default function SourceLinksSubmenu({ slotKey, variant }: { const errorId = useId() const connected = useConnected() const store = useAppStore() - const slot = useAppSelector(s => s.dashboard.slots.find(x => x.key === slotKey)) + const slot = useAppSelector(s => slotRowByKey(s.dashboard.slots, slotKey)) const links = slot?.source_links ?? [] const total = slot?.source_links_total // Stable identity of the session CURRENTLY behind ``slotKey``. It must change diff --git a/website/src/components/chat-input/busySend.tsx b/website/src/components/chat-input/busySend.tsx index 4e7da597e22..c5b40c0df6c 100644 --- a/website/src/components/chat-input/busySend.tsx +++ b/website/src/components/chat-input/busySend.tsx @@ -11,6 +11,7 @@ import type { SendMode } from '../../pages/chat/ChatSettings' import { i18nT } from '../../i18n/t' import { StopControls } from './StopControls' import type { ComposerBusyMode } from './props' +import { slotRowByKey } from '../../store/slotIndex' /* The composer's send path: `fireComposer` is every Enter and Send, idle or busy (it holds the send while a batch dictation transcribes, and holds an @@ -82,7 +83,7 @@ export function busySteerFlag({ slotKey, busy, turnRunning, stopState, jevAutoCo * its composer reads; any other slot may have no live run state yet after a * reload, so its slots-stream row counts as running too. */ export function slotBusySteer(state: RootState, slot: string, jevAutoConsented: boolean): true | 'auto' | undefined { - const row = state.dashboard.slots.find(s => s.key === slot) + const row = slotRowByKey(state.dashboard.slots, slot) const active = slot === state.chat.activeSlot const rowRunning = !active && !!row?.running return busySteerFlag({ diff --git a/website/src/components/chat-input/voice.ts b/website/src/components/chat-input/voice.ts index 8662c4d0f11..e888a744cf0 100644 --- a/website/src/components/chat-input/voice.ts +++ b/website/src/components/chat-input/voice.ts @@ -9,6 +9,7 @@ import { i18nT } from '../../i18n/t' import type { ComposerVoiceInputProps } from '../../chat-core/composer/Composer' import type { ComposerControl } from '../composerControl' import { anotherEscapeOwnerIsOpen } from './escapeOwners' +import { slotRowByKey } from '../../store/slotIndex' /* Dictation as the composer sees it. The Composer root's Voice atom (`chat-core/composer/useComposerVoice.ts`) owns capture, the one-mic mutex @@ -278,7 +279,7 @@ export function useHoldToTalk({ onVoiceStart, onVoiceStop, onVoiceCancel, voiceC // An untitled chat (its stored title can be the raw slot key) is named // "Untitled session", so the label and the jump action never show the key. const micOwnerTitle = useAppSelector(s => { - const owner = voiceBusyElsewhereSession ? s.dashboard.slots.find(x => x.key === voiceBusyElsewhereSession) : undefined + const owner = voiceBusyElsewhereSession ? slotRowByKey(s.dashboard.slots, voiceBusyElsewhereSession) : undefined return owner ? sessionDisplayTitle(owner) : null }) const micHeldElsewhereLabel = micOwnerTitle diff --git a/website/src/components/commandPalette/providers/actionsProvider.ts b/website/src/components/commandPalette/providers/actionsProvider.ts index 26b219b9424..2f893ac6a27 100644 --- a/website/src/components/commandPalette/providers/actionsProvider.ts +++ b/website/src/components/commandPalette/providers/actionsProvider.ts @@ -13,6 +13,7 @@ import { fuzzyMatch, makeScoreThenNameComparator } from '../../../utils/fuzzyMat import type { ResourceProvider, Result } from '../types' import { i18nT } from '../../../i18n/t' +import { slotRowByKey } from '../../../store/slotIndex' /** * Actions provider (Search Everywhere). @@ -184,7 +185,7 @@ export function useActionsProvider(opts: { openShortcuts: () => void }): Resourc const { togglePin } = useSessionActions() const activeSlot = useAppSelector((s) => s.chat.activeSlot) const activePinned = useAppSelector( - (s) => s.dashboard.slots.find((slot) => slot.key === activeSlot)?.pinned ?? false, + (s) => slotRowByKey(s.dashboard.slots, activeSlot)?.pinned ?? false, ) // "New session" is a write (createSlot); route it through useMutation for diff --git a/website/src/components/library/ArtifactScopeChips.tsx b/website/src/components/library/ArtifactScopeChips.tsx index ecfc5738540..5f7a3aab2ce 100644 --- a/website/src/components/library/ArtifactScopeChips.tsx +++ b/website/src/components/library/ArtifactScopeChips.tsx @@ -7,6 +7,7 @@ import { useAppSelector } from '../../store' import type { ChatFolder } from '../../types' import { i18nT } from '../../i18n/t' import type { ReactNode } from 'react' +import { slotRowByKey } from '../../store/slotIndex' /** The query parameters that narrow the library to a chat's involvement. */ export type ArtifactChatScopeParam = 'touched_by' | 'chat_folder' @@ -30,7 +31,7 @@ export default function ArtifactScopeChips({ touchedBy, chatFolder, onClear, ask askAgent?: boolean }) { const chatTitle = useAppSelector( - s => (touchedBy ? s.dashboard.slots.find(x => x.key === touchedBy)?.title || '' : ''), + s => (touchedBy ? slotRowByKey(s.dashboard.slots, touchedBy)?.title || '' : ''), ) // The sidebar's own folder cache; fetched only when a folder scope needs a name. const { data: chatFolders, error: chatFoldersError } = useQuery({ diff --git a/website/src/components/useChatPanePickers.ts b/website/src/components/useChatPanePickers.ts index 8aa831290c1..91e45dc76af 100644 --- a/website/src/components/useChatPanePickers.ts +++ b/website/src/components/useChatPanePickers.ts @@ -25,6 +25,7 @@ import { api } from '../api/client' import { displayModel, modelChipMarker } from '../lib/model' import { useSettingsDefaultModel } from '../hooks/useSettingsDefaultModel' import { i18nT } from '../i18n/t' +import { slotRowByKey } from '../store/slotIndex' /** * `ChatPane`'s agent and model pickers, bound to the pane's own slot through @@ -52,7 +53,7 @@ export function useChatPanePickerModels({ slotKey, paneSlot, provider, dispatch, const agentsRefreshTrigger = useAppSelector((s) => s.dashboard.refreshTrigger ?? 0) // This pane takes no project prop, so read THIS slot's project from the store: // it scopes which project-local agents exist, so a project change must refetch. - const paneProject = useAppSelector((s) => s.dashboard.slots.find((x) => x.key === slotKey)?.project || undefined) + const paneProject = useAppSelector((s) => slotRowByKey(s.dashboard.slots, slotKey)?.project || undefined) const { agents: installedAgents, choices: catalogChoices, defaultAgent } = useAgents(agentsRefreshTrigger, slotKey, paneProject) // The picker lists every catalog row (a member and a template of one name // are two rows). A roster source that exposes only the folded list -- one diff --git a/website/src/hooks/useCrewAvatarState.ts b/website/src/hooks/useCrewAvatarState.ts index 1a956d9adde..0814c4be694 100644 --- a/website/src/hooks/useCrewAvatarState.ts +++ b/website/src/hooks/useCrewAvatarState.ts @@ -25,6 +25,7 @@ import { useAppSelector } from '../store' import { packSoundUrl } from '../lib/appearancePacks/library' import type { AvatarFaceState, AvatarSounds, AvatarState } from '../lib/crewAvatarState' import { loadSoundSettings, playPreset, playSoundFile } from './useNotificationSound' +import { slotRowByKey } from '../store/slotIndex' /** How long `done` / `error` shows before the face returns to rest. */ export const AVATAR_FLASH_MS = 4000 @@ -122,12 +123,12 @@ export function useCrewAvatarState({ return '' }) const liveRunning = useAppSelector((s) => - resolvedKey ? !!s.dashboard.slots.find((x) => x.key === resolvedKey)?.running : false, + resolvedKey ? !!slotRowByKey(s.dashboard.slots, resolvedKey)?.running : false, ) // `interrupted` is the backend's own reading of the transcript and is always // false while running, so it is only ever consulted on the stopping edge. const interrupted = useAppSelector((s) => - resolvedKey ? !!s.dashboard.slots.find((x) => x.key === resolvedKey)?.interrupted : false, + resolvedKey ? !!slotRowByKey(s.dashboard.slots, resolvedKey)?.interrupted : false, ) const isWorking = running ?? liveRunning diff --git a/website/src/lib/closeBusyGate.ts b/website/src/lib/closeBusyGate.ts index 57b98b091a5..27d452a07bb 100644 --- a/website/src/lib/closeBusyGate.ts +++ b/website/src/lib/closeBusyGate.ts @@ -8,6 +8,7 @@ import { import { isAutomationRunning } from '../monitoring/automation' import { hasLiveSessionWork } from '../pages/chat/sessionLane' import { normalizeRunSessionKey } from '../apps/workflows/runModel' +import { slotRowByKey } from '../store/slotIndex' /** * One kind of live work a close would stop. The close prompt names each one @@ -45,7 +46,7 @@ const LIVE_WORK_REASONS: ReadonlySet = new Set(['approval', 'tu export function closeBusyReasons(state: RootState, slotKey: string): CloseBusyReason[] { const reasons: CloseBusyReason[] = [] const subagentsQueued = state.chat.subagentQueued?.[slotKey] || 0 - const slot = state.dashboard.slots.find(s => s.key === slotKey) + const slot = slotRowByKey(state.dashboard.slots, slotKey) // Queued children are not in `hasLiveSessionWork` (nothing has started), but // closing retires them too, so they count even for an unlisted slot. if (!slot) return subagentsQueued > 0 ? ['subagents'] : [] diff --git a/website/src/pages/PopoutFrame.tsx b/website/src/pages/PopoutFrame.tsx index a4343d709dd..77e3f081fbc 100644 --- a/website/src/pages/PopoutFrame.tsx +++ b/website/src/pages/PopoutFrame.tsx @@ -5,6 +5,7 @@ import { useAppSelector } from '../store' import ChatPage from './ChatPage' import { i18nT } from '../i18n/t' +import { slotRowByKey } from '../store/slotIndex' /** * The window shell for a popped-out chat session (`/popout/chat/:slug?sid=…`). @@ -20,7 +21,7 @@ import { i18nT } from '../i18n/t' export default function PopoutFrame() { const [params] = useSearchParams() const sid = params.get('sid') || params.get('slot') || '' - const title = useAppSelector(s => s.dashboard.slots.find(x => x.key === sid)?.title) + const title = useAppSelector(s => slotRowByKey(s.dashboard.slots, sid)?.title) // Announce presence for `sid` and wire the heartbeat/control responder. useEffect(() => { diff --git a/website/src/pages/chat-sidebar/SessionRow.tsx b/website/src/pages/chat-sidebar/SessionRow.tsx index eb792145167..2ebf56a1ad4 100644 --- a/website/src/pages/chat-sidebar/SessionRow.tsx +++ b/website/src/pages/chat-sidebar/SessionRow.tsx @@ -7,7 +7,7 @@ import { motion } from 'framer-motion' import { X, Pin, VolumeX, EyeOff, VenetianMask, MoreVertical, CornerDownRight, GitFork, Loader, Loader2, Bot, ExternalLink, Workflow, TriangleAlert, Goal, MessageCircleQuestionMark, ShieldCheck, Hourglass, GripVertical, Moon } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import { shallowEqual } from 'react-redux' -import { useAppDispatch, useAppSelector } from '../../store' +import { useAppDispatch, useAppSelector, type RootState } from '../../store' import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent } from '../../components/ui/dropdown-menu' import { ContextMenu, ContextMenuTrigger, ContextMenuContent } from '../../components/ui/context-menu' import { offlineProps } from '../../utils/offline' @@ -48,6 +48,23 @@ import { useSlotTitleGenerating } from '../../hooks/slotTitleGeneration' import { DisclosureChevron, isOpenInTabModifierClick } from './chrome' import { SessionSourceChips } from './SessionSourceChips' +/** A row's slot-scoped live facts, read in ONE subscription. Flat, so the + * caller's shallowEqual compares each fact by identity: the workflow entry + * (rebuilt per run event) is spread into primitives for the same reason the + * old per-field subscription compared it shallowly. */ +function selectRowLive(st: RootState, localSlotKey: string) { + const wf = selectSidebarWorkflowActive(st)[normalizeRunSessionKey(localSlotKey)] + return { + statusDetail: st.chat.slotStatusDetail?.[localSlotKey], + automation: selectAutomationForSlot(st, localSlotKey), + queuedForSlot: st.chat.subagentQueued?.[localSlotKey] || 0, + queuedReason: st.chat.subagentQueuedReason?.[localSlotKey], + wfCount: wf?.count, + wfName: wf?.name, + wfPhase: wf?.phase, + } +} + /** * Session-row type scale, quantized to a 4px baseline grid. * @@ -343,16 +360,22 @@ export const SessionRow = memo(function SessionRow({ view, actions }: SessionRow // namespace and a collision would show another session's status here. // `selectAutomationForSlot` does its own `isUnsafeKey`/`safeKey` normalization, // so this needs no own-property guard of its own. - const statusDetail = useAppSelector(st => st.chat.slotStatusDetail?.[localSlotKey]) - const automation = useAppSelector(st => selectAutomationForSlot(st, localSlotKey)) + // + // ONE subscription for all of them (`selectRowLive`): every mounted row runs + // its selectors on every store notification, so five per row was five times + // the sidebar's share of each dispatch. shallowEqual over the flat result + // keeps each field's own identity rule: an entry object repaints only when + // THIS slot's entry is replaced, and the workflow primitives only when this + // slot's runs change. + const { statusDetail, automation, queuedForSlot, queuedReason, wfCount, wfName: wfRawName, wfPhase: wfRawPhase } = + useAppSelector(st => selectRowLive(st, localSlotKey), shallowEqual) const goalLoop = automation?.kind === 'legacy_goal_loop' ? automation : undefined const monitor = automation?.kind === 'structured_monitor' ? automation : null - const queuedForSlot = useAppSelector(st => st.chat.subagentQueued?.[localSlotKey] || 0) - const queuedReason = useAppSelector(st => st.chat.subagentQueuedReason?.[localSlotKey]) // {count, name, phase} of this slot's running workflow fan-out, or undefined. - // shallowEqual because the map is rebuilt per run event; the primitives only - // change when THIS slot's runs do. - const wf = useAppSelector(st => selectSidebarWorkflowActive(st)[normalizeRunSessionKey(localSlotKey)], shallowEqual) + const wf = useMemo( + () => (wfCount === undefined ? undefined : { count: wfCount, name: wfRawName ?? '', phase: wfRawPhase ?? '' }), + [wfCount, wfRawName, wfRawPhase], + ) // The conductor lane's count cluster, built HERE so it can sit inside the card's // own meta group. The lane hands over numbers and renders none of this itself, // which is what keeps one card implementation across every lane. diff --git a/website/src/pages/chat/ActivityViewer.tsx b/website/src/pages/chat/ActivityViewer.tsx index 7bad4cebf9c..822e284c115 100644 --- a/website/src/pages/chat/ActivityViewer.tsx +++ b/website/src/pages/chat/ActivityViewer.tsx @@ -27,6 +27,7 @@ import { runBelongsToSlot } from '../../apps/workflows/runModel' import { ContextBreakdownTab } from '../ContextBreakdownPanel' import ErrorBoundary from '../../components/ErrorBoundary' +import { slotRowByKey } from '../../store/slotIndex' // The crew log is a drill-in: six fold sections, their own i18n copy and the table // that draws each one, on a tab most sessions never open. Fetched on first open @@ -797,13 +798,13 @@ function SessionArtifactsTab({ slot, onArtifactOpen }: { slot: string; onArtifac // Companion binding for this slot. Narrowed to the primitive so an unrelated // slot field changing (message count, running flag) can't re-render the tab. const boundArtifactSlug = useAppSelector( - s => s.dashboard.slots.find(x => x.key === slot)?.artifact || '', + s => slotRowByKey(s.dashboard.slots, slot)?.artifact || '', ) // The sidebar folder this chat is filed in, if any: it adds a "Browse this // folder" link that opens the library scoped to the chats in that folder and // its sub-folders. const slotFolderId = useAppSelector( - s => s.dashboard.slots.find(x => x.key === slot)?.folder_id || '', + s => slotRowByKey(s.dashboard.slots, slot)?.folder_id || '', ) const invalidate = () => { qc.invalidateQueries({ queryKey: ['session-artifact-records', slot] }) diff --git a/website/src/pages/chat/SessionTitleControl.tsx b/website/src/pages/chat/SessionTitleControl.tsx index 0ef11647902..f39cd0ecc65 100644 --- a/website/src/pages/chat/SessionTitleControl.tsx +++ b/website/src/pages/chat/SessionTitleControl.tsx @@ -14,6 +14,7 @@ import { errMessage } from '../../utils/thunkError' import { i18nT } from '../../i18n/t' import { uiLocation } from '../../uiLocations/uiLocation' import { isUntitledSession, sessionDisplayTitle } from '../../utils/sessionDisplayTitle' +import { slotRowByKey } from '../../store/slotIndex' /** * SessionTitleControl — the session title as ONE editable control: the @@ -101,11 +102,11 @@ export default function SessionTitleControl({ const dispatch = useAppDispatch() const store = useAppStore() const queryClient = useQueryClient() - const memoryMode = useAppSelector((s) => s.dashboard.slots.find((x) => x.key === slotKey)?.memory_mode) + const memoryMode = useAppSelector((s) => slotRowByKey(s.dashboard.slots, slotKey)?.memory_mode) // Pinning from the header menu moves the row to the sidebar's pinned group, // often out of view, so the header itself has to show the state: without // it a pin or unpin from the chevron gave no visible sign it took. - const pinned = useAppSelector((s) => !!s.dashboard.slots.find((x) => x.key === slotKey)?.pinned) + const pinned = useAppSelector((s) => !!slotRowByKey(s.dashboard.slots, slotKey)?.pinned) const [localEditing, setLocalEditing] = useState(false) const editing = editingProp ?? localEditing const setEditing = (next: boolean) => { setLocalEditing(next); onEditingChange?.(next) } diff --git a/website/src/pages/chat/ToolCallLine.tsx b/website/src/pages/chat/ToolCallLine.tsx index af8a5852ef2..ad1b75ce72d 100644 --- a/website/src/pages/chat/ToolCallLine.tsx +++ b/website/src/pages/chat/ToolCallLine.tsx @@ -36,6 +36,7 @@ import { api } from '../../api/client' import { useLanguageGeneration } from '../../i18n/useLanguageGeneration' import { isApprovedDecision, isRejectedDecision } from '../../utils/approvalDecision' import { selectToolRowIndex, lookupLogEntry, denySiblingContent, isNewestOccurrence } from './toolRowIndex' +import { slotRowByKey } from '../../store/slotIndex' import type { ToolActivity } from '../../types' import { pathBasename, decodePathForDisplay } from '../../utils/pathBasename' import { selectEditRuns } from './editRuns' @@ -199,6 +200,239 @@ const sameRows = (a: readonly ChatMessage[], b: readonly ChatMessage[]): boolean const NO_RUN: readonly ChatMessage[] = [] const NO_OUTPUTS: readonly string[] = [] +/** The call's tool-log entry, or the row's persisted meta when the log has + * none (a historical row). Returns purpose/input/output for the inline + * expansion as well as completion status for the icon. All transcript scans + * go through the shared per-slot index (see toolRowIndex.ts): built once per + * (messages, toolLog) identity change, O(1) per row per dispatch. */ +function readToolEntry(s: RootState, slot: string | undefined, message: ChatMessage, toolCallId: string | undefined, label: string) { + // Slot-aware: for a non-active slot (split-view pane) read that slot's + // per-slot tool log / messages / running state; `slot` undefined or equal to + // the active slot → active-slot globals. + const bg = slot && slot !== s.chat.activeSlot ? slot : null + const log = bg ? (s.chat.slotActivity[bg]?.toolLog ?? EMPTY_TOOL_LOG) : s.chat.toolLog + const slotRunning = bg ? ((s.chat.slotRun[bg]?.state ?? 'idle') !== 'idle') : s.chat.slotRunning + const msgs = bg ? (s.chat.slotMessages[bg] ?? EMPTY_MESSAGES) : s.chat.messages + const index = selectToolRowIndex(msgs, log) + + // Was this tool's permission resolved as rejected? Only the NEWEST + // permission message for the id carries the decision. + const wasRejectedByPerm = () => { + if (!toolCallId) return false + const perm = index.lastPermById.get(toolCallId) + return perm ? isRejectedDecision(perm.meta?.resolved) : false + } + + // Auto-denied detection. When a security-policy deny rule or hook blocks a + // tool, the gateway appends a SECOND tool message — "🚫 …" — + // sharing this pill's tool_call_id. That message never renders (TurnBlock + // hides every tool message not starting with 🔧), so the visible 🔧 pill + // must find its hidden sibling to know the call was blocked. Its CONTENT is + // used too: the row carries "— Blocked by security policy: <reason>", and the + // Output panel leads with its own LOCALIZED sentence and follows with the + // rule, so the user learns which rule fired without a translated sentence + // being replaced by untranslated English (extractDenyDetail yields "" for a + // row without that marker). A row without the marker but with the host's + // own "Blocked: …" sentence — the gate-crash refusal, which says no policy + // rule fired — yields that sentence as the NOTICE, and the panel shows it + // in place of the localized line rather than contradicting it. A row with + // neither (hook blocked) shows the localized line alone. The interactive + // user-reject path also appends a 🚫 message, but that flow ALSO resolves + // a permission message as rejected — wasRejectedByPerm() takes precedence + // below, so a user rejection still shows red, not amber. + const denySibling = denySiblingContent(index, toolCallId, message) + const autoDenied = !!denySibling + const autoDenyReason = extractDenyDetail(denySibling) + const autoDenyNotice = extractDenyNotice(denySibling) + + const e = lookupLogEntry(index, toolCallId, label) + if (e) { + const rejected = !!e.rejected || wasRejectedByPerm() + const isDone = e.output != null || rejected || autoDenied || !slotRunning + return { + effectiveId: e.tool_call_id || null, + isDone, isRejected: rejected, + isAutoDenied: !rejected && autoDenied, + autoDenyReason, + autoDenyNotice, + purpose: e.purpose || '', + input: e.input || '', + output: e.output || '', + // Clamp seams for the two payloads (null when nothing was cut). The + // panel renders the localized marker there at view time; the store + // holds only the offset, so a language switch re-renders it. + inputCut: e.input_cut ?? null, + outputCut: e.output_cut ?? null, + auto: !!e.auto, + ts: e.ts || 0, + executionStartedAt: e.execution_started_at || 0, + hasEntry: true, + // Older ACP update frames may omit is_shell; execute is the stable + // tool-kind value used by the transport and keeps those frames live. + isShell: e.is_shell === true || e.kind === 'execute' || e.text.startsWith('Running:'), + // Raw ACP tool kind — gates the inline diff-card promotion below + // (only kind === 'edit' rows promote). + toolKind: e.kind || '', + // Raw transport tool name, kept separate from the display `label`: + // the label is simplified/localized for humans, so gating behaviour on + // it would break under `useSimplifiedToolNames` or a translated UI. + toolName: e.text || '', + // Trusted identity from the transport's `_meta.kiro`, when it sent one; + // '' otherwise, and the title derivation falls back to parsing the title. + trustedToolName: e.tool_name || '', + mcpServer: e.mcp_server || '', + fromLog: true, + } + } + // No toolLog entry — historical message. Check permission state for rejection. + const rejected = wasRejectedByPerm() + // Backend persists `input` (when the call was issued) and `output` (when + // the result arrived) directly on the tool message's meta — see + // _tool_meta() and the EVENT_TOOL_RESULT handler in chat_runner.py. + // Pre-persistence messages won't have these fields and fall through to + // the empty-state hint inside ToolDetails. + const metaInput = (message.meta?.input as string | undefined) || '' + const metaOutput = (message.meta?.output as string | undefined) || '' + return { + effectiveId: toolCallId || null, + isDone: true, isRejected: rejected, + isAutoDenied: !rejected && autoDenied, + autoDenyReason, + autoDenyNotice, + purpose: (message.meta?.purpose as string) || '', + // Persisted meta is served whole by the server (_tool_meta caps at 1 MB, + // never clamps), so a historical row has no seam to mark. + input: metaInput, output: metaOutput, inputCut: null, outputCut: null, auto: false, + // ChatMessage.ts is a string (ISO timestamp) when restored from history; + // parse it for the meta-row time renderer. Falls to 0 if unparseable — + // fmtTime hides the row when ts is 0. + ts: typeof message.ts === 'number' ? message.ts : (message.ts ? Date.parse(String(message.ts)) || 0 : 0), + executionStartedAt: 0, + // Treat the message as having an entry when persisted I/O is available, + // so the empty-state copy only shows for truly bare historical messages. + hasEntry: !!(metaInput || metaOutput), + isShell: false, + // Persisted ACP tool kind (see _tool_meta in chat_runner.py). Rows + // written before the field existed read '' and never promote a card. + toolKind: (message.meta?.kind as string | undefined) || '', + // Historical rows have no log entry; the message content is the only + // carrier. Harmless either way — a replayed wait is never in flight. + toolName: message.content.replace(/^🔧\s*/, ''), + // Persisted by _tool_meta (chat_runner.py) since the title-derivation + // change; rows written before it read '' and derive from the title. + trustedToolName: (message.meta?.tool_name as string | undefined) || '', + mcpServer: (message.meta?.mcp_server as string | undefined) || '', + // No live tool-log entry backs this row. `isDone` above is a REPLAY + // default, not an observation, so anything that needs real liveness must + // consult server state instead of trusting it (see the wait countdown). + fromLog: false, + } +} + +/** The `wait` countdown this row owns, or null. See the comment above the + * countdown in ToolCallLineBody for why each guard is here. */ +function readWaitState(s: RootState, slot: string | undefined, waitSlotKey: string | null, toolCallId: string | undefined, + isWaitTool: boolean, hasPendingPerm: boolean, fromLog: boolean, isDone: boolean) { + if (!isWaitTool || hasPendingPerm) return null + if (fromLog && isDone) return null + const found = waitSlotKey ? slotRowByKey(s.dashboard.slots, waitSlotKey) : undefined + const ws = found?.wait_state + if (!ws) return null + // The turn must actually be running. Cheap on its own, but load-bearing for + // a replayed row: the historical branch cannot observe liveness, so without + // this an idle slot's old wait pill would count down against a deadline + // belonging to some other sleep the backend is tracking. + if (!found?.running) return null + const bg = slot && slot !== s.chat.activeSlot ? slot : null + const msgs = bg ? (s.chat.slotMessages[bg] ?? EMPTY_MESSAGES) : s.chat.messages + const log = bg ? (s.chat.slotActivity[bg]?.toolLog ?? EMPTY_TOOL_LOG) : s.chat.toolLog + // Exactly one pill owns the countdown, and it must be the transcript's + // newest TOOL call of any kind -- not merely its newest wait. Scanning only + // for waits would let a completed wait from earlier in the turn light up + // while the agent is busy in some later tool, which is the shape a sleep + // belonging to a different ACP session on this same slot would take. + const last = selectToolRowIndex(msgs, log).lastToolMsg + if (!last || last.meta?.tool_call_id !== toolCallId) return null + return isWaitToolTitle(last.content.replace(/^🔧\s*/, '')) ? ws : null +} + +/** Every store fact a tool row draws, read in ONE subscription. Each mounted + * row's selectors run on every store notification, and this used to be + * thirteen subscriptions per row, which made the transcript's tool rows the + * largest share of the selector calls per dispatch. Compare with + * `sameToolRowView`, which keeps each fact's old equality rule. */ +function selectToolRowView(s: RootState, slot: string | undefined, message: ChatMessage, toolCallId: string | undefined, label: string) { + const entry = readToolEntry(s, slot, message, toolCallId, label) + const waitSlotKey = slot ?? s.chat.activeSlot + const bg = slot && slot !== s.chat.activeSlot ? slot : null + const msgs = slotMessagesOf(s, slot) + const log = slotToolLogOf(s, slot) + const index = selectToolRowIndex(msgs, log) + // MCP App (SEP-1865) render payload attached to this tool call, if any. + // Rendered as an inline sandboxed iframe below the tool-call row. Selected + // by (slot, tool_call_id) — this row's own slot — so another session's app + // (and its callback capability) can never mount here. + const mcpApp = toolCallId && waitSlotKey ? s.chat.mcpApps?.[mcpAppKey(waitSlotKey, toolCallId)] : undefined + // The answers card belongs to one ask occurrence; on a reused id only the newest row owns the log entry's output. + const isNewestRow = isNewestOccurrence(index, toolCallId, message) + // Every call of the edit run this row leads (see editRuns.ts), lead first; + // empty unless this row leads a run of two or more. + const runMembers = selectEditRuns(msgs, log).membersByLead.get(message) ?? NO_RUN + const isRun = runMembers.length > 1 + // A person approved this call at a prompt, on the dashboard or a channel (its + // newest permission record says so). editRuns never merges such a call, so an approved edit can sit as its + // own row right after a merged run of the same file; the tag next to its + // chip says why it is separate. Auto-approved calls have no record and no tag. + const perm = toolCallId ? index.lastPermById.get(toolCallId) : undefined + const approvedByUser = perm ? isApprovedDecision(perm.meta?.resolved) : false + // A merged row is one row for several calls, so it reads as running until + // the last of them has returned. Followers draw nothing of their own. + const slotRunning = bg ? ((s.chat.slotRun[bg]?.state ?? 'idle') !== 'idle') : s.chat.slotRunning + const runMemberRunning = isRun && slotRunning && runMembers.slice(1).some(m => { + const e = index.logById.get(m.meta?.tool_call_id as string) + return !!e && e.output == null && !e.rejected + }) + // Every member's output, lead first: its newest tool-log entry's output when + // the log has one, else the output persisted on the row (history). The + // details panel lists them all, so a later member's result stays reachable. + const runOutputs = isRun + ? runMembers.map(m => index.logById.get(m.meta?.tool_call_id as string)?.output || (m.meta?.output as string | undefined) || '') + : NO_OUTPUTS + // The tool log is authoritative here: its output/rejection/turn-running + // state survives the ACP initial-call → update handoff. + const isDone = entry.isDone && !runMemberRunning + // Check if this specific tool has a pending (unresolved) permission matching its tool_call_id. + // Only match when tool_call_id is present on both sides — prevents batched approvals from + // incorrectly lighting up all pills as pending approval. + const hasPendingPerm = !isDone && !!toolCallId && index.pendingPermIds.has(toolCallId) + const waitState = readWaitState(s, slot, waitSlotKey, toolCallId, isWaitToolTitle(entry.toolName), hasPendingPerm, entry.fromLog, isDone) + // External focus signal (e.g. ChatInput's "Jump to tool" link) aimed at this pill. + const focusId = s.chat.focusToolCallId + const focused = !!focusId && !!entry.effectiveId && focusId === entry.effectiveId + // The slot's project directory, so native-tool titles show paths relative + // to it (`Read src/a.ts`, not the absolute path). Reads THIS row's slot — + // a split-view pane must not borrow the active session's tree. + const projectDir = waitSlotKey ? (slotRowByKey(s.dashboard.slots, waitSlotKey)?.project || undefined) : undefined + return { ...entry, isDone, mcpApp, isNewestRow, runMembers, approvedByUser, hasPendingPerm, runOutputs, waitSlotKey, waitState, focused, projectDir } +} + +type ToolRowView = ReturnType<typeof selectToolRowView> + +/** Field-wise equality for `selectToolRowView`: identity for every fact, except + * the three the separate subscriptions compared by value. The run index + * rebuilds on every transcript flush, so a fresh array of the same members or + * outputs must not repaint the row, and neither must a re-sent wait state. */ +function sameToolRowView(a: ToolRowView, b: ToolRowView): boolean { + for (const k of Object.keys(a) as (keyof ToolRowView)[]) { + if (k === 'runMembers') { + if (!sameRows(a.runMembers, b.runMembers)) return false + } else if (k === 'runOutputs' || k === 'waitState') { + if (!shallowEqual(a[k], b[k])) return false + } else if (!Object.is(a[k], b[k])) return false + } + return true +} + /** Inline tool call pill. Click toggles an expanded panel below the pill that * shows purpose / input / output. A row folded into an earlier edit run of * the same file (see editRuns.ts) draws nothing: the run's lead row shows it. */ @@ -225,15 +459,6 @@ function ToolCallLineBody({ message, running: _running, slot, onFileOpen, disclo const simplified = useSimplifiedToolNames() const uiLang = useLanguage().resolved - // MCP App (SEP-1865) render payload attached to this tool call, if any. - // Rendered as an inline sandboxed iframe below the tool-call row. Selected - // by (slot, tool_call_id) — this row's own slot — so another session's app - // (and its callback capability) can never mount here. - const mcpApp = useAppSelector(s => { - const sk = slot ?? s.chat.activeSlot - return toolCallId && sk ? s.chat.mcpApps?.[mcpAppKey(sk, toolCallId)] : undefined - }) - // An auto-approved call writes TWO tool rows under one `tool_call_id` (the // pre-approval pill and the post-approval one), and the durable app flag is // stored on both, so a bare flag check would draw the notice twice and read as @@ -276,195 +501,22 @@ function ToolCallLineBody({ message, running: _running, slot, onFileOpen, disclo // notice about this row's app. A row's meta is written with the row and no later // call can reassign it, which is the property the notice needs. const noticeServer = (message.meta?.mcp_server as string | undefined) || '' - // The answers card belongs to one ask occurrence; on a reused id only the newest row owns the log entry's output. - const isNewestRow = useAppSelector(s => { - const bg = slot && slot !== s.chat.activeSlot ? slot : null - const log = bg ? (s.chat.slotActivity[bg]?.toolLog ?? EMPTY_TOOL_LOG) : s.chat.toolLog - const msgs = bg ? (s.chat.slotMessages[bg] ?? EMPTY_MESSAGES) : s.chat.messages - return isNewestOccurrence(selectToolRowIndex(msgs, log), toolCallId, message) - }) - // Pull the matching toolLog entry. Returns purpose/input/output for the inline - // expansion as well as completion status for the icon. All transcript scans go - // through the shared per-slot index (see toolRowIndex.ts): built once per - // (messages, toolLog) identity change, O(1) per row per dispatch. - const { effectiveId, isDone: logIsDone, isRejected, isAutoDenied, autoDenyReason, autoDenyNotice, purpose, input, output, inputCut, outputCut, auto, ts, executionStartedAt, hasEntry, isShell, toolKind, toolName, trustedToolName, mcpServer, fromLog } = useAppSelector(s => { - // Slot-aware: for a non-active slot (split-view pane) read that slot's - // per-slot tool log / messages / running state; `slot` undefined or equal to - // the active slot → active-slot globals. - const bg = slot && slot !== s.chat.activeSlot ? slot : null - const log = bg ? (s.chat.slotActivity[bg]?.toolLog ?? EMPTY_TOOL_LOG) : s.chat.toolLog - const slotRunning = bg ? ((s.chat.slotRun[bg]?.state ?? 'idle') !== 'idle') : s.chat.slotRunning - const msgs = bg ? (s.chat.slotMessages[bg] ?? EMPTY_MESSAGES) : s.chat.messages - const index = selectToolRowIndex(msgs, log) - - // Was this tool's permission resolved as rejected? Only the NEWEST - // permission message for the id carries the decision. - const wasRejectedByPerm = () => { - if (!toolCallId) return false - const perm = index.lastPermById.get(toolCallId) - return perm ? isRejectedDecision(perm.meta?.resolved) : false - } - - // Auto-denied detection. When a security-policy deny rule or hook blocks a - // tool, the gateway appends a SECOND tool message — "🚫 <title> …" — - // sharing this pill's tool_call_id. That message never renders (TurnBlock - // hides every tool message not starting with 🔧), so the visible 🔧 pill - // must find its hidden sibling to know the call was blocked. Its CONTENT is - // used too: the row carries "— Blocked by security policy: <reason>", and the - // Output panel leads with its own LOCALIZED sentence and follows with the - // rule, so the user learns which rule fired without a translated sentence - // being replaced by untranslated English (extractDenyDetail yields "" for a - // row without that marker). A row without the marker but with the host's - // own "Blocked: …" sentence — the gate-crash refusal, which says no policy - // rule fired — yields that sentence as the NOTICE, and the panel shows it - // in place of the localized line rather than contradicting it. A row with - // neither (hook blocked) shows the localized line alone. The interactive - // user-reject path also appends a 🚫 message, but that flow ALSO resolves - // a permission message as rejected — wasRejectedByPerm() takes precedence - // below, so a user rejection still shows red, not amber. - const denySibling = denySiblingContent(index, toolCallId, message) - const autoDenied = !!denySibling - const autoDenyReason = extractDenyDetail(denySibling) - const autoDenyNotice = extractDenyNotice(denySibling) - - const e = lookupLogEntry(index, toolCallId, label) - if (e) { - const rejected = !!e.rejected || wasRejectedByPerm() - const isDone = e.output != null || rejected || autoDenied || !slotRunning - return { - effectiveId: e.tool_call_id || null, - isDone, isRejected: rejected, - isAutoDenied: !rejected && autoDenied, - autoDenyReason, - autoDenyNotice, - purpose: e.purpose || '', - input: e.input || '', - output: e.output || '', - // Clamp seams for the two payloads (null when nothing was cut). The - // panel renders the localized marker there at view time; the store - // holds only the offset, so a language switch re-renders it. - inputCut: e.input_cut ?? null, - outputCut: e.output_cut ?? null, - auto: !!e.auto, - ts: e.ts || 0, - executionStartedAt: e.execution_started_at || 0, - hasEntry: true, - // Older ACP update frames may omit is_shell; execute is the stable - // tool-kind value used by the transport and keeps those frames live. - isShell: e.is_shell === true || e.kind === 'execute' || e.text.startsWith('Running:'), - // Raw ACP tool kind — gates the inline diff-card promotion below - // (only kind === 'edit' rows promote). - toolKind: e.kind || '', - // Raw transport tool name, kept separate from the display `label`: - // the label is simplified/localized for humans, so gating behaviour on - // it would break under `useSimplifiedToolNames` or a translated UI. - toolName: e.text || '', - // Trusted identity from the transport's `_meta.kiro`, when it sent one; - // '' otherwise, and the title derivation falls back to parsing the title. - trustedToolName: e.tool_name || '', - mcpServer: e.mcp_server || '', - fromLog: true, - } - } - // No toolLog entry — historical message. Check permission state for rejection. - const rejected = wasRejectedByPerm() - // Backend persists `input` (when the call was issued) and `output` (when - // the result arrived) directly on the tool message's meta — see - // _tool_meta() and the EVENT_TOOL_RESULT handler in chat_runner.py. - // Pre-persistence messages won't have these fields and fall through to - // the empty-state hint inside ToolDetails. - const metaInput = (message.meta?.input as string | undefined) || '' - const metaOutput = (message.meta?.output as string | undefined) || '' - return { - effectiveId: toolCallId || null, - isDone: true, isRejected: rejected, - isAutoDenied: !rejected && autoDenied, - autoDenyReason, - autoDenyNotice, - purpose: (message.meta?.purpose as string) || '', - // Persisted meta is served whole by the server (_tool_meta caps at 1 MB, - // never clamps), so a historical row has no seam to mark. - input: metaInput, output: metaOutput, inputCut: null, outputCut: null, auto: false, - // ChatMessage.ts is a string (ISO timestamp) when restored from history; - // parse it for the meta-row time renderer. Falls to 0 if unparseable — - // fmtTime hides the row when ts is 0. - ts: typeof message.ts === 'number' ? message.ts : (message.ts ? Date.parse(String(message.ts)) || 0 : 0), - executionStartedAt: 0, - // Treat the message as having an entry when persisted I/O is available, - // so the empty-state copy only shows for truly bare historical messages. - hasEntry: !!(metaInput || metaOutput), - isShell: false, - // Persisted ACP tool kind (see _tool_meta in chat_runner.py). Rows - // written before the field existed read '' and never promote a card. - toolKind: (message.meta?.kind as string | undefined) || '', - // Historical rows have no log entry; the message content is the only - // carrier. Harmless either way — a replayed wait is never in flight. - toolName: message.content.replace(/^🔧\s*/, ''), - // Persisted by _tool_meta (chat_runner.py) since the title-derivation - // change; rows written before it read '' and derive from the title. - trustedToolName: (message.meta?.tool_name as string | undefined) || '', - mcpServer: (message.meta?.mcp_server as string | undefined) || '', - // No live tool-log entry backs this row. `isDone` above is a REPLAY - // default, not an observation, so anything that needs real liveness must - // consult server state instead of trusting it (see the wait countdown). - fromLog: false, - } - }, shallowEqual) + // Every store fact this row draws, in ONE subscription (selectToolRowView). + const { effectiveId, isDone, isRejected, isAutoDenied, autoDenyReason, autoDenyNotice, purpose, input, output, inputCut, outputCut, auto, ts, executionStartedAt, hasEntry, isShell, toolKind, toolName, trustedToolName, mcpServer, mcpApp, isNewestRow, runMembers, approvedByUser, hasPendingPerm, runOutputs, waitSlotKey, waitState, focused, projectDir } = + useAppSelector(s => selectToolRowView(s, slot, message, toolCallId, label), sameToolRowView) - // Every call of the edit run this row leads (see editRuns.ts), lead first; - // empty unless this row leads a run of two or more. - const runMembers = useAppSelector(s => slotEditRuns(s, slot).membersByLead.get(message) ?? NO_RUN, sameRows) const isRun = runMembers.length > 1 - // A person approved this call at a prompt, on the dashboard or a channel (its - // newest permission record says so). editRuns never merges such a call, so an approved edit can sit as its - // own row right after a merged run of the same file; the tag next to its - // chip says why it is separate. Auto-approved calls have no record and no tag. - const approvedByUser = useAppSelector(s => { - if (!toolCallId) return false - const perm = selectToolRowIndex(slotMessagesOf(s, slot), slotToolLogOf(s, slot)).lastPermById.get(toolCallId) - return perm ? isApprovedDecision(perm.meta?.resolved) : false - }) - // A merged row is one row for several calls, so it reads as running until - // the last of them has returned. Followers draw nothing of their own. - const runMemberRunning = useAppSelector(s => { - if (!isRun) return false - const bg = slot && slot !== s.chat.activeSlot ? slot : null - const slotRunning = bg ? ((s.chat.slotRun[bg]?.state ?? 'idle') !== 'idle') : s.chat.slotRunning - if (!slotRunning) return false - const log = bg ? (s.chat.slotActivity[bg]?.toolLog ?? EMPTY_TOOL_LOG) : s.chat.toolLog - const index = selectToolRowIndex(slotMessagesOf(s, slot), log) - return runMembers.slice(1).some(m => { - const e = index.logById.get(m.meta?.tool_call_id as string) - return !!e && e.output == null && !e.rejected - }) - }) - // Every member's output, lead first: its newest tool-log entry's output when - // the log has one, else the output persisted on the row (history). The - // details panel lists them all, so a later member's result stays reachable. - const runOutputs = useAppSelector(s => { - if (!isRun) return NO_OUTPUTS - const index = selectToolRowIndex(slotMessagesOf(s, slot), slotToolLogOf(s, slot)) - return runMembers.map(m => - index.logById.get(m.meta?.tool_call_id as string)?.output || (m.meta?.output as string | undefined) || '') - }, shallowEqual) // The tool log is authoritative here: its output/rejection/turn-running // state survives the ACP initial-call → update handoff, while the parent // `running` prop is not present on every historical/live rendering path. - const isDone = logIsDone && !runMemberRunning + // `isDone` already folds in a merged run's still-running members. const turnRunning = !isDone // Check if this specific tool has a pending (unresolved) permission matching its tool_call_id. // Only match when tool_call_id is present on both sides — prevents batched approvals from // incorrectly lighting up all pills as pending approval. - const hasPendingPerm = useAppSelector(s => { - if (isDone || !toolCallId) return false - const bg = slot && slot !== s.chat.activeSlot ? slot : null - const msgs = bg ? (s.chat.slotMessages[bg] ?? EMPTY_MESSAGES) : s.chat.messages - const log = bg ? (s.chat.slotActivity[bg]?.toolLog ?? EMPTY_TOOL_LOG) : s.chat.toolLog - return selectToolRowIndex(msgs, log).pendingPermIds.has(toolCallId) - }) // Shell commands do not expose a reliable total, so their live indicator is // deliberately indeterminate. The existing tool output remains the source @@ -507,31 +559,6 @@ function ToolCallLineBody({ message, running: _running, slot, onFileOpen, disclo // The `running` and newest-tool-row guards below are containment for a session // identity that resolves per RUNTIME rather than per ACP session; the cure is // tracked in https://github.com/kirodotdev/KiroCrew/issues/2347. - const isWaitTool = isWaitToolTitle(toolName) - const waitSlotKey = useAppSelector(s => slot ?? s.chat.activeSlot) - const waitState = useAppSelector(s => { - if (!isWaitTool || hasPendingPerm) return null - if (fromLog && isDone) return null - const found = waitSlotKey ? s.dashboard.slots.find(x => x.key === waitSlotKey) : undefined - const ws = found?.wait_state - if (!ws) return null - // The turn must actually be running. Cheap on its own, but load-bearing for - // a replayed row: the historical branch cannot observe liveness, so without - // this an idle slot's old wait pill would count down against a deadline - // belonging to some other sleep the backend is tracking. - if (!found?.running) return null - const bg = slot && slot !== s.chat.activeSlot ? slot : null - const msgs = bg ? (s.chat.slotMessages[bg] ?? EMPTY_MESSAGES) : s.chat.messages - const log = bg ? (s.chat.slotActivity[bg]?.toolLog ?? EMPTY_TOOL_LOG) : s.chat.toolLog - // Exactly one pill owns the countdown, and it must be the transcript's - // newest TOOL call of any kind -- not merely its newest wait. Scanning only - // for waits would let a completed wait from earlier in the turn light up - // while the agent is busy in some later tool, which is the shape a sleep - // belonging to a different ACP session on this same slot would take. - const last = selectToolRowIndex(msgs, log).lastToolMsg - if (!last || last.meta?.tool_call_id !== toolCallId) return null - return isWaitToolTitle(last.content.replace(/^🔧\s*/, '')) ? ws : null - }, shallowEqual) const showWaitCountdown = !!waitState && waitState.deadline_ts > 0 const [activityNow, setActivityNow] = useState(() => Date.now()) // Seed from execution_started_at (persisted in Redux, survives remount) when @@ -721,16 +748,15 @@ function ToolCallLineBody({ message, running: _running, slot, onFileOpen, disclo // then clear the focus so subsequent re-renders don't keep firing. Treat the // jump as user intent — clear pendingAutoExpand so the panel stays open // through approval resolution. - const focusToolCallId = useAppSelector(s => s.chat.focusToolCallId) useEffect(() => { - if (focusToolCallId && effectiveId && focusToolCallId === effectiveId) { + if (focused) { applyExpanded(true) setPendingAutoExpand(false) setUserChoseOpen(true) requestAnimationFrame(() => containerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })) dispatch(clearFocusToolCallId()) } - }, [focusToolCallId, effectiveId, dispatch, applyExpanded]) + }, [focused, dispatch, applyExpanded]) // File-op tool pills (read/edit/write) get a side-panel open affordance. // Extract the fs path from the tool's JSON args; the chip is gated on a @@ -902,10 +928,6 @@ function ToolCallLineBody({ message, running: _running, slot, onFileOpen, disclo // The slot's project directory, so native-tool titles show paths relative // to it (`Read src/a.ts`, not the absolute path). Reads THIS row's slot — // a split-view pane must not borrow the active session's tree. - const projectDir = useAppSelector(s => { - const key = slot ?? s.chat.activeSlot - return key ? (s.dashboard.slots.find(sl => sl.key === key)?.project || undefined) : undefined - }) const derived = useMemo(() => { void langGen // the localized title is a function of the catalog generation too diff --git a/website/src/pages/chat/command-center/CommandCenterPanel.tsx b/website/src/pages/chat/command-center/CommandCenterPanel.tsx index 9246968171a..f01756f9fc7 100644 --- a/website/src/pages/chat/command-center/CommandCenterPanel.tsx +++ b/website/src/pages/chat/command-center/CommandCenterPanel.tsx @@ -14,6 +14,7 @@ import { missingSourcesNotice, useCommandCenter } from './useCommandCenter' import AttentionCard, { RestoredQuestionNotice } from './AttentionCard' import { APPROVAL_MODE_KEYS, runTitle } from './model' import { PANEL_HEADING_ATTR } from './panelHeading' +import { slotRowByKey } from '../../../store/slotIndex' const CrewDynamicDashboard = lazy(() => import('../../members/CrewDynamicDashboard')) @@ -70,7 +71,7 @@ export default function CommandCenterPanel({ slot, active, sessionReady = true, // the dashboard PACKAGE; whether a session has a derived CARD to show is a different // question on a different route, and `canCarryCard` in `./model` is the one predicate // for that. - const owner = useAppSelector(s => slot ? s.dashboard.slots.find(item => item.key === slot) : undefined) + const owner = useAppSelector(s => slot ? slotRowByKey(s.dashboard.slots, slot) : undefined) const root = !!slot && !!owner && !owner.created_by && !owner.parent // A crewmate's DM slot is a root too, but its agent writes the CREWMATE's page, // not a session page, so the Overview shows that page. Decided by the roster's diff --git a/website/src/pages/chat/command-center/SessionStatusFrame.tsx b/website/src/pages/chat/command-center/SessionStatusFrame.tsx index 269cfd7a9b2..03e42fc821a 100644 --- a/website/src/pages/chat/command-center/SessionStatusFrame.tsx +++ b/website/src/pages/chat/command-center/SessionStatusFrame.tsx @@ -14,6 +14,7 @@ import { localizeCrewMainData } from './crewMainPhrases' import type { DynamicDashboardCard } from '../../../types/dynamicDashboard' import { useAppSelector } from '../../../store' import { canCarryCard } from './model' +import { slotRowByKey } from '../../../store/slotIndex' const STATUS_KEYS: Record<Exclude<DynamicDashboardCard['status'], 'published'>, string> = { disabled: 'commandCenter.card_disabled', waiting: 'commandCenter.card_waiting', @@ -31,7 +32,7 @@ export const TASK_DASHBOARD_SANDBOX = '' export default function SessionStatusFrame({ slot, title, active }: { slot: string; title: string; active: boolean }) { const { t, i18n } = useTranslation() const { theme, colorTheme, themeVersion } = useTheme() - const owner = useAppSelector(s => s.dashboard.slots.find(item => item.key === slot)) + const owner = useAppSelector(s => slotRowByKey(s.dashboard.slots, slot)) // Mirrors the backend's _derived_allowed on the privacy half (privacy and remoteness), // and drops only the CREATOR-link cost term: a spawned conductor (`created_by` set) still // owns a DERIVED board, which the read route serves before the cost gate, so we must fetch diff --git a/website/src/pages/chat/page/activeSlotState.ts b/website/src/pages/chat/page/activeSlotState.ts index b23486099f0..1deea1e6292 100644 --- a/website/src/pages/chat/page/activeSlotState.ts +++ b/website/src/pages/chat/page/activeSlotState.ts @@ -13,6 +13,7 @@ import { useTipTrigger } from '../../../components/TipCard' import { useKnowledgeFetch } from '../useKnowledgeFetch' import { closeCrewWindow, useCrewWindow } from '../crew-window/crewWindowStore' import { useGuideRevealScope } from '../../../guide/GuideRevealScope' +import { slotRowByKey } from '../../../store/slotIndex' // Stable identity for the "no follow-up cards" case: returning a fresh {} from // the selector would make it a new reference on every store update. @@ -215,7 +216,7 @@ export function useActiveSlotState({ const [splitAnchor, setSplitAnchor] = useState<string | null>(null) // Temporary sessions ("no memory reads or writes") must never show // memory-personalized tips. - const tipTemporary = useAppSelector(s => s.dashboard.slots.find(sl => sl.key === s.chat.activeSlot)?.memory_mode === 'temporary') + const tipTemporary = useAppSelector(s => slotRowByKey(s.dashboard.slots, s.chat.activeSlot)?.memory_mode === 'temporary') const tipBlocked = tipTemporary || splitMode || embedMode === 'sessions' const { tip: activeTip, dismiss: dismissTip } = useTipTrigger(!!slotRunning, tipSuppressed, activeSlot, tipBlocked) const slotState = useAppSelector(s => s.chat.slotState) diff --git a/website/src/pages/members/MembersPageThread.tsx b/website/src/pages/members/MembersPageThread.tsx index 629dcddc348..e047fb8b0f9 100644 --- a/website/src/pages/members/MembersPageThread.tsx +++ b/website/src/pages/members/MembersPageThread.tsx @@ -73,6 +73,7 @@ import { type MembersPageLayout } from './useMembersPageLayout' import { type MembersPageNavigation } from './useMembersPageNavigation' import { type MembersPageRosterRead, type MembersPageRosterSignals } from './useMembersPageRoster' import { type MembersPageTeamsRead, type MembersPageTeamView } from './useMembersPageTeams' +import { slotRowByKey } from '../../store/slotIndex' /** The notice for a refused Fresh start, by the route's refusal code: queued * work says to wait, a turn that would not stop says to stop it first. */ @@ -439,7 +440,7 @@ export function useMembersPageThreadView({ const headerInset = headerInBar ? 0 : threadHeaderH const [betweenHeaderRef, betweenHeaderH] = useMeasuredHeight<HTMLDivElement>() const pillStreamState = useAppSelector((s) => (pillSlotKey ? selectSlotStreamState(s, pillSlotKey) : 'idle')) - const pillLiveSlot = useAppSelector((s) => (pillSlotKey ? s.dashboard.slots.find((sl) => sl.key === pillSlotKey) : undefined)) + const pillLiveSlot = useAppSelector((s) => (pillSlotKey ? slotRowByKey(s.dashboard.slots, pillSlotKey) : undefined)) const uiLang = useLanguage().resolved const pillTurnAct = useSlotActivity(pillSlotKey, { running: !!active && !!isRunning(active), diff --git a/website/src/shell/appShell.tsx b/website/src/shell/appShell.tsx index a1571332b54..15ad1f756da 100644 --- a/website/src/shell/appShell.tsx +++ b/website/src/shell/appShell.tsx @@ -62,6 +62,7 @@ import { resolveSlotOverlays, type SlotOwners } from '../apps/overlaySlots' import { isChatRoute, useRouteActiveModel } from './nav/routeActive' import { useDeveloperMode } from './nav/developerMode' import { appPathname } from '../lib/basePath' +import { slotRowByKey } from '../store/slotIndex' /** Mobile nav drawer travel: its 220px width + the 8px mx-2 inset + border. */ /** Mobile nav drawer width. Shared with its travel below so the two cannot drift @@ -769,7 +770,7 @@ export function useAppShell() { // is still its key has no real title yet (same rule as the chat popout). const activeSessionTitle = useAppSelector(s => { const key = s.chat.activeSlot - const title = key ? s.dashboard.slots.find(x => x.key === key)?.title : undefined + const title = key ? slotRowByKey(s.dashboard.slots, key)?.title : undefined return title && title !== key ? title : '' }) diff --git a/website/src/shell/boot/startupVideo.tsx b/website/src/shell/boot/startupVideo.tsx index b8f29339baa..49425694d40 100644 --- a/website/src/shell/boot/startupVideo.tsx +++ b/website/src/shell/boot/startupVideo.tsx @@ -8,6 +8,7 @@ import { markStartupVideoHandled, startupVideoHandledThisLaunch, } from '../../components/startupVideoGate' +import { slotRowByKey } from '../../store/slotIndex' // Lazy, like the update-found popup `App.tsx` mounts: the startup feature clip pulls in // a video element and the whole share-card graph, and most launches never show @@ -64,7 +65,7 @@ export function useStartupVideo({ // not get asked. Read off the ACTIVE slot, matching where `memory_mode` is // authoritative everywhere else. const activeSlotMemoryMode = useAppSelector( - s => s.dashboard.slots.find(x => x.key === s.chat.activeSlot)?.memory_mode, + s => slotRowByKey(s.dashboard.slots, s.chat.activeSlot)?.memory_mode, ) // The slot list is filled by a fetch that lands AFTER mount. Until it does, the // active slot resolves to nothing and `activeSlotMemoryMode` is undefined -- diff --git a/website/src/store/chat/selectors.ts b/website/src/store/chat/selectors.ts index 8cf525ec4f0..161a4341c4c 100644 --- a/website/src/store/chat/selectors.ts +++ b/website/src/store/chat/selectors.ts @@ -11,6 +11,7 @@ import type { InjectKind } from '../../pages/chat/RecoveryCard' import type { SlotState } from './state' import { safeKey } from './wire' import { selectSlotSubagentsActive } from './subagents' +import { slotRowByKey } from '../slotIndex' /** Path B selectors: read a slot's messages / stream-state, falling back to the * global active mirror when the slot IS the currently-active one. */ @@ -25,7 +26,7 @@ export const selectSlotMessages = (state: RootState, slot: string): ChatMessage[ * not its reply to the person reading. A structured monitor's wake runs before * its row is written; until then the slot frame says so (`wake_starting`). */ export const selectSlotTurnFromWake = (state: RootState, slot: string): boolean => { - if (state.dashboard.slots.find((sl) => sl.key === slot)?.wake_starting) return true + if (slotRowByKey(state.dashboard.slots, slot)?.wake_starting) return true const msgs = selectSlotMessages(state, slot) for (let i = msgs.length - 1; i >= 0; i--) { const m = msgs[i] @@ -92,7 +93,7 @@ export const selectComposerBusy = (state: RootState, slot: string | null): boole if (selectSlotStreamState(state, slot) !== 'idle') return true if (slot === state.chat.activeSlot && state.chat.slotRunning) return true if (selectSlotSubagentsActive(state, slot)) return true - const dashSlot = state.dashboard.slots.find((sl) => sl.key === slot) + const dashSlot = slotRowByKey(state.dashboard.slots, slot) return !!dashSlot?.subagents_running } @@ -104,7 +105,7 @@ export const selectComposerBusy = (state: RootState, slot: string | null): boole export const selectProjectChipLocked = (state: RootState, slot: string | null): boolean => { if (!slot) return false if (selectSlotSubagentsActive(state, slot)) return true - const dashSlot = state.dashboard.slots.find((sl) => sl.key === slot) + const dashSlot = slotRowByKey(state.dashboard.slots, slot) return !!dashSlot?.subagents_running } @@ -143,7 +144,7 @@ const CONTINUE_SCAN_SKIP = new Set(['queued', 'tool_call', 'tool_result', 'injec export const selectActiveSlotProject = (state: RootState): string | undefined => { const key = state.chat.activeSlot if (!key) return undefined - return state.dashboard.slots.find((sl) => sl.key === key)?.project || undefined + return slotRowByKey(state.dashboard.slots, key)?.project || undefined } /** @@ -185,7 +186,7 @@ export const selectContinuable = (state: RootState, slot?: string | null): boole const c = state.chat const key = slot ?? c.activeSlot const other = key && key !== c.activeSlot ? key : null - const dashSlot = state.dashboard.slots.find((sl) => sl.key === key) + const dashSlot = slotRowByKey(state.dashboard.slots, key) if (other) { if (selectSlotStreamState(state, other) !== 'idle' || c.pendingTurnSlot === other || dashSlot?.running || dashSlot?.stopping) return false } else if (c.slotRunning || c.slotStopping || c.pendingTurnSlot) return false diff --git a/website/src/store/slotIndex.ts b/website/src/store/slotIndex.ts new file mode 100644 index 00000000000..991410a3e0d --- /dev/null +++ b/website/src/store/slotIndex.ts @@ -0,0 +1,29 @@ +/** Key-to-row lookup over `dashboard.slots`. + * + * Selectors that resolve one slot's row run on every store notification, and + * `slots.find(x => x.key === key)` is a linear scan of every row each time. + * The index is built once per `slots` array identity and kept in a WeakMap, + * so it lives exactly as long as that array and needs no invalidation: any + * write to a row gives the array a new identity, and the next lookup rebuilds. + * It works from a selector and from a one-off `store.getState()` read alike. */ +import type { ChatSlot } from '../types' + +const INDEX = new WeakMap<readonly ChatSlot[], Map<string, ChatSlot>>() + +function slotIndexOf(slots: readonly ChatSlot[]): Map<string, ChatSlot> { + let index = INDEX.get(slots) + if (!index) { + index = new Map() + // First row wins, the row `Array.prototype.find` would return. + for (const row of slots) if (!index.has(row.key)) index.set(row.key, row) + INDEX.set(slots, index) + } + return index +} + +/** The row for `key`, the same row `slots.find(x => x.key === key)` returns, + * an empty key included. A null or undefined key matches no row, as a string + * key never equals either. Callers that must skip an empty key guard it. */ +export function slotRowByKey(slots: readonly ChatSlot[], key: string | null | undefined): ChatSlot | undefined { + return key == null ? undefined : slotIndexOf(slots).get(key) +} diff --git a/website/src/test/slotIndex.test.ts b/website/src/test/slotIndex.test.ts new file mode 100644 index 00000000000..5d8efd8c7b8 --- /dev/null +++ b/website/src/test/slotIndex.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { slotRowByKey } from '../store/slotIndex' +import type { ChatSlot } from '../types' + +const row = (key: string, title = key) => ({ key, title }) as unknown as ChatSlot + +describe('slotRowByKey', () => { + it('returns the row Array.prototype.find would return', () => { + const slots = [row('a'), row('b'), row('c')] + for (const key of ['a', 'b', 'c', 'missing']) { + expect(slotRowByKey(slots, key)).toBe(slots.find(s => s.key === key)) + } + }) + + it('keeps the first row when a key repeats, as find does', () => { + const first = row('dup', 'first') + const slots = [first, row('dup', 'second')] + expect(slotRowByKey(slots, 'dup')).toBe(first) + }) + + it('matches an empty key as find does, and no row for a null or undefined key', () => { + const empty = row('') + const slots = [row('a'), empty] + expect(slotRowByKey(slots, '')).toBe(slots.find(s => s.key === '')) + expect(slotRowByKey(slots, '')).toBe(empty) + expect(slotRowByKey([row('a')], '')).toBeUndefined() + expect(slotRowByKey(slots, null)).toBeUndefined() + expect(slotRowByKey(slots, undefined)).toBeUndefined() + }) + + it('reads a new slots array afresh, so a replaced row is never stale', () => { + const before = [row('a', 'old')] + expect(slotRowByKey(before, 'a')?.title).toBe('old') + const after = [row('a', 'new')] + expect(slotRowByKey(after, 'a')?.title).toBe('new') + expect(slotRowByKey(before, 'a')?.title).toBe('old') + }) +})