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') + }) +})