diff --git a/AGENTS.md b/AGENTS.md index a4b8ab5d..1d6b2ec4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,6 +264,8 @@ These rules exist because violating them has caused measurable regressions (rend - **Update only the fields that changed.** Preserve references for untouched state branches. - **Prefer leaf selectors over container selectors.** Subscribe to the smallest stable value that satisfies the component. - **Isolate hot consumers.** If a value changes often and only a few components need it, move it to a narrower store or consume it in a memoized child. +- **Do not subscribe shell/layout components to broad live collections.** If a shell only needs one field, entity, or derived flag, subscribe to that instead of the whole collection. +- **Treat provider roots as global hot paths.** A top-level provider must not subscribe to high-frequency data unless the feature is actually enabled and the subscription is essential. ### Zustand referential equality @@ -272,6 +274,7 @@ Zustand skips re-renders when a selector returns the same reference (`Object.is` - **Never spread all state fields in an update.** Only create new references for fields that actually changed. A `message.part.delta` event should not clone `session`, `permission`, etc. - **Select leaf values, not containers.** `useStore((s) => s.permission[sessionID])` is correct. `useStore((s) => s.permission)` subscribes to every permission change across all sessions. - **Preserve references when merging.** If prepending older messages, keep existing message object references. Only add truly new items. Return the original array if nothing was added. +- **For derived collections, preserve item identity when presentation-relevant fields are unchanged.** Reuse previous item references for unchanged rows/items and move high-frequency live fields to narrow per-item selectors. ### Store splitting @@ -306,6 +309,8 @@ A single store with N properties means every subscriber re-evaluates on every st - **Capture send config at queue time.** Queue items must include provider/model/agent/variant snapshot; do not re-resolve from mutable live state at send time. - **Keep server-selected attachments sendable.** Preserve server-backed file selections in queue/submit flows and convert them to proper `file://` URLs before sending. +- **Do not let text input state repaint unrelated chrome.** Typing should not force unrelated controls, menus, indicators, or toolbars to re-render on every keystroke. +- **Extract slow-changing chrome from hot input paths.** If controls do not depend on the current text value, move them behind memoized boundaries with stable callbacks. ### Bootstrap resilience @@ -316,6 +321,16 @@ A single store with N properties means every subscriber re-evaluates on every st - **Never use `await waitForFrames()` for scroll preservation.** Frames of visible scroll jump are unacceptable. Use `useLayoutEffect` to adjust scroll synchronously after React commits DOM — before the browser paints. - **Capture scroll state before the state change, restore in layout effect.** The pattern: save `scrollHeight`/`scrollTop` into a ref before triggering the update, consume it in `useLayoutEffect` on the rendered output. +- **Do not let viewport resizes masquerade as content growth.** Viewport-height changes must not trigger the same scroll compensation logic used for actual content growth. +- **Disable or narrow native/browser scroll anchoring when custom scroll logic exists.** Browser anchoring and app-managed pinning/follow logic will fight and produce jiggle. +- **Autosize textareas without transient collapse on growth.** Avoid `height='auto'` shrink/expand cycles on every character when the content only grew; this creates visible layout bounce. + +### List ordering and view consistency + +- **Do not sort structural lists directly from high-churn live fields.** If live updates are frequent, sorting directly from them causes reorder thrash and wide rerender cascades. +- **If live recency is required, freeze order during high-frequency updates and apply a one-shot reorder only at an intentional lifecycle edge.** Choose the lifecycle edge explicitly instead of letting every intermediate update reshuffle the UI. +- **Use one ordering source for all views of the same data.** Different views of the same entities must derive from the same ranked list or rank map; do not let each surface re-derive ordering independently. +- **Do not mix global snapshots and local live snapshots without an explicit reconciliation policy.** If multiple data sources feed one view, define which fields win and how they merge. ### Component isolation diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index 3a1aa39d..9400a62f 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -45,8 +45,9 @@ export const AgentMentionAutocomplete = React.forwardRef([]); const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]); const ignoreTabClickRef = React.useRef(false); - const { getVisibleAgents } = useConfigStore(); - const { agents: agentsWithMetadata, loadAgents } = useAgentsStore(); + const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); + const agentsWithMetadata = useAgentsStore((state) => state.agents); + const loadAgents = useAgentsStore((state) => state.loadAgents); React.useEffect(() => { if (agentsWithMetadata.length === 0) { diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index f90762e3..81b6043f 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -104,7 +104,9 @@ export const ChatContainer: React.FC = () => { ); // UI store - const { isExpandedInput, stickyUserHeader, chatRenderMode } = useUIStore(); + const isExpandedInput = useUIStore((state) => state.isExpandedInput); + const stickyUserHeader = useUIStore((state) => state.stickyUserHeader); + const chatRenderMode = useUIStore((state) => state.chatRenderMode); // Streaming state const streamingMessageId = useStreamingStore( @@ -516,9 +518,7 @@ export const ChatContainer: React.FC = () => { ; + handleLocalFileSelect: (event: React.ChangeEvent) => void | Promise; + handlePickLocalFiles: () => void; + handleOpenCommandMenu: () => void; + openIssuePicker: () => void; + openPrPicker: () => void; + onOpenSettings?: () => void; +}; + +const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) { + const { + isMobile, + isVSCode, + footerIconButtonClass, + iconSizeClass, + fileInputRef, + handleLocalFileSelect, + handlePickLocalFiles, + handleOpenCommandMenu, + openIssuePicker, + openPrPicker, + onOpenSettings, + } = props; + + return ( +
+ {isMobile ? ( + + ) : null} + + +
+ {isVSCode ? ( + + ) : ( + + + + + + { + requestAnimationFrame(handlePickLocalFiles); + }} + > + + Attach files + + { + requestAnimationFrame(openIssuePicker); + }} + > + + Link GitHub Issue + + { + requestAnimationFrame(openPrPicker); + }} + > + + Link GitHub PR + + + + )} +
+ + {onOpenSettings ? ( + + ) : null} +
+ ); +}, (prev, next) => ( + prev.isMobile === next.isMobile + && prev.isVSCode === next.isVSCode + && prev.footerIconButtonClass === next.footerIconButtonClass + && prev.iconSizeClass === next.iconSizeClass + && prev.onOpenSettings === next.onOpenSettings +)); + +type PermissionAutoAcceptButtonProps = { + footerIconButtonClass: string; + iconSizeClass: string; + permissionScopeSessionId: string | null; + permissionAutoAcceptEnabled: boolean; + handlePermissionAutoAcceptToggle: () => void; + withTooltip?: boolean; +}; + +const PermissionAutoAcceptButton = React.memo(function PermissionAutoAcceptButton(props: PermissionAutoAcceptButtonProps) { + const { + footerIconButtonClass, + iconSizeClass, + permissionScopeSessionId, + permissionAutoAcceptEnabled, + handlePermissionAutoAcceptToggle, + withTooltip = false, + } = props; + + const ariaLabel = permissionAutoAcceptEnabled + ? 'Disable permission auto-accept' + : 'Enable permission auto-accept'; + const tooltipLabel = permissionAutoAcceptEnabled + ? 'Permission auto-accept: on' + : 'Permission auto-accept: off'; + + const button = ( + + ); + + if (!withTooltip) { + return button; + } + + return ( + + + {button} + + + {tooltipLabel} + + + ); +}); + +type FocusModeButtonProps = { + footerIconButtonClass: string; + iconSizeClass: string; + isExpandedInput: boolean; + onToggle: () => void; +}; + +const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) { + const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props; + + return ( + + + + + +
+ Focus mode + + {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'} + +
+
+
+ ); +}); + +type ComposerActionButtonsProps = { + isMobile: boolean; + footerIconButtonClass: string; + sendIconSizeClass: string; + stopIconSizeClass: string; + canSend: boolean; + canAbort: boolean; + hasContent: boolean; + currentSessionId: string | null; + newSessionDraftOpen: boolean; + onPrimaryAction: () => void; + onQueueMessage: () => void; + onAbort: () => void; +}; + +const ComposerActionButtons = React.memo(function ComposerActionButtons(props: ComposerActionButtonsProps) { + const { + isMobile, + footerIconButtonClass, + sendIconSizeClass, + stopIconSizeClass, + canSend, + canAbort, + hasContent, + currentSessionId, + newSessionDraftOpen, + onPrimaryAction, + onQueueMessage, + onAbort, + } = props; + + const sendButton = ( + + ); + + if (!canAbort) { + return sendButton; + } + + return ( +
+ {hasContent ? ( + + ) : null} + +
+ ); +}, (prev, next) => ( + prev.isMobile === next.isMobile + && prev.footerIconButtonClass === next.footerIconButtonClass + && prev.sendIconSizeClass === next.sendIconSizeClass + && prev.stopIconSizeClass === next.stopIconSizeClass + && prev.canSend === next.canSend + && prev.canAbort === next.canAbort + && prev.hasContent === next.hasContent + && prev.currentSessionId === next.currentSessionId + && prev.newSessionDraftOpen === next.newSessionDraftOpen +)); + const appendWithLineBreaks = (base: string, next: string): string => { const separator = !base ? '' @@ -322,6 +686,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode const textareaRef = React.useRef(null); + const previousMessageLengthRef = React.useRef(message.length); const dropZoneRef = React.useRef(null); const suppressNextFileDropTextInsertRef = React.useRef(false); const suppressNextFileDropTextInsertTimeoutRef = React.useRef | null>(null); @@ -368,17 +733,29 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo const activeProjectId = useProjectsStore((state) => state.activeProjectId); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); - const { currentProviderId, currentModelId, currentVariant, currentAgentName, setAgent, getVisibleAgents } = useConfigStore(); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const currentVariant = useConfigStore((state) => state.currentVariant); + const currentAgentName = useConfigStore((state) => state.currentAgentName); + const setAgent = useConfigStore((state) => state.setAgent); + const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); - const { isMobile, inputBarOffset, isKeyboardOpen, cornerRadius, persistChatDraft, inputSpellcheckEnabled, isExpandedInput, setExpandedInput } = useUIStore(); + const isMobile = useUIStore((state) => state.isMobile); + const inputBarOffset = useUIStore((state) => state.inputBarOffset); + const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen); + const cornerRadius = useUIStore((state) => state.cornerRadius); + const persistChatDraft = useUIStore((state) => state.persistChatDraft); + const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled); + const isExpandedInput = useUIStore((state) => state.isExpandedInput); + const setExpandedInput = useUIStore((state) => state.setExpandedInput); const { working } = useAssistantStatus(); const { git: runtimeGit } = useRuntimeAPIs(); const { currentTheme } = useThemeSystem(); const chatSearchDirectory = useChatSearchDirectory(); const [showAbortStatus, setShowAbortStatus] = React.useState(false); - const [textareaScrollTop, setTextareaScrollTop] = React.useState(0); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); + const composerHighlightRef = React.useRef(null); const isDesktopExpanded = isExpandedInput && !isMobile; const chatInputRadius = 'var(--radius-lg)'; @@ -867,7 +1244,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [pendingInputText, consumePendingInputText]); - const hasContent = message.trim() || sendableAttachedFiles.length > 0 || hasDrafts; + const hasContent = message.trim().length > 0 || sendableAttachedFiles.length > 0 || hasDrafts; const hasQueuedMessages = queuedMessages.length > 0; const canSend = hasContent || hasQueuedMessages; @@ -913,6 +1290,29 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts, currentProviderId, currentModelId, currentAgentName, currentVariant]); + const handleQueuedMessageEdit = React.useCallback((content: string) => { + setMessage(content); + setTimeout(() => { + textareaRef.current?.focus(); + }, 0); + }, []); + + const handleOpenAgentPanel = React.useCallback(() => { + setMobileControlsPanel('agent'); + }, []); + + const handleToggleExpandedInput = React.useCallback(() => { + setExpandedInput(!isExpandedInput); + }, [isExpandedInput, setExpandedInput]); + + const openIssuePicker = React.useCallback(() => { + setIssuePickerOpen(true); + }, []); + + const openPrPicker = React.useCallback(() => { + setPrPickerOpen(true); + }, []); + const handleSubmit = async (options?: SubmitOptions) => { const queuedOnly = options?.queuedOnly ?? false; @@ -1509,20 +1909,27 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo } }, [primaryAgents, currentAgentName, currentSessionId, setAgent, saveSessionAgentSelection]); - const adjustTextareaHeight = React.useCallback(() => { + const adjustTextareaHeight = React.useCallback((options?: { allowShrink?: boolean }) => { const textarea = textareaRef.current; if (!textarea) { return; } + const previousScrollTop = textarea.scrollTop; + if (isDesktopExpanded) { textarea.style.height = '100%'; textarea.style.maxHeight = 'none'; setTextareaSize(null); + if (textarea.scrollTop !== previousScrollTop) { + textarea.scrollTop = previousScrollTop; + } return; } - textarea.style.height = 'auto'; + if (options?.allowShrink ?? true) { + textarea.style.height = 'auto'; + } const view = textarea.ownerDocument?.defaultView; const computedStyle = view ? view.getComputedStyle(textarea) : null; @@ -1541,6 +1948,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo textarea.style.height = `${nextHeight}px`; textarea.style.maxHeight = `${maxHeight}px`; + if (textarea.scrollTop !== previousScrollTop) { + textarea.scrollTop = previousScrollTop; + } setTextareaSize((prev) => { if (prev && prev.height === nextHeight && prev.maxHeight === maxHeight) { @@ -1551,7 +1961,9 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }, [isDesktopExpanded]); React.useLayoutEffect(() => { - adjustTextareaHeight(); + const allowShrink = message.length < previousMessageLengthRef.current; + previousMessageLengthRef.current = message.length; + adjustTextareaHeight({ allowShrink }); }, [adjustTextareaHeight, message, isMobile]); const updateAutocompleteState = React.useCallback((value: string, cursorPosition: number) => { @@ -2723,238 +3135,6 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo }); }, [permissionAutoAcceptEnabled, permissionScopeSessionId, setSessionAutoAccept]); - const permissionAutoAcceptAriaLabel = permissionAutoAcceptEnabled - ? 'Disable permission auto-accept' - : 'Enable permission auto-accept'; - const permissionAutoAcceptTooltipLabel = permissionAutoAcceptEnabled - ? 'Permission auto-accept: on' - : 'Permission auto-accept: off'; - - const permissionAutoAcceptButton = ( - - ); - - const permissionAutoAcceptButtonWithTooltip = ( - - - {permissionAutoAcceptButton} - - - {permissionAutoAcceptTooltipLabel} - - - ); - - // Send button - respects queue mode setting - const sendButton = ( - - ); - - // Queue button for adding message to queue while working - const queueButton = ( - - ); - - // Stop button replaces send button when working - const stopButton = ( - - ); - - // Action buttons area: either send button, or stop (+ optional queue button floating above) - const actionButtons = canAbort ? ( -
- {hasContent && queueButton} - {stopButton} -
- ) : ( - sendButton - ); - - const attachmentMenu = ( - <> - - -
- {isVSCode ? ( - - ) : ( - - - - - - { - requestAnimationFrame(() => handlePickLocalFiles()); - }} - > - - Attach files - - { - requestAnimationFrame(() => { - setIssuePickerOpen(true); - }); - }} - > - - Link GitHub Issue - - { - requestAnimationFrame(() => { - setPrPickerOpen(true); - }); - }} - > - - Link GitHub PR - - - - )} -
- - ); - - const settingsButton = onOpenSettings ? ( - - ) : null; - - const attachmentsControls = ( -
- {isMobile ? ( - - ) : null} - {attachmentMenu} - {settingsButton} -
- ); - const workingStatusText = working.statusText; React.useEffect(() => { @@ -2998,12 +3178,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
{ - setMessage(content); - setTimeout(() => { - textareaRef.current?.focus(); - }, 0); - }} + onEditMessage={handleQueuedMessageEdit} /> {hasDrafts && (
@@ -3122,7 +3297,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo
)} - = ({ onOpenSettings, scrollToBo : 'pt-4 pb-2', inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label', )} - style={{ transform: `translateY(-${textareaScrollTop}px)` }} + ref={composerHighlightRef} > {highlightedComposerContent.map((part, index) => ( = ({ onOpenSettings, scrollToBo onClick={updateAutocompleteOverlayPosition} onScroll={(event) => { updateAutocompleteOverlayPosition(); - setTextareaScrollTop(event.currentTarget.scrollTop); + const scrollTop = event.currentTarget.scrollTop; + if (composerHighlightRef.current) { + composerHighlightRef.current.style.transform = `translateY(-${scrollTop}px)`; + } }} onSelect={updateAutocompleteOverlayPosition} placeholder={currentSessionId || newSessionDraftOpen @@ -3411,32 +3589,63 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo <>
- {attachmentsControls} - {permissionAutoAcceptButton} + +
- - setMobileControlsPanel('agent')} + +
- - {actionButtons} + +
- setMobileControlsPanel(null)} /> - handleOpenMobilePanel('model')} @@ -3446,43 +3655,51 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo ) : ( <>
- {attachmentsControls} - - - - - -
- Focus mode - - {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'} - -
-
-
- {permissionAutoAcceptButtonWithTooltip} + + +
- - - {actionButtons} + + +
)} diff --git a/packages/ui/src/components/chat/FileAttachment.tsx b/packages/ui/src/components/chat/FileAttachment.tsx index 7a19c132..6cafbbe6 100644 --- a/packages/ui/src/components/chat/FileAttachment.tsx +++ b/packages/ui/src/components/chat/FileAttachment.tsx @@ -14,8 +14,8 @@ import type { ToolPopupContent } from './message/types'; export const FileAttachmentButton = memo(() => { const fileInputRef = useRef(null); - const { addAttachedFile } = useInputStore(); - const { isMobile } = useUIStore(); + const addAttachedFile = useInputStore((state) => state.addAttachedFile); + const isMobile = useUIStore((state) => state.isMobile); const isVSCodeRuntime = useIsVSCodeRuntime(); const buttonSizeClass = isMobile ? 'h-9 w-9' : 'h-7 w-7'; const iconSizeClass = isMobile ? 'h-5 w-5' : 'h-[18px] w-[18px]'; @@ -256,7 +256,8 @@ const FileChip = memo(({ file, onRemove }: FileChipProps) => { FileChip.displayName = 'FileChip'; export const AttachedFilesList = memo(() => { - const { attachedFiles, removeAttachedFile } = useInputStore(); + const attachedFiles = useInputStore((state) => state.attachedFiles); + const removeAttachedFile = useInputStore((state) => state.removeAttachedFile); const localFiles = attachedFiles.filter((file) => file.source !== 'server'); diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index 051d78c5..79ab1a2a 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -64,7 +64,7 @@ export const FileMentionAutocomplete = React.forwardRef state.getVisibleAgents); const searchFiles = useFileSearchStore((state) => state.searchFiles); const debouncedQuery = useDebouncedValue(searchQuery, 180); const showHidden = useDirectoryShowHidden(); diff --git a/packages/ui/src/components/chat/MobileAgentButton.tsx b/packages/ui/src/components/chat/MobileAgentButton.tsx index f1687889..b39571dd 100644 --- a/packages/ui/src/components/chat/MobileAgentButton.tsx +++ b/packages/ui/src/components/chat/MobileAgentButton.tsx @@ -16,7 +16,8 @@ const LONG_PRESS_MS = 500; // NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile export const MobileAgentButton: React.FC = ({ onCycleAgent, onOpenAgentPanel, className }) => { - const { currentAgentName, getVisibleAgents } = useConfigStore(); + const currentAgentName = useConfigStore((state) => state.currentAgentName); + const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionAgentName = useSelectionStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null diff --git a/packages/ui/src/components/chat/MobileModelButton.tsx b/packages/ui/src/components/chat/MobileModelButton.tsx index 04b316ee..fc550e70 100644 --- a/packages/ui/src/components/chat/MobileModelButton.tsx +++ b/packages/ui/src/components/chat/MobileModelButton.tsx @@ -9,7 +9,8 @@ interface MobileModelButtonProps { } export const MobileModelButton: React.FC = ({ onOpenModel, className }) => { - const { currentModelId, getCurrentProvider } = useConfigStore(); + const currentModelId = useConfigStore((state) => state.currentModelId); + const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); const currentProvider = getCurrentProvider(); const modelLabel = getModelDisplayName(currentProvider, currentModelId); diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index e29b27b4..fb7cb398 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -1438,8 +1438,11 @@ export const MobileSessionStatusBar: React.FC = ({ const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const agents = useConfigStore((state) => state.agents); - const { getCurrentModel } = useConfigStore(); - const { isMobile, showMobileSessionStatusBar, isMobileSessionStatusBarCollapsed, setIsMobileSessionStatusBarCollapsed } = useUIStore(); + const getCurrentModel = useConfigStore((state) => state.getCurrentModel); + const isMobile = useUIStore((state) => state.isMobile); + const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar); + const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed); + const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); // Project store diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 22fbc844..8b527d2e 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -293,25 +293,23 @@ export const ModelControls: React.FC = ({ onMobilePanelSelection, onAgentPanelSelection, }) => { - const { - providers, - currentProviderId, - currentModelId, - currentVariant, - currentAgentName, - settingsDefaultVariant, - settingsDefaultAgent, - setProvider, - setSelectedProvider, - setModel, - setCurrentVariant, - getCurrentModelVariants, - setAgent, - getCurrentProvider, - getModelMetadata, - getCurrentAgent, - getVisibleAgents, - } = useConfigStore(); + const providers = useConfigStore((state) => state.providers); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const currentVariant = useConfigStore((state) => state.currentVariant); + const currentAgentName = useConfigStore((state) => state.currentAgentName); + const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant); + const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent); + const setProvider = useConfigStore((state) => state.setProvider); + const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); + const setModel = useConfigStore((state) => state.setModel); + const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant); + const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants); + const setAgent = useConfigStore((state) => state.setAgent); + const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); + const getModelMetadata = useConfigStore((state) => state.getModelMetadata); + const getCurrentAgent = useConfigStore((state) => state.getCurrentAgent); + const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); // Use visible agents (excludes hidden internal agents) const agents = getVisibleAgents(); @@ -321,15 +319,13 @@ export const ModelControls: React.FC = ({ const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession); const sync = useSync(); - const { - getSessionModelSelection, - saveSessionModelSelection, - saveSessionAgentSelection, - saveAgentModelForSession, - getAgentModelForSession, - saveAgentModelVariantForSession, - getAgentModelVariantForSession, - } = useSelectionStore(); + const getSessionModelSelection = useSelectionStore((state) => state.getSessionModelSelection); + const saveSessionModelSelection = useSelectionStore((state) => state.saveSessionModelSelection); + const saveSessionAgentSelection = useSelectionStore((state) => state.saveSessionAgentSelection); + const saveAgentModelForSession = useSelectionStore((state) => state.saveAgentModelForSession); + const getAgentModelForSession = useSelectionStore((state) => state.getAgentModelForSession); + const saveAgentModelVariantForSession = useSelectionStore((state) => state.saveAgentModelVariantForSession); + const getAgentModelVariantForSession = useSelectionStore((state) => state.getAgentModelVariantForSession); const contextHydrated = useContextStore((state) => state.hasHydrated); @@ -355,19 +351,17 @@ export const ModelControls: React.FC = ({ ? (sessionSavedAgentName || stickySessionAgentName || currentAgentName) : currentAgentName; - const { - toggleFavoriteModel, - isFavoriteModel, - collapsedModelProviders, - toggleModelProviderCollapsed, - addRecentModel, - addRecentAgent, - addRecentEffort, - isModelSelectorOpen, - setModelSelectorOpen, - setSettingsDialogOpen, - setSettingsPage, - } = useUIStore(); + const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel); + const isFavoriteModel = useUIStore((state) => state.isFavoriteModel); + const collapsedModelProviders = useUIStore((state) => state.collapsedModelProviders); + const toggleModelProviderCollapsed = useUIStore((state) => state.toggleModelProviderCollapsed); + const addRecentModel = useUIStore((state) => state.addRecentModel); + const addRecentAgent = useUIStore((state) => state.addRecentAgent); + const addRecentEffort = useUIStore((state) => state.addRecentEffort); + const isModelSelectorOpen = useUIStore((state) => state.isModelSelectorOpen); + const setModelSelectorOpen = useUIStore((state) => state.setModelSelectorOpen); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const setSettingsPage = useUIStore((state) => state.setSettingsPage); const hiddenModels = useUIStore((state) => state.hiddenModels); const collapsedProviderSet = React.useMemo( () => new Set(collapsedModelProviders.map((providerId) => providerId.trim()).filter(Boolean)), diff --git a/packages/ui/src/components/chat/StatusChip.tsx b/packages/ui/src/components/chat/StatusChip.tsx index 6283ba71..4801e0c9 100644 --- a/packages/ui/src/components/chat/StatusChip.tsx +++ b/packages/ui/src/components/chat/StatusChip.tsx @@ -11,14 +11,12 @@ interface StatusChipProps { } export const StatusChip: React.FC = ({ onClick, className }) => { - const { - currentModelId, - currentVariant, - currentAgentName, - getCurrentProvider, - getCurrentModelVariants, - getVisibleAgents, - } = useConfigStore(); + const currentModelId = useConfigStore((state) => state.currentModelId); + const currentVariant = useConfigStore((state) => state.currentVariant); + const currentAgentName = useConfigStore((state) => state.currentAgentName); + const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); + const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants); + const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionAgentName = useContextStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 2a8a2eb6..668c49cd 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -158,7 +158,7 @@ export const StatusRow: React.FC = ({ () => (currentSessionId ? todosRecord[currentSessionId] ?? EMPTY_TODOS : EMPTY_TODOS), [todosRecord, currentSessionId], ); - const { isMobile } = useUIStore(); + const isMobile = useUIStore((state) => state.isMobile); const isCompact = isMobile || isVSCodeRuntime(); // Filter out cancelled todos for display and keep original order. diff --git a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx index e3d64d5a..d31eb1dc 100644 --- a/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx +++ b/packages/ui/src/components/chat/UnifiedControlsDrawer.tsx @@ -43,21 +43,22 @@ export const UnifiedControlsDrawer: React.FC = ({ onOpenModel, onOpenEffort, }) => { - const { - providers, - currentProviderId, - currentModelId, - currentVariant, - setProvider, - setModel, - setCurrentVariant, - getCurrentModelVariants, - getModelMetadata, - } = useConfigStore(); - const { addRecentModel, addRecentEffort, recentEfforts } = useUIStore(); + const providers = useConfigStore((state) => state.providers); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const currentVariant = useConfigStore((state) => state.currentVariant); + const setProvider = useConfigStore((state) => state.setProvider); + const setModel = useConfigStore((state) => state.setModel); + const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant); + const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants); + const getModelMetadata = useConfigStore((state) => state.getModelMetadata); + const addRecentModel = useUIStore((state) => state.addRecentModel); + const addRecentEffort = useUIStore((state) => state.addRecentEffort); + const recentEfforts = useUIStore((state) => state.recentEfforts); const { recentModelsList } = useModelLists(); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); - const { saveAgentModelForSession, saveAgentModelVariantForSession } = useSelectionStore(); + const saveAgentModelForSession = useSelectionStore((state) => state.saveAgentModelForSession); + const saveAgentModelVariantForSession = useSelectionStore((state) => state.saveAgentModelVariantForSession); const sessionAgentName = useContextStore((state) => currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null ); diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 0589eb95..23bf662a 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -21,7 +21,7 @@ import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSessions, useSessionMessageRecords } from '@/sync/sync-context'; +import { useSession, useSessionMessageRecords } from '@/sync/sync-context'; import { getAllSyncSessions } from '@/sync/sync-refs'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; @@ -68,6 +68,452 @@ import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/ import { resolveSessionDiffStats } from '@/components/session/sidebar/utils'; import type { Session } from '@opencode-ai/sdk/v2/client'; +const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors'; +const MOBILE_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-interactive-hover transition-colors'; + +type HeaderIconActionButtonProps = { + visible?: boolean; + title: string; + ariaLabel: string; + onClick: () => void; + className?: string; + Icon: RemixiconComponentType; + iconClassName?: string; +}; + +const HeaderIconActionButton = React.memo(function HeaderIconActionButton({ + visible = true, + title, + ariaLabel, + onClick, + className, + Icon, + iconClassName, +}: HeaderIconActionButtonProps) { + if (!visible) { + return null; + } + + return ( + + + + + +

{title}

+
+
+ ); +}); + +type DesktopGitHubControlProps = { + isMobile: boolean; + githubAuthStatus: GitHubAuthStatus | null; + githubAccounts: Array[number]>; + githubAvatarUrl: string | null; + githubLogin: string | null; + isSwitchingGitHubAccount: boolean; + handleGitHubAccountSwitch: (accountId: string) => Promise; +}; + +const DesktopGitHubControl = React.memo(function DesktopGitHubControl({ + isMobile, + githubAuthStatus, + githubAccounts, + githubAvatarUrl, + githubLogin, + isSwitchingGitHubAccount, + handleGitHubAccountSwitch, +}: DesktopGitHubControlProps) { + if (!githubAuthStatus?.connected || isMobile) { + return null; + } + + if (githubAccounts.length > 1) { + return ( + + + + + + + GitHub Accounts + + + {githubAccounts.map((account) => { + const accountUser = account.user; + const isCurrent = Boolean(account.current); + return ( + { + if (!isCurrent) { + void handleGitHubAccountSwitch(account.id); + } + }} + > + {accountUser?.avatarUrl ? ( + {accountUser.login + ) : ( +
+ +
+ )} + + + {accountUser?.name?.trim() || accountUser?.login || 'GitHub'} + + {accountUser?.login ? ( + + {accountUser.login} + + ) : null} + + {isCurrent ? : null} +
+ ); + })} +
+
+ ); + } + + return ( +
+ {githubAvatarUrl ? ( + {githubLogin + ) : ( + + )} +
+ ); +}); + +type DesktopServicesMenuProps = { + isDesktopApp: boolean; + currentInstanceLabel: string; + compactCurrentInstanceLabel: string; + isDesktopServicesOpen: boolean; + setIsDesktopServicesOpen: React.Dispatch>; + refreshCurrentInstanceLabel: () => Promise; + desktopServicesTab: 'instance' | 'usage' | 'mcp'; + setDesktopServicesTab: React.Dispatch>; + quotaResultsLength: number; + fetchAllQuotas: () => Promise; + servicesTabItems: SortableTabsStripItem[]; + quotaLastUpdated: number | null; + quotaDisplayMode: 'usage' | 'remaining'; + quotaDisplayTabItems: SortableTabsStripItem[]; + handleDisplayModeChange: (mode: 'usage' | 'remaining') => Promise; + handleUsageRefresh: () => void; + isQuotaLoading: boolean; + isUsageRefreshSpinning: boolean; + hasRateLimits: boolean; + rateLimitGroups: RateLimitGroup[]; + expandedFamilies: Record; + toggleFamilyExpanded: (providerId: string, familyId: string) => void; + shortcutLabel: (actionId: string) => string; +}; + +const DesktopServicesMenu = React.memo(function DesktopServicesMenu({ + isDesktopApp, + currentInstanceLabel, + compactCurrentInstanceLabel, + isDesktopServicesOpen, + setIsDesktopServicesOpen, + refreshCurrentInstanceLabel, + desktopServicesTab, + setDesktopServicesTab, + quotaResultsLength, + fetchAllQuotas, + servicesTabItems, + quotaLastUpdated, + quotaDisplayMode, + quotaDisplayTabItems, + handleDisplayModeChange, + handleUsageRefresh, + isQuotaLoading, + isUsageRefreshSpinning, + hasRateLimits, + rateLimitGroups, + expandedFamilies, + toggleFamilyExpanded, + shortcutLabel, +}: DesktopServicesMenuProps) { + return ( + { + setIsDesktopServicesOpen(open); + if (open) { + void refreshCurrentInstanceLabel(); + if (desktopServicesTab === 'usage' && quotaResultsLength === 0) { + void fetchAllQuotas(); + } + } + }} + > + + + + + + + +

+ {isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')}) +

+
+
+ +
+
+ { + const value = tabID as 'instance' | 'usage' | 'mcp'; + setDesktopServicesTab(value); + if (value === 'usage' && quotaResultsLength === 0) { + void fetchAllQuotas(); + } + }} + layoutMode="fit" + variant="active-pill" + activePillInsetClassName="gap-0.5 px-px py-0" + activePillButtonClassName="h-8" + className="h-full" + /> +
+
+ + {isDesktopApp && desktopServicesTab === 'instance' ? ( + {}} + onHostSwitched={() => setIsDesktopServicesOpen(false)} + /> + ) : null} + + {desktopServicesTab === 'mcp' ? ( + + ) : null} + + {desktopServicesTab === 'usage' ? ( +
+
+
+ Rate limits + {formatTime(quotaLastUpdated)} +
+
+
+ void handleDisplayModeChange(tabID as 'usage' | 'remaining')} + layoutMode="fit" + variant="active-pill" + activePillInsetClassName="gap-0.5 px-px py-0" + className="h-full" + /> +
+ +
+
+ + {!hasRateLimits ? ( +
+ No rate limits available. +
+ ) : null} + +
+ {rateLimitGroups.map((group, index) => { + const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; + return ( + + {index > 0 ?
: null} +
+ + {group.providerName} +
+ {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( +
+ {group.error ?? 'No rate limits reported.'} +
+ ) : ( +
+ {group.entries.map(([label, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? (quotaDisplayMode === 'remaining' + ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) + : null; + return ( +
+
+
+ {formatWindowLabel(label)} + {window.resetAfterFormatted ?? window.resetAtFormatted ? ( + + {window.resetAfterFormatted ?? window.resetAtFormatted} + + ) : null} +
+ + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + +
+ + {paceInfo ? : null} +
+ ); + })} + {group.modelFamilies && group.modelFamilies.length > 0 ? ( +
+ {group.modelFamilies.map((family) => { + const familyKey = family.familyId ?? 'other'; + const isExpanded = providerExpandedFamilies.includes(familyKey); + return ( + toggleFamilyExpanded(group.providerId, familyKey)} + > + + {family.familyLabel} + {isExpanded ? : } + + +
+ {family.models.map(([modelName, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; + const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); + const expectedMarker = paceInfo?.dailyAllocationPercent != null + ? (quotaDisplayMode === 'remaining' + ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) + : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) + : null; + return ( +
+
+ {getDisplayModelName(modelName)} + + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + +
+ + {paceInfo ? : null} +
+ ); + })} +
+
+
+ ); + })} +
+ ) : null} +
+ )} + + ); + })} +
+
+ ) : null} + + + ); +}); + const isSameContextUsage = ( a: SessionContextUsage | null, @@ -171,6 +617,18 @@ interface TabConfig { showDot?: boolean; } +interface RateLimitGroup { + providerId: string; + providerName: string; + entries: Array<[string, UsageWindow]>; + error?: string; + modelFamilies?: Array<{ + familyId: string | null; + familyLabel: string; + models: Array<[string, UsageWindow]>; + }>; +} + interface HeaderProps { onToggleLeftDrawer?: () => void; onToggleRightDrawer?: () => void; @@ -200,7 +658,7 @@ export const Header: React.FC = ({ const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const { getCurrentModel } = useConfigStore(); + const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const runtimeApis = useRuntimeAPIs(); const getContextUsage = useSessionUIStore((state) => state.getContextUsage); @@ -209,7 +667,7 @@ export const Header: React.FC = ({ const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionMessageRecords = useSessionMessageRecords(currentSessionId ?? ''); const currentSessionMessages = currentSessionId ? (currentSessionMessageRecords.length > 0 ? currentSessionMessageRecords : undefined) : undefined; - const sessions = useSessions(); + const currentSyncedSession = useSession(currentSessionId ?? null); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const activeProject = useProjectsStore((state) => { if (!state.activeProjectId) { @@ -321,8 +779,8 @@ export const Header: React.FC = ({ }, [contextUsage, currentSessionId, isContextUsageResolvedForSession]); const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); - const githubAvatarUrl = githubAuthStatus?.connected ? githubAuthStatus.user?.avatarUrl : null; - const githubLogin = githubAuthStatus?.connected ? githubAuthStatus.user?.login : null; + const githubAvatarUrl = githubAuthStatus?.connected ? (githubAuthStatus.user?.avatarUrl ?? null) : null; + const githubLogin = githubAuthStatus?.connected ? (githubAuthStatus.user?.login ?? null) : null; const githubAccounts = githubAuthStatus?.accounts ?? []; const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false); const [isMobileRateLimitsOpen, setIsMobileRateLimitsOpen] = React.useState(false); @@ -393,18 +851,6 @@ export const Header: React.FC = ({ const expandedFamilies = useQuotaStore((state) => state.expandedFamilies); const toggleFamilyExpanded = useQuotaStore((state) => state.toggleFamilyExpanded); - interface RateLimitGroup { - providerId: string; - providerName: string; - entries: Array<[string, UsageWindow]>; - error?: string; - modelFamilies?: Array<{ - familyId: string | null; - familyLabel: string; - models: Array<[string, UsageWindow]>; - }>; - } - const rateLimitGroups = React.useMemo(() => { const groups: RateLimitGroup[] = []; @@ -527,10 +973,10 @@ export const Header: React.FC = ({ // Resolve from the global sessions snapshot first (same source as sidebar). // Child-store lists are intentionally partial/truncated during bootstrap. return globalActiveSessions.find((s) => s.id === currentSessionId) - ?? sessions.find((s) => s.id === currentSessionId) + ?? currentSyncedSession ?? getAllSyncSessions().find((s) => s.id === currentSessionId) ?? null; - }, [currentSessionId, globalActiveSessions, sessions]); + }, [currentSessionId, currentSyncedSession, globalActiveSessions]); const lastResolvedSessionRef = React.useRef<{ sessionId: string; @@ -905,8 +1351,8 @@ export const Header: React.FC = ({ return getActiveContextMode(panelState) === 'plan'; }, [contextPanelByDirectory, openDirectory]); - const desktopHeaderIconButtonClass = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors'; - const mobileHeaderIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-interactive-hover transition-colors'; + const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS; + const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS; const desktopPaddingClass = React.useMemo(() => { if (!isSidebarOpen && isDesktopApp && isMacPlatform && !isDesktopWindowFullscreen) { @@ -1240,108 +1686,6 @@ export const Header: React.FC = ({ return {tabButton}; }; - const renderDesktopGitHubControl = () => { - if (!githubAuthStatus?.connected || isMobile) { - return null; - } - - if (githubAccounts.length > 1) { - return ( - - - - - - - GitHub Accounts - - - {githubAccounts.map((account) => { - const accountUser = account.user; - const isCurrent = Boolean(account.current); - return ( - { - if (!isCurrent) { - void handleGitHubAccountSwitch(account.id); - } - }} - > - {accountUser?.avatarUrl ? ( - {accountUser.login - ) : ( -
- -
- )} - - - {accountUser?.name?.trim() || accountUser?.login || 'GitHub'} - - {accountUser?.login ? ( - - {accountUser.login} - - ) : null} - - {isCurrent ? : null} -
- ); - })} -
-
- ); - } - - return ( -
- {githubAvatarUrl ? ( - {githubLogin - ) : ( - - )} -
- ); - }; - const desktopSidebarActions = ( <> {showPlanTab && ( @@ -1362,284 +1706,52 @@ export const Header: React.FC = ({ )} - { - setIsDesktopServicesOpen(open); - if (open) { - void refreshCurrentInstanceLabel(); - if (desktopServicesTab === 'usage' && quotaResults.length === 0) { - fetchAllQuotas(); - } - } - }} - > - - - - - - - -

- {isDesktopApp ? `Current instance: ${currentInstanceLabel}` : 'Services'} ({shortcutLabel('toggle_services_menu')}; next tab {shortcutLabel('cycle_services_tab')}) -

-
-
- -
-
- { - const value = tabID as 'instance' | 'usage' | 'mcp'; - setDesktopServicesTab(value); - if (value === 'usage' && quotaResults.length === 0) { - fetchAllQuotas(); - } - }} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - activePillButtonClassName="h-8" - className="h-full" - /> -
-
- - {isDesktopApp && desktopServicesTab === 'instance' ? ( - {}} - onHostSwitched={() => setIsDesktopServicesOpen(false)} - /> - ) : null} - - {desktopServicesTab === 'mcp' ? ( - - ) : null} - - {desktopServicesTab === 'usage' ? ( -
- {/* Usage header bar */} -
-
- Rate limits - {formatTime(quotaLastUpdated)} -
-
-
- handleDisplayModeChange(tabID as 'usage' | 'remaining')} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - className="h-full" - /> -
- -
-
- - {!hasRateLimits ? ( -
- No rate limits available. -
- ) : null} - - {/* Provider groups */} -
- {rateLimitGroups.map((group, index) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - - return ( - - {index > 0 ? ( -
- ) : null} - - {/* Provider header */} -
- - {group.providerName} -
- - {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( -
- {group.error ?? 'No rate limits reported.'} -
- ) : ( -
- {/* Window-level entries */} - {group.entries.map(([label, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds, label); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - - return ( -
-
-
- {formatWindowLabel(label)} - {window.resetAfterFormatted ?? window.resetAtFormatted ? ( - - {window.resetAfterFormatted ?? window.resetAtFormatted} - - ) : null} -
- - {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - -
- - {paceInfo ? ( - - ) : null} -
- ); - })} - - {/* Model family collapsibles */} - {group.modelFamilies && group.modelFamilies.length > 0 ? ( -
- {group.modelFamilies.map((family) => { - const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); - - return ( - toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} - > - - {family.familyLabel} - {isExpanded ? ( - - ) : ( - - )} - - -
- {family.models.map(([modelName, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' ? window.remainingPercent : window.usedPercent; - const paceInfo = calculatePace(window.usedPercent, window.resetAt, window.windowSeconds); - const expectedMarker = paceInfo?.dailyAllocationPercent != null - ? (quotaDisplayMode === 'remaining' - ? 100 - calculateExpectedUsagePercent(paceInfo.elapsedRatio) - : calculateExpectedUsagePercent(paceInfo.elapsedRatio)) - : null; - - return ( -
-
- {getDisplayModelName(modelName)} - - {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} - -
- - {paceInfo ? ( - - ) : null} -
- ); - })} -
-
-
- ); - })} -
- ) : null} -
- )} - - ); - })} -
-
- ) : null} - - - - - - - -

Terminal panel ({shortcutLabel('toggle_terminal')})

-
-
- - - - - -

Right sidebar ({shortcutLabel('toggle_right_sidebar')})

-
-
- {renderDesktopGitHubControl()} + + + + ); @@ -1656,23 +1768,14 @@ export const Header: React.FC = ({ role="tablist" aria-label="Main navigation" > - {!isSidebarOpen ? ( - - - - - -

Open sessions ({shortcutLabel('toggle_sidebar')})

-
-
- ) : null} +
{!isLeftSidebarOpen ? ( diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 637e2c7d..31a5e164 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -51,26 +51,70 @@ const normalizeDirectoryKey = (value: string): string => { return normalized; }; +const MemoSessionSidebar = React.memo(SessionSidebar); +const MemoHeader = React.memo(Header); +const MemoChatView = React.memo(ChatView); +const MemoPlanView = React.memo(PlanView); +const MemoGitView = React.memo(GitView); +const MemoDiffView = React.memo(DiffView); +const MemoTerminalView = React.memo(TerminalView); +const MemoFilesView = React.memo(FilesView); +const MemoRightSidebarTabs = React.memo(RightSidebarTabs); + +const DesktopLeftSidebar = React.memo(function DesktopLeftSidebar({ + isSidebarOpen, + isMobile, +}: { + isSidebarOpen: boolean; + isMobile: boolean; +}) { + return ( + + + + + + ); +}); + +const DesktopRightPanel = React.memo(function DesktopRightPanel({ + isRightSidebarOpen, + setDesktopRightSidebarActionsHost, +}: { + isRightSidebarOpen: boolean; + setDesktopRightSidebarActionsHost: React.Dispatch>; +}) { + return ( + + + + + + ); +}); + export const MainLayout: React.FC = () => { const RIGHT_SIDEBAR_AUTO_CLOSE_WIDTH = 1140; const RIGHT_SIDEBAR_AUTO_OPEN_WIDTH = 1220; const BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT = 640; const BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT = 700; - const { - isSidebarOpen, - isRightSidebarOpen, - isBottomTerminalOpen, - setRightSidebarOpen, - setBottomTerminalOpen, - activeMainTab, - setIsMobile, - isSessionSwitcherOpen, - isSettingsDialogOpen, - setSettingsDialogOpen, - isMultiRunLauncherOpen, - setMultiRunLauncherOpen, - multiRunLauncherPrefillPrompt, - } = useUIStore(); + const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); + const isRightSidebarOpen = useUIStore((state) => state.isRightSidebarOpen); + const isBottomTerminalOpen = useUIStore((state) => state.isBottomTerminalOpen); + const setRightSidebarOpen = useUIStore((state) => state.setRightSidebarOpen); + const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen); + const activeMainTab = useUIStore((state) => state.activeMainTab); + const setIsMobile = useUIStore((state) => state.setIsMobile); + const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); + const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); + const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); + const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen); + const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen); + const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt); const { isMobile } = useDeviceInfo(); const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); diff --git a/packages/ui/src/components/layout/Sidebar.tsx b/packages/ui/src/components/layout/Sidebar.tsx index 8fc02de6..c0f378ee 100644 --- a/packages/ui/src/components/layout/Sidebar.tsx +++ b/packages/ui/src/components/layout/Sidebar.tsx @@ -16,7 +16,8 @@ interface SidebarProps { } export const Sidebar: React.FC = ({ isOpen, isMobile, children, className }) => { - const { sidebarWidth, setSidebarWidth } = useUIStore(); + const sidebarWidth = useUIStore((state) => state.sidebarWidth); + const setSidebarWidth = useUIStore((state) => state.setSidebarWidth); const isDesktopApp = React.useMemo(() => isDesktopShell(), []); const [isResizing, setIsResizing] = React.useState(false); const startXRef = React.useRef(0); diff --git a/packages/ui/src/components/multirun/ModelMultiSelect.tsx b/packages/ui/src/components/multirun/ModelMultiSelect.tsx index 2ca6649a..b5f930d7 100644 --- a/packages/ui/src/components/multirun/ModelMultiSelect.tsx +++ b/packages/ui/src/components/multirun/ModelMultiSelect.tsx @@ -115,7 +115,8 @@ export const ModelMultiSelect: React.FC = ({ maxModels, addButtonClassName, }) => { - const { providers, modelsMetadata } = useConfigStore(); + const providers = useConfigStore((state) => state.providers); + const modelsMetadata = useConfigStore((state) => state.modelsMetadata); const { favoriteModelsList, recentModelsList } = useModelLists(); const [isOpen, setIsOpen] = React.useState(false); const [searchQuery, setSearchQuery] = React.useState(''); diff --git a/packages/ui/src/components/providers/ThemeProvider.tsx b/packages/ui/src/components/providers/ThemeProvider.tsx index 4ad1d6c0..918aa272 100644 --- a/packages/ui/src/components/providers/ThemeProvider.tsx +++ b/packages/ui/src/components/providers/ThemeProvider.tsx @@ -6,7 +6,10 @@ interface ThemeProviderProps { } export const ThemeProvider: React.FC = ({ children }) => { - const { fontSize, applyTypography, padding, applyPadding } = useUIStore(); + const fontSize = useUIStore((state) => state.fontSize); + const applyTypography = useUIStore((state) => state.applyTypography); + const padding = useUIStore((state) => state.padding); + const applyPadding = useUIStore((state) => state.applyPadding); React.useLayoutEffect(() => { applyTypography(); diff --git a/packages/ui/src/components/sections/agents/ModelSelector.tsx b/packages/ui/src/components/sections/agents/ModelSelector.tsx index a6002157..fa3c8a30 100644 --- a/packages/ui/src/components/sections/agents/ModelSelector.tsx +++ b/packages/ui/src/components/sections/agents/ModelSelector.tsx @@ -55,10 +55,13 @@ export const ModelSelector: React.FC = ({ allowedProviderIds, placeholder }) => { - const { providers, modelsMetadata } = useConfigStore(); + const providers = useConfigStore((state) => state.providers); + const modelsMetadata = useConfigStore((state) => state.modelsMetadata); const isMobile = useUIStore(state => state.isMobile); const hiddenModels = useUIStore(state => state.hiddenModels); - const { toggleFavoriteModel, isFavoriteModel, addRecentModel } = useUIStore(); + const toggleFavoriteModel = useUIStore((state) => state.toggleFavoriteModel); + const isFavoriteModel = useUIStore((state) => state.isFavoriteModel); + const addRecentModel = useUIStore((state) => state.addRecentModel); const { favoriteModelsList, recentModelsList } = useModelLists(); const { isMobile: deviceIsMobile } = useDeviceInfo(); const isActuallyMobile = isMobile || deviceIsMobile; diff --git a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx index 4b7ed727..1fb55407 100644 --- a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx @@ -45,12 +45,10 @@ const keyboardEventToCombo = (event: React.KeyboardEvent): Sho }; export const KeyboardShortcutsSettings: React.FC = () => { - const { - shortcutOverrides, - setShortcutOverride, - clearShortcutOverride, - resetAllShortcutOverrides, - } = useUIStore(); + const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); + const setShortcutOverride = useUIStore((state) => state.setShortcutOverride); + const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride); + const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides); const actions = React.useMemo(() => getCustomizableShortcutActions(), []); diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index 00f847c0..6f1899f8 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -54,36 +54,34 @@ export const VoiceSettings: React.FC = () => { language, setLanguage, } = useBrowserVoice(); - const { - voiceProvider, - setVoiceProvider, - speechRate, - setSpeechRate, - speechPitch, - setSpeechPitch, - speechVolume, - setSpeechVolume, - sayVoice, - setSayVoice, - browserVoice, - setBrowserVoice, - openaiVoice, - setOpenaiVoice, - openaiApiKey, - setOpenaiApiKey, - showMessageTTSButtons, - setShowMessageTTSButtons, - voiceModeEnabled, - setVoiceModeEnabled, - summarizeMessageTTS, - setSummarizeMessageTTS, - summarizeVoiceConversation, - setSummarizeVoiceConversation, - summarizeCharacterThreshold, - setSummarizeCharacterThreshold, - summarizeMaxLength, - setSummarizeMaxLength, - } = useConfigStore(); + const voiceProvider = useConfigStore((state) => state.voiceProvider); + const setVoiceProvider = useConfigStore((state) => state.setVoiceProvider); + const speechRate = useConfigStore((state) => state.speechRate); + const setSpeechRate = useConfigStore((state) => state.setSpeechRate); + const speechPitch = useConfigStore((state) => state.speechPitch); + const setSpeechPitch = useConfigStore((state) => state.setSpeechPitch); + const speechVolume = useConfigStore((state) => state.speechVolume); + const setSpeechVolume = useConfigStore((state) => state.setSpeechVolume); + const sayVoice = useConfigStore((state) => state.sayVoice); + const setSayVoice = useConfigStore((state) => state.setSayVoice); + const browserVoice = useConfigStore((state) => state.browserVoice); + const setBrowserVoice = useConfigStore((state) => state.setBrowserVoice); + const openaiVoice = useConfigStore((state) => state.openaiVoice); + const setOpenaiVoice = useConfigStore((state) => state.setOpenaiVoice); + const openaiApiKey = useConfigStore((state) => state.openaiApiKey); + const setOpenaiApiKey = useConfigStore((state) => state.setOpenaiApiKey); + const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); + const setShowMessageTTSButtons = useConfigStore((state) => state.setShowMessageTTSButtons); + const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled); + const setVoiceModeEnabled = useConfigStore((state) => state.setVoiceModeEnabled); + const summarizeMessageTTS = useConfigStore((state) => state.summarizeMessageTTS); + const setSummarizeMessageTTS = useConfigStore((state) => state.setSummarizeMessageTTS); + const summarizeVoiceConversation = useConfigStore((state) => state.summarizeVoiceConversation); + const setSummarizeVoiceConversation = useConfigStore((state) => state.setSummarizeVoiceConversation); + const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold); + const setSummarizeCharacterThreshold = useConfigStore((state) => state.setSummarizeCharacterThreshold); + const summarizeMaxLength = useConfigStore((state) => state.summarizeMaxLength); + const setSummarizeMaxLength = useConfigStore((state) => state.setSummarizeMaxLength); const [isSayAvailable, setIsSayAvailable] = useState(false); const [sayVoices, setSayVoices] = useState>([]); diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index f7d1b2f1..7c9396f8 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -8,8 +8,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { sessionEvents } from '@/lib/sessionEvents'; import { formatDirectoryName, cn } from '@/lib/utils'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useViewportStore } from '@/sync/viewport-store'; -import { useSessions, useDirectorySync, useAllSessionStatuses } from '@/sync/sync-context'; +import { useSidebarSessions, useAllSessionStatuses } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useSync } from '@/sync/use-sync'; import { useSessionPrefetch } from './sidebar/hooks/useSessionPrefetch'; @@ -57,6 +56,7 @@ import { } from './sidebar/ConfirmDialogs'; import { type SessionGroup, type SessionNode } from './sidebar/types'; import { + type ActiveNowEntry, addActiveNowSession, deriveActiveNowSessions, persistActiveNowEntries, @@ -68,7 +68,7 @@ import { formatProjectLabel, normalizePath, } from './sidebar/utils'; -import { refreshGlobalSessions, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { refreshGlobalSessions, resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; @@ -113,6 +113,50 @@ interface SessionSidebarProps { showOnlyMainWorkspace?: boolean; } +type SessionStatusActivityBridgeProps = { + safeStorage: Storage; + setActiveNowEntries: React.Dispatch>; +}; + +const SessionStatusActivityBridge: React.FC = ({ + safeStorage, + setActiveNowEntries, +}) => { + const globalSessionStatuses = useAllSessionStatuses(); + const sessionStatus = React.useMemo( + () => new Map(Object.entries(globalSessionStatuses)), + [globalSessionStatuses], + ); + + const previousStreamingIdsRef = React.useRef>(new Set()); + + React.useEffect(() => { + const nextStreamingIds = new Set(); + sessionStatus.forEach((status, sessionId) => { + if (status?.type === 'busy' || status?.type === 'retry') { + nextStreamingIds.add(sessionId); + } + }); + + const previousStreamingIds = previousStreamingIdsRef.current; + const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId)); + if (startedStreamingIds.length > 0) { + setActiveNowEntries((prev) => { + const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev); + if (next === prev) { + return prev; + } + persistActiveNowEntries(safeStorage, next); + return next; + }); + } + + previousStreamingIdsRef.current = nextStreamingIds; + }, [sessionStatus, safeStorage, setActiveNowEntries]); + + return null; +}; + export const SessionSidebar: React.FC = ({ mobileVariant = false, onSessionSelected, @@ -137,7 +181,6 @@ export const SessionSidebar: React.FC = ({ const [projectRepoStatus, setProjectRepoStatus] = React.useState>(new Map()); const [expandedSessionGroups, setExpandedSessionGroups] = React.useState>(new Set()); - const [hoveredProjectId, setHoveredProjectId] = React.useState(null); const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); const [projectNotesPanelOpen, setProjectNotesPanelOpen] = React.useState(false); @@ -267,10 +310,9 @@ export const SessionSidebar: React.FC = ({ const gitBranches = useGitAllBranches(); const sync = useSync(); - const syncSessions = useSessions(); + const syncSessions = useSidebarSessions(); const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions); - const sessionsByDirectory = useGlobalSessionsStore((state) => state.sessionsByDirectory); const hasLoadedGlobalSessions = useGlobalSessionsStore((state) => state.hasLoaded); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); @@ -278,37 +320,54 @@ export const SessionSidebar: React.FC = ({ const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); const shareSession = useSessionUIStore((state) => state.shareSession); const unshareSession = useSessionUIStore((state) => state.unshareSession); - const sessionMemoryState = useViewportStore((state) => state.sessionMemoryState); - const globalSessionStatuses = useAllSessionStatuses(); // sessionAttentionStates removed — now using notification-store directly in SessionNodeItem - const permissionsRecord = useDirectorySync((state) => state.permission); - - const sessionStatus = React.useMemo( - () => new Map(Object.entries(globalSessionStatuses)), - [globalSessionStatuses], - ); - const permissions = React.useMemo( - () => new Map(Object.entries(permissionsRecord)), - [permissionsRecord], - ); const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const updateStore = useUpdateStore(); - const sessions = React.useMemo( - () => (hasLoadedGlobalSessions ? globalActiveSessions : syncSessions), - [globalActiveSessions, hasLoadedGlobalSessions, syncSessions], - ); + const sessions = React.useMemo(() => { + if (!hasLoadedGlobalSessions) { + return syncSessions; + } - const syncSessionSignature = React.useMemo( + if (syncSessions.length === 0) { + return globalActiveSessions; + } + + const syncedById = new Map(syncSessions.map((session) => [session.id, session])); + const merged = globalActiveSessions.map((session) => syncedById.get(session.id) ?? session); + const seenIds = new Set(merged.map((session) => session.id)); + + syncSessions.forEach((session) => { + if (seenIds.has(session.id)) { + return; + } + + const sessionDirectory = resolveGlobalSessionDirectory(session); + if (sessionDirectory && sessionDirectory === currentDirectory) { + merged.push(session); + } + }); + + return merged; + }, [currentDirectory, globalActiveSessions, hasLoadedGlobalSessions, syncSessions]); + + const syncSessionStructureSignature = React.useMemo( () => syncSessions - .map((session) => `${session.id}:${session.time?.updated ?? session.time?.created ?? 0}:${session.time?.archived ? 1 : 0}`) + .map((session) => { + const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? ''; + return `${session.id}:${session.title ?? ''}:${session.time?.archived ? 1 : 0}:${directory}`; + }) .join('|'), [syncSessions], ); + const syncSessionsSnapshotRef = React.useRef(syncSessions); + React.useEffect(() => { + syncSessionsSnapshotRef.current = syncSessions; + }, [syncSessionStructureSignature, syncSessions]); + React.useEffect(() => { let cancelled = false; @@ -346,13 +405,13 @@ export const SessionSidebar: React.FC = ({ }); }; - void refreshGlobalSessions(syncSessions); + void refreshGlobalSessions(syncSessionsSnapshotRef.current); void discoverWorktrees(); return () => { cancelled = true; }; - }, [currentDirectory, syncSessionSignature, syncSessions]); + }, [currentDirectory, syncSessionStructureSignature]); const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []); const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); @@ -489,6 +548,11 @@ export const SessionSidebar: React.FC = ({ return [...sessions].sort((a, b) => compareSessionsByPinnedAndTime(a, b, pinnedSessionIds)); }, [sessions, pinnedSessionIds]); + const sessionOrderIndex = React.useMemo( + () => new Map(sortedSessions.map((session, index) => [session.id, index])), + [sortedSessions], + ); + const allKnownSessionsById = React.useMemo(() => { const next = new Map(); [...sessions, ...archivedSessions].forEach((session) => { @@ -506,77 +570,6 @@ export const SessionSidebar: React.FC = ({ persistActiveNowEntries(safeStorage, pruned); }, [activeNowEntries, allKnownSessionsById, safeStorage]); - const previousStreamingIdsRef = React.useRef>(new Set()); - React.useEffect(() => { - const nextStreamingIds = new Set(); - sessionStatus?.forEach((status, sessionId) => { - if (status?.type === 'busy' || status?.type === 'retry') { - nextStreamingIds.add(sessionId); - } - }); - - const previousStreamingIds = previousStreamingIdsRef.current; - const startedStreamingIds = Array.from(nextStreamingIds).filter((sessionId) => !previousStreamingIds.has(sessionId)); - if (startedStreamingIds.length > 0) { - setActiveNowEntries((prev) => { - const next = startedStreamingIds.reduce((entries, sessionId) => addActiveNowSession(entries, sessionId), prev); - if (next === prev) { - return prev; - } - persistActiveNowEntries(safeStorage, next); - return next; - }); - } - - previousStreamingIdsRef.current = nextStreamingIds; - }, [sessionStatus, safeStorage]); - - React.useEffect(() => { - const busyIds: string[] = []; - sessionStatus?.forEach((status, sessionId) => { - if (status?.type === 'busy' || status?.type === 'retry') { - busyIds.push(sessionId); - } - }); - - if (busyIds.length === 0) { - return; - } - - setActiveNowEntries((prev) => { - const known = new Set(prev.map((entry) => entry.sessionId)); - let next = prev; - let changed = false; - - busyIds.forEach((sessionId) => { - if (known.has(sessionId)) { - return; - } - - const session = allKnownSessionsById.get(sessionId); - if (!session || session.time?.archived) { - return; - } - - const isSubtask = Boolean((session as Session & { parentID?: string | null }).parentID); - if (isSubtask) { - return; - } - - next = addActiveNowSession(next, sessionId); - known.add(sessionId); - changed = true; - }); - - if (!changed) { - return prev; - } - - persistActiveNowEntries(safeStorage, next); - return next; - }); - }, [sessionStatus, allKnownSessionsById, safeStorage]); - const childrenMap = React.useMemo(() => { const map = new Map(); sortedSessions.forEach((session) => { @@ -887,8 +880,6 @@ export const SessionSidebar: React.FC = ({ isVSCode, sessions, archivedSessions, - sessionsByDirectory, - getSessionsByDirectory, availableWorktreesByProject, }); @@ -1248,15 +1239,12 @@ export const SessionSidebar: React.FC = ({ projectId={projectId} archivedBucket={archivedBucket} directoryStatus={directoryStatus} - sessionMemoryState={sessionMemoryState as Map} currentSessionId={currentSessionId} pinnedSessionIds={pinnedSessionIds} expandedParents={expandedParents} hasSessionSearchQuery={hasSessionSearchQuery} normalizedSessionSearchQuery={normalizedSessionSearchQuery} notifyOnSubtasks={notifyOnSubtasks} - sessionStatus={sessionStatus as Map | undefined} - permissions={permissions as Map} editingId={editingId} setEditingId={setEditingId} editTitle={editTitle} @@ -1289,15 +1277,12 @@ export const SessionSidebar: React.FC = ({ ), [ directoryStatus, - sessionMemoryState, currentSessionId, pinnedSessionIds, expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, notifyOnSubtasks, - sessionStatus, - permissions, editingId, setEditingId, editTitle, @@ -1395,6 +1380,7 @@ export const SessionSidebar: React.FC = ({ setRenameFolderDraft={setRenameFolderDraft} setRenamingFolderId={setRenamingFolderId} pinnedSessionIds={pinnedSessionIds} + sessionOrderIndex={sessionOrderIndex} prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch} onToggleCollapsedGroup={toggleCollapsedGroup} dragHandleProps={dragHandleProps} @@ -1428,6 +1414,7 @@ export const SessionSidebar: React.FC = ({ renamingFolderId, renameFolderDraft, pinnedSessionIds, + sessionOrderIndex, prVisualStateByDirectoryBranch, toggleCollapsedGroup, ], @@ -1490,6 +1477,11 @@ export const SessionSidebar: React.FC = ({
) : null} + + = ({ collapsedProjects={collapsedProjects} hideDirectoryControls={hideDirectoryControls} projectRepoStatus={projectRepoStatus} - hoveredProjectId={hoveredProjectId} - setHoveredProjectId={setHoveredProjectId} isDesktopShellRuntime={isDesktopShellRuntime} stuckProjectHeaders={stuckProjectHeaders} mobileVariant={mobileVariant} diff --git a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx index df7b2f57..c4deb621 100644 --- a/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx +++ b/packages/ui/src/components/session/sidebar/SessionGroupSection.tsx @@ -66,6 +66,7 @@ type Props = { setRenameFolderDraft: React.Dispatch>; setRenamingFolderId: React.Dispatch>; pinnedSessionIds: Set; + sessionOrderIndex: Map; prVisualStateByDirectoryBranch: Map { + const aIndex = sessionOrderIndex.get(a.session.id); + const bIndex = sessionOrderIndex.get(b.session.id); + if (aIndex !== undefined || bIndex !== undefined) { + if (aIndex === undefined) return 1; + if (bIndex === undefined) return -1; + if (aIndex !== bIndex) return aIndex - bIndex; + } + return compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds); + }, [pinnedSessionIds, sessionOrderIndex]); + const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null; const displayMode = useSessionDisplayStore((state) => state.displayMode); const isMinimalMode = displayMode === 'minimal'; @@ -144,7 +157,11 @@ export function SessionGroupSection(props: Props): React.ReactNode { const maxVisible = hideDirectoryControls ? 10 : 5; const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false; const shouldFilterGroupContents = hasSessionSearchQuery; - const sourceGroupNodes = shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions; + const sourceGroupNodes = React.useMemo( + () => [...(shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions)] + .sort(compareSessionNodes), + [compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents], + ); const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null); const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : []; @@ -163,7 +180,7 @@ export function SessionGroupSection(props: Props): React.ReactNode { const nodes = folder.sessionIds .map((sid) => nodeBySessionId.get(sid)) .filter((n): n is SessionNode => Boolean(n)) - .sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds)); + .sort(compareSessionNodes); return { folder, nodes }; }); diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index 1ec78232..e03afa9c 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -34,6 +34,8 @@ import { } from '@remixicon/react'; import { cn } from '@/lib/utils'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { useGlobalSessionStatus, useSession, useSessionPermissions } from '@/sync/sync-context'; +import { useViewportStore } from '@/sync/viewport-store'; import { DraggableSessionRow } from './sessionFolderDnd'; import type { SessionNode, SessionSummaryMeta } from './types'; import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils'; @@ -60,15 +62,12 @@ type Props = { projectId?: string | null; archivedBucket?: boolean; directoryStatus: Map; - sessionMemoryState: Map; currentSessionId: string | null; pinnedSessionIds: Set; expandedParents: Set; hasSessionSearchQuery: boolean; normalizedSessionSearchQuery: string; notifyOnSubtasks: boolean; - sessionStatus?: Map; - permissions: Map; editingId: string | null; setEditingId: (id: string | null) => void; editTitle: string; @@ -99,7 +98,59 @@ type Props = { renderContext?: 'project' | 'recent'; }; -export function SessionNodeItem(props: Props): React.ReactNode { +const getNodeChildSignature = (node: SessionNode): string => { + if (node.children.length === 0) { + return ''; + } + + return node.children + .map((child) => `${child.session.id}:${child.children.length}`) + .join('|'); +}; + +const areEqual = (prev: Props, next: Props): boolean => { + const prevSession = prev.node.session; + const nextSession = next.node.session; + const prevSessionId = prevSession.id; + const nextSessionId = nextSession.id; + + if (prevSessionId !== nextSessionId) return false; + if (prev.node.session !== next.node.session) return false; + if (getNodeChildSignature(prev.node) !== getNodeChildSignature(next.node)) return false; + if (prev.depth !== next.depth) return false; + if (prev.groupDirectory !== next.groupDirectory) return false; + if (prev.projectId !== next.projectId) return false; + if (prev.archivedBucket !== next.archivedBucket) return false; + if ((prev.currentSessionId === prevSessionId) !== (next.currentSessionId === nextSessionId)) return false; + if (prev.pinnedSessionIds.has(prevSessionId) !== next.pinnedSessionIds.has(nextSessionId)) return false; + if (prev.expandedParents.has(prevSessionId) !== next.expandedParents.has(nextSessionId)) return false; + if (prev.hasSessionSearchQuery !== next.hasSessionSearchQuery) return false; + if (prev.normalizedSessionSearchQuery !== next.normalizedSessionSearchQuery) return false; + if (prev.notifyOnSubtasks !== next.notifyOnSubtasks) return false; + if ((prev.editingId === prevSessionId) !== (next.editingId === nextSessionId)) return false; + if (prev.editTitle !== next.editTitle && ((prev.editingId === prevSessionId) || (next.editingId === nextSessionId))) return false; + if ((prev.copiedSessionId === prevSessionId) !== (next.copiedSessionId === nextSessionId)) return false; + + const prevMenuKey = `${prev.renderContext ?? 'project'}:${prev.archivedBucket ? 'archived' : 'active'}:${prevSessionId}`; + const nextMenuKey = `${next.renderContext ?? 'project'}:${next.archivedBucket ? 'archived' : 'active'}:${nextSessionId}`; + if ((prev.openSidebarMenuKey === prevMenuKey) !== (next.openSidebarMenuKey === nextMenuKey)) return false; + + const prevDirectory = normalizePath((prevSession as Session & { directory?: string | null }).directory ?? null) + ?? normalizePath(prev.groupDirectory ?? null); + const nextDirectory = normalizePath((nextSession as Session & { directory?: string | null }).directory ?? null) + ?? normalizePath(next.groupDirectory ?? null); + if (prevDirectory !== nextDirectory) return false; + if ((prevDirectory ? prev.directoryStatus.get(prevDirectory) : null) !== (nextDirectory ? next.directoryStatus.get(nextDirectory) : null)) return false; + + if ((prev.secondaryMeta?.projectLabel ?? null) !== (next.secondaryMeta?.projectLabel ?? null)) return false; + if ((prev.secondaryMeta?.branchLabel ?? null) !== (next.secondaryMeta?.branchLabel ?? null)) return false; + if (prev.mobileVariant !== next.mobileVariant) return false; + if ((prev.renderContext ?? 'project') !== (next.renderContext ?? 'project')) return false; + + return true; +}; + +function SessionNodeItemComponent(props: Props): React.ReactNode { const { node, depth = 0, @@ -107,15 +158,12 @@ export function SessionNodeItem(props: Props): React.ReactNode { projectId, archivedBucket = false, directoryStatus, - sessionMemoryState, currentSessionId, pinnedSessionIds, expandedParents, hasSessionSearchQuery, normalizedSessionSearchQuery, notifyOnSubtasks, - sessionStatus, - permissions, editingId, setEditingId, editTitle, @@ -163,24 +211,30 @@ export function SessionNodeItem(props: Props): React.ReactNode { const suppressNextSelectRef = React.useRef(false); const session = node.session; + const liveSession = useSession(session.id); + const resolvedSession = liveSession ?? session; const menuInstanceKey = `${renderContext}:${archivedBucket ? 'archived' : 'active'}:${session.id}`; const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); + const isZombie = useViewportStore( + React.useCallback((state) => Boolean(state.sessionMemoryState.get(session.id)?.isZombie), [session.id]), + ); + const sessionStatus = useGlobalSessionStatus(session.id); + const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined); const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null; const isMissingDirectory = directoryState === 'missing'; - const memoryState = sessionMemoryState.get(session.id); const isActive = currentSessionId === session.id; - const sessionTitle = session.title || 'Untitled Session'; + const sessionTitle = resolvedSession.title || 'Untitled Session'; const hasChildren = node.children.length > 0; const isPinnedSession = pinnedSessionIds.has(session.id); const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id); - const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID); + const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID); const unseenCount = useSessionUnseenCount(session.id); const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks); - const sessionSummary = session.summary as SessionSummaryMeta | undefined; + const sessionSummary = resolvedSession.summary as SessionSummaryMeta | undefined; const sessionDiffStats = resolveSessionDiffStats(sessionSummary); - const sessionTimestamp = session.time?.updated || session.time?.created || Date.now(); + const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now(); const sessionUpdatedLabel = formatSessionDateLabel(sessionTimestamp); const sessionCompactUpdatedLabel = formatSessionCompactDateLabel(sessionTimestamp); const isMenuOpen = openSidebarMenuKey === menuInstanceKey; @@ -236,9 +290,9 @@ export function SessionNodeItem(props: Props): React.ReactNode { ); } - const statusType = sessionStatus?.get(session.id)?.type ?? 'idle'; + const statusType = sessionStatus?.type ?? 'idle'; const isStreaming = statusType === 'busy' || statusType === 'retry'; - const pendingPermissionCount = permissions.get(session.id)?.length ?? 0; + const pendingPermissionCount = sessionPermissions.length; const showUnreadStatus = !isStreaming && needsAttention && !isActive; const showStatusMarker = isStreaming || showUnreadStatus; const statusMarkerContent = isStreaming @@ -296,7 +350,7 @@ export function SessionNodeItem(props: Props): React.ReactNode { ) : null; - const streamingIndicator = memoryState?.isZombie + const streamingIndicator = isZombie ? : null; @@ -338,14 +392,14 @@ export function SessionNodeItem(props: Props): React.ReactNode { {isPinnedSession ? : } {isPinnedSession ? 'Unpin session' : 'Pin session'} - {!session.share ? ( - handleShareSession(session)} className="[&>svg]:mr-1"> + {!resolvedSession.share ? ( + handleShareSession(resolvedSession)} className="[&>svg]:mr-1"> Share ) : ( <> - { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1"> + { if (resolvedSession.share?.url) handleCopyShareUrl(resolvedSession.share.url, session.id); }} className="[&>svg]:mr-1"> {copiedSessionId === session.id ? <>Copied : <>Copy link} handleUnshareSession(session.id)} className="[&>svg]:mr-1"> @@ -601,3 +655,5 @@ export function SessionNodeItem(props: Props): React.ReactNode { ); } + +export const SessionNodeItem = React.memo(SessionNodeItemComponent, areEqual); diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index 8e3c45b8..a4175ff3 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -43,8 +43,6 @@ type Props = { collapsedProjects: Set; hideDirectoryControls: boolean; projectRepoStatus: Map; - hoveredProjectId: string | null; - setHoveredProjectId: (id: string | null) => void; isDesktopShellRuntime: boolean; stuckProjectHeaders: Set; mobileVariant: boolean; @@ -144,7 +142,6 @@ export function SidebarProjectsList(props: Props): React.ReactNode { const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory); const isCollapsed = props.collapsedProjects.has(projectKey); const isActiveProject = projectKey === props.activeProjectId; - const isHovered = props.hoveredProjectId === projectKey; const isRepo = props.projectRepoStatus.get(projectKey); const orderedGroups = props.getOrderedGroups(projectKey, section.groups); const rootGroup = orderedGroups.find((group) => group.isMain) ?? null; @@ -164,14 +161,12 @@ export function SidebarProjectsList(props: Props): React.ReactNode { projectIconBackground={project.iconBackground} isCollapsed={isCollapsed} isActiveProject={isActiveProject} - isHovered={isHovered} isRepo={Boolean(isRepo)} isDesktopShell={props.isDesktopShellRuntime} isStuck={props.stuckProjectHeaders.has(projectKey)} hideDirectoryControls={props.hideDirectoryControls} mobileVariant={props.mobileVariant} onToggle={() => props.toggleProject(projectKey)} - onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)} onNewSession={() => { if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); props.setActiveMainTab('chat'); diff --git a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts index 05b924be..775c30cc 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useProjectSessionLists.ts @@ -8,8 +8,6 @@ type Args = { isVSCode: boolean; sessions: Session[]; archivedSessions: Session[]; - sessionsByDirectory: Map; - getSessionsByDirectory: (directory: string) => Session[]; availableWorktreesByProject: Map; }; @@ -18,11 +16,25 @@ export const useProjectSessionLists = (args: Args) => { isVSCode, sessions, archivedSessions, - sessionsByDirectory, - getSessionsByDirectory, availableWorktreesByProject, } = args; + const sessionsByDirectory = React.useMemo(() => { + const next = new Map(); + sessions.forEach((session) => { + const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) + ?? normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null); + if (!directory) { + return; + } + + const collection = next.get(directory) ?? []; + collection.push(session); + next.set(directory, collection); + }); + return next; + }, [sessions]); + const getSessionsForProject = React.useCallback( (project: { normalizedPath: string }) => { const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []); @@ -37,7 +49,7 @@ export const useProjectSessionLists = (args: Args) => { const collected: Session[] = []; directories.forEach((directory) => { - const sessionsForDirectory = sessionsByDirectory.get(directory) ?? getSessionsByDirectory(directory); + const sessionsForDirectory = sessionsByDirectory.get(directory) ?? []; sessionsForDirectory.forEach((session) => { if (seen.has(session.id)) { return; @@ -49,7 +61,7 @@ export const useProjectSessionLists = (args: Args) => { return collected; }, - [availableWorktreesByProject, getSessionsByDirectory, isVSCode, sessionsByDirectory], + [availableWorktreesByProject, isVSCode, sessionsByDirectory], ); const getArchivedSessionsForProject = React.useCallback( diff --git a/packages/ui/src/components/session/sidebar/sortableItems.tsx b/packages/ui/src/components/session/sidebar/sortableItems.tsx index f326dbf1..46f6c383 100644 --- a/packages/ui/src/components/session/sidebar/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/sortableItems.tsx @@ -32,14 +32,12 @@ export interface SortableProjectItemProps { projectIconBackground?: string; isCollapsed: boolean; isActiveProject: boolean; - isHovered: boolean; isRepo: boolean; isDesktopShell: boolean; isStuck: boolean; hideDirectoryControls: boolean; mobileVariant: boolean; onToggle: () => void; - onHoverChange: (hovered: boolean) => void; onNewSession: () => void; onNewWorktreeSession?: () => void; onRenameStart: () => void; @@ -67,14 +65,12 @@ export const SortableProjectItem: React.FC = ({ projectIconBackground, isCollapsed, isActiveProject, - isHovered, isRepo, isDesktopShell, isStuck, hideDirectoryControls, mobileVariant, onToggle, - onHoverChange, onNewSession, onNewWorktreeSession, onRenameStart, @@ -158,8 +154,6 @@ export const SortableProjectItem: React.FC = ({ 'w-full text-left group/project select-none', )} style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }} - onMouseEnter={() => onHoverChange(true)} - onMouseLeave={() => onHoverChange(false)} >
@@ -172,17 +166,17 @@ export const SortableProjectItem: React.FC = ({ className={cn( 'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]', isRepo && !hideDirectoryControls - ? (mobileVariant ? 'pr-20' : isHovered ? 'pr-20' : 'pr-7') - : (mobileVariant ? 'pr-14' : isHovered ? 'pr-14' : 'pr-7'), + ? (mobileVariant ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20') + : (mobileVariant ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'), )} > - + {isCollapsed ? : } {imageUrl ? ( = ({ /> ) : ProjectIcon ? ( - + ) : ( - + )} = ({ }} className={cn( 'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground transition-opacity', - mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none', + mobileVariant ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto', )} aria-label="New worktree" > @@ -249,7 +243,11 @@ export const SortableProjectItem: React.FC = ({ type="button" className={cn( 'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground', - isMenuOpen ? 'opacity-100 pointer-events-auto' : mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none', + isMenuOpen + ? 'opacity-100 pointer-events-auto' + : mobileVariant + ? 'opacity-100' + : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto', )} aria-label="Project menu" onClick={handleMenuTriggerClick} @@ -291,7 +289,7 @@ export const SortableProjectItem: React.FC = ({ }} className={cn( 'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity', - mobileVariant ? 'opacity-100' : isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none', + mobileVariant ? 'opacity-100' : 'opacity-0 pointer-events-none group-hover/project:opacity-100 group-hover/project:pointer-events-auto group-focus-within/project:opacity-100 group-focus-within/project:pointer-events-auto', )} aria-label={isRepo ? 'New draft session' : 'New session'} > diff --git a/packages/ui/src/components/session/sidebar/utils.tsx b/packages/ui/src/components/session/sidebar/utils.tsx index 4876af8f..fb6c0231 100644 --- a/packages/ui/src/components/session/sidebar/utils.tsx +++ b/packages/ui/src/components/session/sidebar/utils.tsx @@ -133,6 +133,20 @@ export const compareSessionsByPinnedAndTime = ( return getSessionUpdatedAt(b) - getSessionUpdatedAt(a); }; +export const compareSessionsByPinnedAndCreated = ( + a: Session, + b: Session, + pinnedSessionIds: Set, +): number => { + const aPinned = pinnedSessionIds.has(a.id); + const bPinned = pinnedSessionIds.has(b.id); + if (aPinned !== bPinned) { + return aPinned ? -1 : 1; + } + + return getSessionCreatedAt(b) - getSessionCreatedAt(a); +}; + export const dedupeSessionsById = (sessions: Session[]): Session[] => { const byId = new Map(); sessions.forEach((session) => { diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 890b0257..11b53cfe 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -52,7 +52,9 @@ const renderShortcut = (id: string, fallbackCombo: string, overrides: Record { - const { isHelpDialogOpen, setHelpDialogOpen, shortcutOverrides } = useUIStore(); + const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen); + const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen); + const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const mod = getModifierLabel(); const shortcuts: ShortcutSection[] = [ diff --git a/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx b/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx index 9929fb78..c02970a5 100644 --- a/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx +++ b/packages/ui/src/components/ui/OpenCodeStatusDialog.tsx @@ -11,11 +11,9 @@ import { useUIStore } from '@/stores/useUIStore'; import { copyTextToClipboard } from '@/lib/clipboard'; export const OpenCodeStatusDialog: React.FC = () => { - const { - isOpenCodeStatusDialogOpen, - setOpenCodeStatusDialogOpen, - openCodeStatusText, - } = useUIStore(); + const isOpenCodeStatusDialogOpen = useUIStore((state) => state.isOpenCodeStatusDialogOpen); + const setOpenCodeStatusDialogOpen = useUIStore((state) => state.setOpenCodeStatusDialogOpen); + const openCodeStatusText = useUIStore((state) => state.openCodeStatusText); const handleCopy = React.useCallback(async () => { if (!openCodeStatusText) { diff --git a/packages/ui/src/components/ui/OverlayScrollbar.tsx b/packages/ui/src/components/ui/OverlayScrollbar.tsx index 13f1c9f0..7b909e99 100644 --- a/packages/ui/src/components/ui/OverlayScrollbar.tsx +++ b/packages/ui/src/components/ui/OverlayScrollbar.tsx @@ -18,6 +18,12 @@ type ThumbMetrics = { }; const USER_SCROLL_INTENT_WINDOW_MS = 1000; +const METRIC_EPSILON = 0.5; +const EMPTY_THUMB: ThumbMetrics = { length: 0, offset: 0 }; + +const isSameThumbMetrics = (a: ThumbMetrics, b: ThumbMetrics): boolean => { + return Math.abs(a.length - b.length) < METRIC_EPSILON && Math.abs(a.offset - b.offset) < METRIC_EPSILON; +}; export const OverlayScrollbar: React.FC = ({ containerRef, @@ -52,6 +58,7 @@ export const OverlayScrollbar: React.FC = ({ const { scrollHeight, clientHeight, scrollTop, scrollWidth, clientWidth, scrollLeft } = container; const trackInset = 8; + let nextVertical: ThumbMetrics = EMPTY_THUMB; if (scrollHeight > clientHeight) { const trackLength = Math.max(clientHeight - trackInset * 2, 0); const rawThumb = (clientHeight / scrollHeight) * trackLength; @@ -59,11 +66,11 @@ export const OverlayScrollbar: React.FC = ({ const maxOffset = Math.max(trackLength - length, 0); const maxScroll = Math.max(scrollHeight - clientHeight, 1); const offset = (scrollTop / maxScroll) * maxOffset; - setVertical({ length, offset }); - } else { - setVertical({ length: 0, offset: 0 }); + nextVertical = { length, offset }; } + setVertical((prev) => (isSameThumbMetrics(prev, nextVertical) ? prev : nextVertical)); + let nextHorizontal: ThumbMetrics = EMPTY_THUMB; if (!disableHorizontal && scrollWidth > clientWidth) { const trackLength = Math.max(clientWidth - trackInset * 2, 0); const rawThumb = (clientWidth / scrollWidth) * trackLength; @@ -71,10 +78,9 @@ export const OverlayScrollbar: React.FC = ({ const maxOffset = Math.max(trackLength - length, 0); const maxScroll = Math.max(scrollWidth - clientWidth, 1); const offset = (scrollLeft / maxScroll) * maxOffset; - setHorizontal({ length, offset }); - } else { - setHorizontal({ length: 0, offset: 0 }); + nextHorizontal = { length, offset }; } + setHorizontal((prev) => (isSameThumbMetrics(prev, nextHorizontal) ? prev : nextHorizontal)); }, [containerRef, minThumbSize, disableHorizontal]); const scheduleMetricsUpdate = React.useCallback(() => { diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 6191711c..c0de211a 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -24,7 +24,6 @@ import { useWorkerPool } from '@/contexts/DiffWorkerProvider'; import { ensurePierreThemeRegistered, getResolvedShikiTheme } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; -import { useUIStore } from '@/stores/useUIStore'; import { useDeviceInfo } from '@/lib/device'; import { cn } from '@/lib/utils'; @@ -209,7 +208,6 @@ export const PierreDiffViewer: React.FC = ({ const lightTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.lightThemeId) ?? getDefaultTheme(false); const darkTheme = themeContext?.availableThemes.find(t => t.metadata.id === themeContext.darkThemeId) ?? getDefaultTheme(true); - useUIStore(); const { isMobile } = useDeviceInfo(); const diffCommentController = useInlineCommentController({ diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index 939d2d07..83b8326b 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -5,7 +5,6 @@ import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { Button } from '@/components/ui/button'; -import { useUIStore } from '@/stores/useUIStore'; import { buildCodeMirrorCommentWidgets, normalizeLineRange, useInlineCommentController } from '@/components/comments'; import { getLanguageFromExtension } from '@/lib/toolHelpers'; @@ -87,7 +86,6 @@ export const PlanView: React.FC = () => { const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const runtimeApis = useRuntimeAPIs(); - useUIStore(); const { isMobile } = useDeviceInfo(); const { currentTheme } = useThemeSystem(); React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); diff --git a/packages/ui/src/components/voice/VoiceProvider.tsx b/packages/ui/src/components/voice/VoiceProvider.tsx index ba73789a..471f945f 100644 --- a/packages/ui/src/components/voice/VoiceProvider.tsx +++ b/packages/ui/src/components/voice/VoiceProvider.tsx @@ -1,5 +1,11 @@ import React from 'react'; import { useVoiceContext } from '@/hooks/useVoiceContext'; +import { useConfigStore } from '@/stores/useConfigStore'; + +const VoiceContextBridge = React.memo(function VoiceContextBridge() { + useVoiceContext(); + return null; +}); /** * Provider component that initializes voice context sync. @@ -13,8 +19,12 @@ import { useVoiceContext } from '@/hooks/useVoiceContext'; * ``` */ export function VoiceProvider({ children }: { children: React.ReactNode }) { - // Activate session-to-voice sync - useVoiceContext(); - - return <>{children}; + const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled); + + return ( + <> + {voiceModeEnabled ? : null} + {children} + + ); } diff --git a/packages/ui/src/hooks/useBrowserVoice.ts b/packages/ui/src/hooks/useBrowserVoice.ts index 928b46db..41e75e9a 100644 --- a/packages/ui/src/hooks/useBrowserVoice.ts +++ b/packages/ui/src/hooks/useBrowserVoice.ts @@ -132,7 +132,19 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { const sendMessage = useSessionUIStore((s) => s.sendMessage); const setPendingInputText = useInputStore((s) => s.setPendingInputText); const createSession = useSessionUIStore((s) => s.createSession); - const { currentProviderId, currentModelId, currentAgentName, voiceModeEnabled, voiceProvider, speechRate, speechPitch, speechVolume, sayVoice, browserVoice, openaiVoice, summarizeVoiceConversation, summarizeCharacterThreshold } = useConfigStore(); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const currentAgentName = useConfigStore((state) => state.currentAgentName); + const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled); + const voiceProvider = useConfigStore((state) => state.voiceProvider); + const speechRate = useConfigStore((state) => state.speechRate); + const speechPitch = useConfigStore((state) => state.speechPitch); + const speechVolume = useConfigStore((state) => state.speechVolume); + const sayVoice = useConfigStore((state) => state.sayVoice); + const browserVoice = useConfigStore((state) => state.browserVoice); + const openaiVoice = useConfigStore((state) => state.openaiVoice); + const summarizeVoiceConversation = useConfigStore((state) => state.summarizeVoiceConversation); + const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold); const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai'; const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say'; diff --git a/packages/ui/src/hooks/useChatScrollManager.ts b/packages/ui/src/hooks/useChatScrollManager.ts index a6830b06..ebb1bad5 100644 --- a/packages/ui/src/hooks/useChatScrollManager.ts +++ b/packages/ui/src/hooks/useChatScrollManager.ts @@ -457,7 +457,48 @@ export const useChatScrollManager = ({ const container = scrollRef.current; if (!container || typeof ResizeObserver === 'undefined') return; + let lastScrollHeight = container.scrollHeight; + let lastClientHeight = container.clientHeight; + const observer = new ResizeObserver(() => { + const nextScrollHeight = container.scrollHeight; + const nextClientHeight = container.clientHeight; + const scrollHeightChanged = nextScrollHeight !== lastScrollHeight; + const clientHeightChanged = nextClientHeight !== lastClientHeight; + + if (clientHeightChanged) { + const previousDistanceFromBottom = Math.max( + 0, + lastScrollHeight - lastScrollTopRef.current - lastClientHeight, + ); + + if (isPinnedRef.current) { + const targetScrollTop = Math.max( + 0, + nextScrollHeight - nextClientHeight - previousDistanceFromBottom, + ); + + if (Math.abs(container.scrollTop - targetScrollTop) > 0.5) { + markProgrammaticScroll(); + container.scrollTop = targetScrollTop; + lastScrollTopRef.current = targetScrollTop; + } + + lastScrollHeight = nextScrollHeight; + lastClientHeight = nextClientHeight; + updateScrollButtonVisibility(); + return; + } + } + + lastScrollHeight = nextScrollHeight; + lastClientHeight = nextClientHeight; + + if (clientHeightChanged && !scrollHeightChanged) { + updateScrollButtonVisibility(); + return; + } + schedulePinnedStateAndIndicators(); }); @@ -474,7 +515,7 @@ export const useChatScrollManager = ({ observer.disconnect(); childObserver.disconnect(); }; - }, [schedulePinnedStateAndIndicators]); + }, [schedulePinnedStateAndIndicators, updateScrollButtonVisibility]); React.useEffect(() => { if (typeof window === 'undefined') { diff --git a/packages/ui/src/hooks/useEdgeSwipe.ts b/packages/ui/src/hooks/useEdgeSwipe.ts index 4d76ec9c..baa69253 100644 --- a/packages/ui/src/hooks/useEdgeSwipe.ts +++ b/packages/ui/src/hooks/useEdgeSwipe.ts @@ -16,11 +16,9 @@ export const useEdgeSwipe = (options: EdgeSwipeOptions = {}) => { enabled = true, } = options; - const { - isMobile, - setSessionSwitcherOpen, - isSessionSwitcherOpen, - } = useUIStore(); + const isMobile = useUIStore((state) => state.isMobile); + const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); + const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null); const touchEndRef = useRef<{ x: number; y: number; time: number } | null>(null); diff --git a/packages/ui/src/hooks/useEffectiveDirectory.ts b/packages/ui/src/hooks/useEffectiveDirectory.ts index cf4d2da1..34855ef9 100644 --- a/packages/ui/src/hooks/useEffectiveDirectory.ts +++ b/packages/ui/src/hooks/useEffectiveDirectory.ts @@ -1,7 +1,6 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useSessions } from '@/sync/sync-context'; +import { useSessionDirectory } from '@/sync/sync-context'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import type { Session } from '@opencode-ai/sdk/v2'; /** * Hook that resolves the effective working directory for tabs (Git, Diff, Files, Terminal). @@ -18,7 +17,7 @@ import type { Session } from '@opencode-ai/sdk/v2'; export const useEffectiveDirectory = (): string | undefined => { const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); - const sessions = useSessions(); + const currentSessionDirectory = useSessionDirectory(currentSessionId); const worktreeMap = useSessionUIStore((s) => s.worktreeMetadata); const fallbackDirectory = useDirectoryStore((s) => s.currentDirectory); @@ -28,12 +27,8 @@ export const useEffectiveDirectory = (): string | undefined => { if (worktreeMetadata?.path) { return worktreeMetadata.path; } - - const currentSession = sessions.find((session) => session.id === currentSessionId); - type SessionWithDirectory = Session & { directory?: string }; - const sessionDirectory = (currentSession as SessionWithDirectory | undefined)?.directory; - if (sessionDirectory) { - return sessionDirectory; + if (currentSessionDirectory) { + return currentSessionDirectory; } } diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts index 15e789b6..ff7d9507 100644 --- a/packages/ui/src/hooks/useMessageTTS.ts +++ b/packages/ui/src/hooks/useMessageTTS.ts @@ -24,18 +24,16 @@ export interface UseMessageTTSReturn { export function useMessageTTS(): UseMessageTTSReturn { const [isPlaying, setIsPlaying] = useState(false); - const { - voiceProvider, - speechRate, - speechPitch, - speechVolume, - sayVoice, - browserVoice, - openaiVoice, - summarizeMessageTTS, - summarizeCharacterThreshold, - showMessageTTSButtons, - } = useConfigStore(); + const voiceProvider = useConfigStore((state) => state.voiceProvider); + const speechRate = useConfigStore((state) => state.speechRate); + const speechPitch = useConfigStore((state) => state.speechPitch); + const speechVolume = useConfigStore((state) => state.speechVolume); + const sayVoice = useConfigStore((state) => state.sayVoice); + const browserVoice = useConfigStore((state) => state.browserVoice); + const openaiVoice = useConfigStore((state) => state.openaiVoice); + const summarizeMessageTTS = useConfigStore((state) => state.summarizeMessageTTS); + const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold); + const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const shouldCheckOpenAIAvailability = showMessageTTSButtons && voiceProvider === 'openai'; const shouldCheckSayAvailability = showMessageTTSButtons && voiceProvider === 'say'; diff --git a/packages/ui/src/hooks/useModelLists.ts b/packages/ui/src/hooks/useModelLists.ts index ba7a653d..c3aa2166 100644 --- a/packages/ui/src/hooks/useModelLists.ts +++ b/packages/ui/src/hooks/useModelLists.ts @@ -14,7 +14,7 @@ export interface ModelListItem { } export const useModelLists = () => { - const { providers } = useConfigStore(); + const providers = useConfigStore((state) => state.providers); const favoriteModels = useUIStore((state) => state.favoriteModels); const recentModels = useUIStore((state) => state.recentModels); const hiddenModels = useUIStore((state) => state.hiddenModels); diff --git a/packages/ui/src/hooks/useServerTTS.ts b/packages/ui/src/hooks/useServerTTS.ts index ce78fe2a..88f632d0 100644 --- a/packages/ui/src/hooks/useServerTTS.ts +++ b/packages/ui/src/hooks/useServerTTS.ts @@ -125,7 +125,12 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet const abortControllerRef = useRef(null); // Get current model, threshold, and max length from config store for summarization - const { currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey, settingsZenModel } = useConfigStore(); + const currentProviderId = useConfigStore((state) => state.currentProviderId); + const currentModelId = useConfigStore((state) => state.currentModelId); + const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold); + const summarizeMaxLength = useConfigStore((state) => state.summarizeMaxLength); + const openaiApiKey = useConfigStore((state) => state.openaiApiKey); + const settingsZenModel = useConfigStore((state) => state.settingsZenModel); // Check if server TTS is available const checkAvailability = useCallback(async (): Promise => { diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 0264c59f..f5d98eef 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -1,6 +1,7 @@ /* eslint-disable react-refresh/only-export-components */ import React, { createContext, useContext, useEffect, useRef, useCallback, useMemo } from "react" import type { Event, Message, Part } from "@opencode-ai/sdk/v2/client" +import type { Session } from "@opencode-ai/sdk/v2" import type { StoreApi } from "zustand" import { useStore } from "zustand" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" @@ -681,6 +682,145 @@ export function useSessions(directory?: string) { ) } +const getSidebarSessionSignature = (session: Session, stableUpdatedAt: number): string => { + const directory = (session as Session & { directory?: string | null }).directory ?? '' + const parentID = (session as Session & { parentID?: string | null }).parentID ?? '' + const projectWorktree = (session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? '' + const shared = session.share?.url ?? '' + return [ + session.id, + session.title ?? '', + session.time?.created ?? 0, + session.time?.archived ? 1 : 0, + directory, + parentID, + projectWorktree, + shared, + stableUpdatedAt, + ].join('|') +} + +/** Get sessions stabilized for sidebar tree rendering */ +export function useSidebarSessions(directory?: string): Session[] { + const store = useDirectoryStore(directory) + const cacheRef = React.useRef<{ + source: Session[] + streamingSignature: string + array: Session[] + signatures: Map + sessionsById: Map + stableUpdatedAtById: Map + streamingById: Map + } | null>(null) + + const getSnapshot = React.useCallback(() => { + const state = store.getState() + const source = state.session + const cached = cacheRef.current + const streamingSignature = source + .map((session) => { + const statusType = state.session_status?.[session.id]?.type + const isStreaming = statusType === 'busy' || statusType === 'retry' + return `${session.id}:${isStreaming ? 1 : 0}` + }) + .join('|') + + if (cached && cached.source === source && cached.streamingSignature === streamingSignature) { + return cached.array + } + + const signatures = new Map() + const sessionsById = new Map() + const stableUpdatedAtById = new Map() + const streamingById = new Map() + let changed = !cached || cached.array.length !== source.length + + const array = source.map((session) => { + const rawUpdatedAt = Number(session.time?.updated ?? session.time?.created ?? 0) + const statusType = state.session_status?.[session.id]?.type + const isStreaming = statusType === 'busy' || statusType === 'retry' + const cachedUpdatedAt = cached?.stableUpdatedAtById.get(session.id) ?? rawUpdatedAt + const wasStreaming = cached?.streamingById.get(session.id) ?? false + const stableUpdatedAt = isStreaming + ? (wasStreaming ? cachedUpdatedAt : Math.max(rawUpdatedAt, cachedUpdatedAt, Date.now())) + : cachedUpdatedAt + const signature = getSidebarSessionSignature(session, stableUpdatedAt) + signatures.set(session.id, signature) + stableUpdatedAtById.set(session.id, stableUpdatedAt) + streamingById.set(session.id, isStreaming) + + const cachedSession = cached?.sessionsById.get(session.id) + if ( + cachedSession + && cached?.signatures.get(session.id) === signature + ) { + sessionsById.set(session.id, cachedSession) + return cachedSession + } + + changed = true + const nextSession = stableUpdatedAt === rawUpdatedAt + ? session + : { + ...session, + time: { + ...session.time, + updated: stableUpdatedAt, + }, + } + sessionsById.set(session.id, nextSession) + return nextSession + }) + + if (!changed && cached) { + cacheRef.current = { + source, + streamingSignature, + array: cached.array, + signatures, + sessionsById: cached.sessionsById, + stableUpdatedAtById, + streamingById, + } + return cached.array + } + + cacheRef.current = { source, streamingSignature, array, signatures, sessionsById, stableUpdatedAtById, streamingById } + return array + }, [store]) + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot) +} + +/** Get one session by id for a directory */ +export function useSession(sessionID?: string | null, directory?: string) { + return useDirectorySync( + useCallback( + (state: State) => { + if (!sessionID) return undefined + return state.session.find((session) => session.id === sessionID) + }, + [sessionID], + ), + directory, + ) +} + +/** Get one session directory by id for a directory */ +export function useSessionDirectory(sessionID?: string | null, directory?: string): string | undefined { + return useDirectorySync( + useCallback( + (state: State) => { + if (!sessionID) return undefined + const session = state.session.find((candidate) => candidate.id === sessionID) + return (session as (typeof session & { directory?: string | null }) | undefined)?.directory ?? undefined + }, + [sessionID], + ), + directory, + ) +} + /** Get the SDK client */ export function useSyncSDK() { return useSyncSystem().sdk