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: ", 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
+
+/** 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 — "🚫 …" —
- // 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: ", 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, 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(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()
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>()
+
+function slotIndexOf(slots: readonly ChatSlot[]): Map {
+ 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')
+ })
+})