Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion website/src/components/ChatHeaderChips.tsx
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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 (
<>
<InboundLinkChip slotKey={slotKey} />
Expand Down
5 changes: 3 additions & 2 deletions website/src/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -243,7 +244,7 @@ export default function ChatPane({
// history key (dashboard:<slot>); 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
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/InboundLinkChip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/LinkedSurfacesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/SessionActionsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/SlotTagPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/SourceLinksSubmenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatSlot['source_links']>[number]
const unlinkKey = (slotKey: string) => ['unlink-source-link', slotKey]
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/chat-input/busySend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/chat-input/voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/library/ArtifactScopeChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<ChatFolder[]>({
Expand Down
3 changes: 2 additions & 1 deletion website/src/components/useChatPanePickers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions website/src/hooks/useCrewAvatarState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion website/src/lib/closeBusyGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -45,7 +46,7 @@ const LIVE_WORK_REASONS: ReadonlySet<CloseBusyReason> = 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'] : []
Expand Down
3 changes: 2 additions & 1 deletion website/src/pages/PopoutFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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=…`).
Expand All @@ -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(() => {
Expand Down
39 changes: 31 additions & 8 deletions website/src/pages/chat-sidebar/SessionRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading