| import { |
| useCallback, |
| useEffect, |
| useLayoutEffect, |
| useMemo, |
| useRef, |
| useState, |
| type CSSProperties, |
| type Dispatch, |
| type SetStateAction, |
| } from 'react'; |
| import type { |
| PlanReminder, |
| QuoteRef, |
| SessionSummary, |
| UiLocale, |
| UiLocalePreference, |
| } from '@maka/core'; |
| import { |
| collapseSessionRevisions, |
| isLinkedSubagentSession, |
| parseGraphCommand, |
| parseSwarmCommand, |
| resolveUiLocale, |
| } from '@maka/core'; |
| import { hasSettledInitialOnboarding } from '@maka/core'; |
| import { |
| AutomationsPage, |
| DailyReviewPage, |
| ChatSurfaceLayout, |
| type ComposerHandle, |
| type ComposerSlashCommandOption, |
| type MakaUriDest, |
| MakaUriContext, |
| AstryxLocaleProvider, |
| LocaleProvider, |
| ModuleHubSelector, |
| ToastProvider, |
| type ToastErrorAction, |
| type NavSelection, |
| SessionListPanel, |
| SkillsPage, |
| type SessionViewMode, |
| TitlebarSessionIdentity, |
| type TurnFooterActionMeta, |
| type WorkspacePickerModel, |
| useToast, |
| activeInteractionFor, |
| deriveTitlebarProjectName, |
| enqueueInteraction, |
| getConversationCopy, |
| getSharedUiCopy, |
| reconcileInteractions, |
| } from '@maka/ui'; |
| import { MessageCircleQuestion } from '@maka/ui/icons'; |
| import { useKeyboardHelp } from './keyboard-help'; |
| import { useCommandPalette } from './command-palette'; |
| import { ChatMessageSurface } from './chat-message-surface'; |
| import { deriveWorkspaceReadinessRecovery } from './workspace-readiness-recovery'; |
| import { LiveTurnReconciler } from './live-turn-reconciler'; |
| import { useAppShellSessionUiReads } from './use-app-shell-session-ui-reads'; |
| import { AgentGraphPanel } from './agent-graph-panel'; |
| import { ChatComposerRegion } from './chat-composer-region'; |
| import { ChatWorkbar } from './chat-workbar'; |
| import type { |
| SessionWorkbarPlacement, |
| SessionWorkbarTab, |
| SessionWorkbarTabKind, |
| } from './session-workbar-tabs'; |
| import { |
| findPreferredSideChatWorkbarTab, |
| terminalRefFromWorkbarTab, |
| terminalSessionWorkbarTabId, |
| } from './session-workbar-tabs'; |
| import { |
| consumeCompanionInitialPrompt, |
| consumeCompanionQuoteSnapshot, |
| openCompanionPanel, |
| removeStagedCompanionQuote, |
| stageCompanionQuote, |
| } from './quote-companion-panel-state'; |
| import { |
| parseSideChatCommand, |
| sideChatTitleFromPrompt, |
| } from './side-chat-command'; |
| import { |
| applyCompanionForkVisibilityEvent, |
| reconcileCompanionForkVisibility, |
| } from './quote-companion-visibility'; |
| import { |
| PlanExecutionPanel, |
| PlanProposalCard, |
| usePlanModeState, |
| } from './plan-mode-panel'; |
| import { McpPage } from './mcp-page'; |
| import { getOnboardingActivationCandidate, useOnboardingSnapshot } from './use-onboarding-snapshot'; |
| import type { AppUpdateStatus, OnboardingSnapshot } from '../preload/bridge-contract.js'; |
| import { |
| isAppUpdateInstallFailure, |
| requestDownloadedAppUpdate, |
| } from './app-update-install'; |
| import { ProviderLogo } from './settings/provider-display'; |
| import { ProviderBrandMark } from './settings/provider-brand-marks'; |
| import { getShellCopy, localizedShellErrorMessage } from './locales/shell-copy'; |
| import { getDesktopConversationCopy } from './locales/conversation-copy'; |
| import { |
| SideChatCloseConfirmation, |
| SKIP_SIDE_CHAT_CLOSE_CONFIRMATION_KEY, |
| } from './side-chat-close-confirmation'; |
| import { safeLocalStorageGet, safeLocalStorageSet } from './browser-storage'; |
| import { ErrorBoundary } from './error-boundary'; |
| import { useShellAppearance } from './use-shell-appearance'; |
| import { useShellSearch } from './use-shell-search'; |
| import { useSessionGoal } from './use-session-goal'; |
| import { deriveStaleSessionIds } from './stale-sessions'; |
| import { deriveProjectGroups, deriveWorktreeSessionIds } from './session-project-grouping'; |
| import { deriveSessionRail } from './session-rail'; |
| import { useAppShellTurnPresentation } from './app-shell-turn-view-model'; |
| import { readScrollMotionBehavior } from './scroll-motion-policy'; |
| import { deriveBranchBanner } from './branch-banner'; |
| import { readNavigationState, selectNavigation } from './nav-selection'; |
| import { sessionMatchesNavSelection } from './session-nav-filter'; |
| import { deriveSessionRevisionNavigation } from './session-revisions'; |
| import { deriveDesktopExecutionBoundarySurface } from './desktop-execution-boundary-surface'; |
| import { useActiveExecutionBoundary } from './use-active-execution-boundary'; |
| import { |
| SESSION_LIST_EXPANDED_MAX_WIDTH, |
| SESSION_LIST_EXPANDED_MIN_WIDTH, |
| } from './session-list-layout'; |
| import { modelSetupToastCopy } from './model-connection-errors'; |
| import type { AppShellCommandListOptions } from './app-shell-command-actions'; |
| import { AppShellTopbarActions, AppShellWorkspaceTopActions } from './app-shell-chrome-actions'; |
| import { updateReminderFromStatus } from './app-shell-app-update'; |
| import { AppShellDetailPanel } from './app-shell-detail-panel'; |
| import { AppShellOverlays } from './app-shell-overlays'; |
| import { CustomPetCompanion } from './custom-pet-companion'; |
| import { derivePetActivityState } from './custom-pet-companion-model'; |
| import { createAppShellDailyReviewBridge } from './app-shell-daily-review-bridge'; |
| import { useAppShellModuleData } from './use-module-data'; |
| import { useKeepSystemAwake } from './use-keep-system-awake'; |
| import { useAppShellProjectContext } from './use-project-context'; |
| import { createAppShellSessionEventHandlers } from './app-shell-session-events'; |
| import { createAppShellE2eFixtureActions } from './app-shell-e2e-fixture'; |
| import { |
| createAppShellChatActions, |
| type WorkspaceFileReferencePosition, |
| } from './app-shell-chat-actions'; |
| import { createAppShellTurnActions } from './app-shell-turn-actions'; |
| import { |
| abandonTurnRevisionCopyAttempt, |
| completeTurnRevisionCopyAttempt, |
| createAppShellRevisionActions, |
| type TurnRevisionDraft, |
| } from './app-shell-revision-actions'; |
| import { createAppShellSessionStartActions } from './app-shell-session-start-actions'; |
| import { createAppShellDailyReviewActions } from './app-shell-daily-review-actions'; |
| import { createAppShellSessionRowActions } from './app-shell-session-row-actions'; |
| import { createAppShellSessionSettingsActions } from './app-shell-session-settings-actions'; |
| import { createAppShellStopAction } from './app-shell-stop-action'; |
| import { useStableActions } from './use-stable-actions'; |
| import { |
| useActiveSessionEvents, |
| useAppShellBootstrapSubscriptions, |
| useAppShellHostEffects, |
| useAppShellPersistenceEffects, |
| useAppShellNavRefSync, |
| useSessionEventHealthPolling, |
| useShellRunUpdates, |
| useSettledSessionTransientReconcile, |
| } from './app-shell-effects'; |
| import { loadComposerDefaults, saveComposerDefaults } from './composer-defaults'; |
| import { useKeyedPendingRegistry } from './use-pending-action-registry'; |
| import { useAppShellComposerAttachments } from './use-app-shell-composer-attachments'; |
| import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes'; |
| import { useComposerMentions } from './use-composer-mentions'; |
| import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace'; |
| import { useShellMemoryPill } from './use-shell-memory-pill'; |
| import { useShellConnections } from './use-shell-connections'; |
| import { useShellChatModel } from './use-shell-chat-model'; |
| import { useShellLiveTurn } from './use-shell-live-turn'; |
| import { useShellLayout } from './use-shell-layout'; |
| import { useShellResume } from './use-shell-resume'; |
| import { recoverOrphanedCompanionCopies } from './quote-companion-core'; |
| import { useSideConversationWorkspace } from './use-side-conversation-workspace'; |
| |
| function rebaseWorkspaceFileReferences( |
| sourceText: string, |
| projectedText: string, |
| references: readonly WorkspaceFileReferencePosition[], |
| ): WorkspaceFileReferencePosition[] { |
| const offset = sourceText.lastIndexOf(projectedText); |
| if (offset < 0) return []; |
| return references |
| .filter( |
| (reference) => |
| reference.start >= offset && |
| reference.start + reference.value.length <= offset + projectedText.length, |
| ) |
| .map((reference) => ({ ...reference, start: reference.start - offset })); |
| } |
| import { useSettingsModal } from './use-settings-modal'; |
| import { useSystemUiLocale } from './use-system-ui-locale'; |
| import { |
| isSessionWorkspaceUnavailableError, |
| showSessionWorkspaceUnavailableToast, |
| } from './session-workspace-errors'; |
| import { AppShell as AstryxAppShell } from '@astryxdesign/core/AppShell'; |
| import type { SideNavImperativeCollapseHandle } from '@astryxdesign/core/SideNav'; |
| |
| type ComposerImportOwner = { |
| sessionId: string | undefined; |
| navSection: NavSelection['section']; |
| }; |
| |
| /** |
| * Grace period before the committed-history fallback force-settles an |
| * assistant stream slot when the primary post-commit signal is missed. |
| */ |
| const SETTLE_FALLBACK_GRACE_MS = 1000; |
| /** |
| * Module surfaces that own their whole column and render no workspace toolbar. |
| * This used to be a `display: none` rule keyed on the detail panel's |
| * `data-agents-view`; the toolbar now lives in the window titlebar, which is not |
| * a descendant of the detail panel, so the condition belongs here. |
| */ |
| const VIEWS_WITHOUT_WORKSPACE_ACTIONS = new Set(['skills', 'cron', 'daily-review']); |
| |
| type AppShellProps = { |
| /** Pre-mount snapshot prefetched by main.tsx — see prefetchOnboardingSnapshot. */ |
| initialOnboardingSnapshot?: OnboardingSnapshot | null; |
| }; |
| |
| export function AppShell({ initialOnboardingSnapshot = null }: AppShellProps = {}) { |
| const [uiLocalePreference, setUiLocalePreference] = useState<UiLocalePreference>('auto'); |
| const [uiLocaleOverride, setUiLocaleOverride] = useState<UiLocale | null>(null); |
| const systemUiLocale = useSystemUiLocale(); |
| const uiLocale = resolveUiLocale(uiLocalePreference, systemUiLocale, uiLocaleOverride); |
| const errorToastAction = useMemo<ToastErrorAction>( |
| () => ({ |
| label: getShellCopy(uiLocale).errorBoundary.copyReport, |
| onClick: (input) => { |
| void window.maka.diagnostics.copyErrorReport({ |
| surface: 'toast', |
| title: input.title, |
| ...(input.description ? { description: input.description } : {}), |
| rendererUserAgent: navigator.userAgent, |
| rendererLocale: navigator.language, |
| }) |
| .catch(() => undefined); |
| }, |
| }), |
| [uiLocale], |
| ); |
| |
| return ( |
| <LocaleProvider locale={uiLocale} override={uiLocaleOverride}> |
| {/* #1565: Astryx's message catalog is keyed off OUR locale context, so it |
| must sit inside LocaleProvider — not at the `<Theme>` level, where |
| `useUiLocale()` throws before anything renders. Still above every |
| Astryx subtree. */} |
| <AstryxLocaleProvider> |
| <ToastProvider errorAction={errorToastAction}> |
| <ErrorBoundary locale={uiLocale}> |
| <AppShellContent |
| initialOnboardingSnapshot={initialOnboardingSnapshot} |
| uiLocale={uiLocale} |
| uiLocaleOverride={uiLocaleOverride} |
| setUiLocaleOverride={setUiLocaleOverride} |
| setUiLocalePreference={setUiLocalePreference} |
| /> |
| </ErrorBoundary> |
| </ToastProvider> |
| </AstryxLocaleProvider> |
| </LocaleProvider> |
| ); |
| } |
| |
| function nextSideChatOrdinal(tabs: readonly SessionWorkbarTab[]): number { |
| return ( |
| tabs.reduce( |
| (highest, tab, index) => |
| tab.kind === 'side-chat' |
| ? Math.max(highest, tab.ordinal ?? index + 1) |
| : highest, |
| 0, |
| ) + 1 |
| ); |
| } |
| |
| function nextTerminalOrdinal(tabs: readonly SessionWorkbarTab[]): number { |
| return ( |
| tabs.reduce( |
| (highest, tab, index) => |
| tab.kind === 'terminal' |
| ? Math.max(highest, tab.ordinal ?? index + 1) |
| : highest, |
| 0, |
| ) + 1 |
| ); |
| } |
| |
| function AppShellContent({ |
| initialOnboardingSnapshot = null, |
| uiLocale, |
| uiLocaleOverride, |
| setUiLocaleOverride, |
| setUiLocalePreference, |
| }: { |
| initialOnboardingSnapshot?: OnboardingSnapshot | null; |
| uiLocale: UiLocale; |
| uiLocaleOverride: UiLocale | null; |
| setUiLocaleOverride: Dispatch<SetStateAction<UiLocale | null>>; |
| setUiLocalePreference: Dispatch<SetStateAction<UiLocalePreference>>; |
| }) { |
| const toastApi = useToast(); |
| const [appUpdateStatus, setAppUpdateStatus] = useState<AppUpdateStatus | null>(null); |
| const updateInstallInFlightRef = useRef(false); |
| const notifiedInstallErrorRef = useRef<string | null>(null); |
| const { |
| sessions, |
| authoritativeSessionIds, |
| sessionsRef, |
| setSessions, |
| refreshSessions, |
| seedSessions, |
| upsertSessionSummary, |
| markSessionReadLocally, |
| activeId, |
| activeIdRef, |
| bootstrapSelectionLease, |
| setActiveId, |
| startNewSession, |
| clearOwnedSessionState, |
| messages, |
| setMessages, |
| messageLoadPending, |
| setMessageLoadPending, |
| messageRetryPendingRef, |
| stopPendingRef, |
| sessionUiController, |
| liveTurnBySessionRef, |
| sessionEventHealthBySessionRef, |
| setMessageLoadErrorBySession, |
| setMessageRetryPendingBySession, |
| setStopPendingBySession, |
| setLiveTurnBySession, |
| confirmLiveTurn, |
| setShellRunUpdatesBySession, |
| setInteractionBySession, |
| setSessionEventHealthBySession, |
| setPendingPermissionModeBySession, |
| setPendingSessionModelBySession, |
| clearTurnTransientState, |
| } = useAppShellSessionWorkspace(toastApi); |
| const interactionHydrationEpochRef = useRef(new Map<string, number>()); |
| const markInteractionChanged = useCallback((sessionId: string) => { |
| const epochs = interactionHydrationEpochRef.current; |
| epochs.set(sessionId, (epochs.get(sessionId) ?? 0) + 1); |
| }, []); |
| |
| const attachmentDraftKey = activeId ?? 'new-session'; |
| const { |
| pendingAttachments, |
| pickAttachments, |
| attachFilePaths, |
| removeAttachment, |
| clearSubmittedAttachments, |
| } = useAppShellComposerAttachments({ draftKey: attachmentDraftKey, toastApi }); |
| const { pendingQuotes, addQuote, removeQuote, clearQuotes } = useAppShellComposerQuotes({ |
| draftKey: attachmentDraftKey, |
| }); |
| const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); |
| const [planReminderCreateRequestNonce, setPlanReminderCreateRequestNonce] = useState(0); |
| const [pendingCollaborationModeBySession, setPendingCollaborationModeBySession] = useState<Record<string, boolean>>({}); |
| const [newChatSwarmModeActive, setNewChatSwarmModeActive] = useState(false); |
| const [newChatGraphModeActive, setNewChatGraphModeActive] = useState(false); |
| const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState<Record<string, boolean>>({}); |
| const [petCompletionNonce, setPetCompletionNonce] = useState(0); |
| // P3: session ids with a live embedded-browser view. The right-side |
| // BrowserPanel mounts only for these, so ordinary chats reserve no space. |
| const [, setLiveBrowserSessionIds] = useState<string[]>([]); |
| const [navigationState, setNavigationState] = useState(() => readNavigationState()); |
| const navSelection = navigationState.selection; |
| const setNavSelection = useCallback<Dispatch<SetStateAction<NavSelection>>>((nextSelection) => { |
| setNavigationState((current) => selectNavigation( |
| current, |
| typeof nextSelection === 'function' ? nextSelection(current.selection) : nextSelection, |
| )); |
| }, []); |
| const navSelectionRef = useRef<NavSelection>(navSelection); |
| // #1985: the shell's complete read of session UI state. See the hook for why |
| // the two token-rate maps are absent. |
| const { |
| messageLoadErrorBySession, |
| messageRetryPendingBySession, |
| stopPendingBySession, |
| interactionBySession, |
| pendingPermissionModeBySession, |
| pendingSessionModelBySession, |
| streamingSessionIds, |
| activeLiveTurnSnapshot, |
| } = useAppShellSessionUiReads(sessionUiController, activeId); |
| // PR-MEMORY-VISIBILITY-INDICATOR-0: session-context memory state (MEMORY.md |
| // injected into the system prompt). State and the fire-and-forget refresh |
| // live in `useShellMemoryPill`; recompute is triggered on mount (bootstrap |
| // subscriptions) and when Settings closes (closeSettings). |
| const { memoryActive, refreshMemoryActive } = useShellMemoryPill({ toastApi, uiLocale }); |
| const { |
| connections, |
| defaultConnection, |
| setConnections, |
| setDefaultConnection, |
| refreshConnections, |
| handleConnectionEvent, |
| } = useShellConnections({ toastApi, uiLocale }); |
| const onboarding = useOnboardingSnapshot(initialOnboardingSnapshot); |
| const onboardingState = onboarding.snapshot?.state; |
| const onboardingSettled = hasSettledInitialOnboarding(onboarding.snapshot?.milestones ?? []); |
| const onboardingActivationCandidate = getOnboardingActivationCandidate( |
| onboarding.snapshot, |
| sessions.length > 0, |
| ); |
| const { |
| settingsOpen, |
| settingsRequestedSection, |
| settingsProviderCatalogOpen, |
| settingsConnectionDetailSlug, |
| settingsCreateProviderType, |
| setSettingsOpen, |
| setSettingsProviderCatalogOpen, |
| openSettings, |
| openSettingsSection, |
| openProviderCatalog, |
| openConnectionDetail, |
| openProviderCreate, |
| } = useSettingsModal(); |
| const { |
| themePref, |
| setThemePref, |
| themePalette, |
| setThemePalette, |
| uiLocaleUpdateGate, |
| userLabel, |
| setUserLabel, |
| defaultPermissionMode, |
| defaultThinkingLevel, |
| setDefaultPermissionMode, |
| refreshShellSettings, |
| } = useShellAppearance({ |
| toastApi, |
| uiLocale, |
| setUiLocaleOverride, |
| setUiLocalePreference, |
| }); |
| const shellCopy = getShellCopy(uiLocale).app; |
| const desktopConversationCopy = getDesktopConversationCopy(uiLocale); |
| const terminalPanelCopy = desktopConversationCopy.terminalPanel; |
| const workbarCopy = desktopConversationCopy.workbar; |
| useEffect(() => { |
| if (!isAppUpdateInstallFailure(appUpdateStatus)) { |
| notifiedInstallErrorRef.current = null; |
| return; |
| } |
| if (notifiedInstallErrorRef.current === appUpdateStatus.message) return; |
| notifiedInstallErrorRef.current = appUpdateStatus.message; |
| toastApi.error( |
| shellCopy.updateInstallFailedTitle, |
| shellCopy.updateInstallManualFallback, |
| ); |
| }, [appUpdateStatus, shellCopy, toastApi]); |
| useEffect(() => { |
| let cancelled = false; |
| let receivedPush = false; |
| const unsubscribeUpdateStatus = window.maka.app.subscribeUpdateStatus((next) => { |
| receivedPush = true; |
| if (!cancelled) setAppUpdateStatus(next); |
| }); |
| void window.maka.app |
| .updateStatus() |
| .then((next) => { |
| if (!cancelled && !receivedPush) setAppUpdateStatus(next); |
| }) |
| .catch(() => {}); |
| return () => { |
| cancelled = true; |
| unsubscribeUpdateStatus(); |
| }; |
| }, []); |
| |
| const updateReminder = updateReminderFromStatus(appUpdateStatus); |
| // Dispatches on the reminder, not on the raw status: the footer is this |
| // callback's only caller and it only renders for the two states above, so |
| // reading the status again here would be the same "who needs the user" list |
| // maintained twice. |
| const openUpdateDownload = useCallback(() => { |
| if (updateReminder?.state === 'downloaded') { |
| if (updateInstallInFlightRef.current) return; |
| updateInstallInFlightRef.current = true; |
| void requestDownloadedAppUpdate({ |
| installUpdate: (input) => window.maka.app.installUpdate(input), |
| confirmActiveTasks: () => toastApi.confirm({ |
| title: shellCopy.updateActiveTasksTitle, |
| description: shellCopy.updateActiveTasksDescription, |
| confirmLabel: shellCopy.updateActiveTasksConfirm, |
| cancelLabel: shellCopy.updateActiveTasksCancel, |
| destructive: true, |
| }), |
| }) |
| .then((outcome) => { |
| if (outcome.kind !== 'failed') return; |
| if (outcome.reason === 'install_failed') return; |
| toastApi.error( |
| shellCopy.updateInstallFailedTitle, |
| shellCopy.updateInstallManualFallback, |
| ); |
| }) |
| .catch((error) => { |
| toastApi.error( |
| shellCopy.updateInstallFailedTitle, |
| localizedShellErrorMessage(error, shellCopy.updateInstallFailedFallback, uiLocale), |
| ); |
| }) |
| .finally(() => { |
| updateInstallInFlightRef.current = false; |
| }); |
| return; |
| } |
| if (!updateReminder) return; |
| void window.maka.app |
| .retryUpdateDownload() |
| .then((next) => { |
| if (next.state !== 'error') return; |
| toastApi.error( |
| shellCopy.updateRetryFailedTitle, |
| shellCopy.updateRetryFailedFallback, |
| ); |
| }) |
| .catch((error) => { |
| toastApi.error( |
| shellCopy.updateRetryFailedTitle, |
| localizedShellErrorMessage(error, shellCopy.updateRetryFailedFallback, uiLocale), |
| ); |
| }); |
| }, [updateReminder, shellCopy, toastApi, uiLocale]); |
| const moduleHubCopy = getSharedUiCopy(uiLocale).moduleHubs; |
| const extensionsHubHeader = { |
| title: moduleHubCopy.extensions.title, |
| subtitle: moduleHubCopy.extensions.description, |
| badge: ( |
| <ModuleHubSelector |
| hub="extensions" |
| value={navSelection.section === 'extensions' ? navSelection.module : navigationState.moduleMemory.extensions} |
| onChange={(module) => setNavSelection({ section: 'extensions', module })} |
| /> |
| ), |
| }; |
| const automationsHubHeader = { |
| title: moduleHubCopy.automations.title, |
| subtitle: moduleHubCopy.automations.description, |
| badge: ( |
| <ModuleHubSelector |
| hub="automations" |
| value={navSelection.section === 'automations' ? navSelection.module : navigationState.moduleMemory.automations} |
| onChange={(module) => setNavSelection({ section: 'automations', module })} |
| /> |
| ), |
| }; |
| // Persisted composer defaults seed the empty-state model, project path, and |
| // recent workspace history so the home view is populated before the async |
| // `app:info` round-trip completes on mount. |
| const persistedComposerDefaults = loadComposerDefaults(); |
| const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); |
| const [paletteOpen, openPalette, closePalette] = useCommandPalette(); |
| const [externalImportOpen, setExternalImportOpen] = useState(false); |
| const [viewMode, setViewMode] = useState<SessionViewMode>('conversation'); |
| const composerRef = useRef<ComposerHandle>(null); |
| // The rail's toggle has to reach Astryx's resizable state, not just this |
| // boolean — see the prop's note on SessionListPanel. The sidenav is mounted |
| // for the whole shell, so the handle is always live by the time it is called. |
| const sessionSideNavHandleRef = useRef<SideNavImperativeCollapseHandle | null>(null); |
| // Codex-style side conversations: each workbar tab owns a separate transient |
| // fork, composer draft, quote queue, and cleanup lifecycle. |
| const sideConversations = useSideConversationWorkspace(); |
| const quotePanels = sideConversations.panels; |
| const sideChatContentPanelIds = sideConversations.contentPanelIds; |
| const sideChatPreparingPanelIds = sideConversations.preparingPanelIds; |
| const sideChatActivePanelIds = sideConversations.activePanelIds; |
| const [pendingSideChatClose, setPendingSideChatClose] = |
| useState<Array<{ placement: SessionWorkbarPlacement; tab: SessionWorkbarTab }>>( |
| [], |
| ); |
| const [skipSideChatCloseConfirmation, setSkipSideChatCloseConfirmation] = |
| useState( |
| () => safeLocalStorageGet(SKIP_SIDE_CHAT_CLOSE_CONFIRMATION_KEY) === 'true', |
| ); |
| // Created companion forks stay hidden until authoritative cleanup succeeds or |
| // a later authoritative session list confirms they are gone. A set preserves |
| // earlier failed cleanups when another companion opens. |
| const [hiddenCompanionForkIds, setHiddenCompanionForkIds] = useState<ReadonlySet<string>>( |
| () => new Set(), |
| ); |
| const companionRecoveryStartedRef = useRef(false); |
| useLayoutEffect(() => { |
| if (companionRecoveryStartedRef.current) return; |
| companionRecoveryStartedRef.current = true; |
| void recoverOrphanedCompanionCopies(window.maka.sessions); |
| }, []); |
| const onCompanionForkVisibilityChange = useCallback( |
| (event: Parameters<typeof applyCompanionForkVisibilityEvent>[1]) => |
| setHiddenCompanionForkIds((current) => |
| applyCompanionForkVisibilityEvent(current, event), |
| ), |
| [], |
| ); |
| useEffect(() => { |
| if (!authoritativeSessionIds) return; |
| setHiddenCompanionForkIds((current) => |
| reconcileCompanionForkVisibility(current, authoritativeSessionIds), |
| ); |
| }, [authoritativeSessionIds]); |
| const [revisionDraft, setRevisionDraft] = useState<TurnRevisionDraft | null>(null); |
| const revisionDraftRef = useRef<TurnRevisionDraft | null>(null); |
| const commitRevisionDraft = useCallback((draft: TurnRevisionDraft | null) => { |
| revisionDraftRef.current = draft; |
| setRevisionDraft(draft); |
| }, []); |
| useEffect(() => { |
| const draft = revisionDraftRef.current; |
| if (!draft) return; |
| const source = sessions.find((session) => session.id === draft.sourceSessionId); |
| const owner = sessions.find((session) => session.id === draft.draftSessionId); |
| if (source && owner && !source.isArchived && !owner.isArchived) return; |
| composerRef.current?.clearDraft(draft.draftSessionId); |
| if (draft.sourceSessionId !== draft.draftSessionId) { |
| composerRef.current?.clearDraft(draft.sourceSessionId); |
| } |
| if (draft.copyPhase === 'reserved') completeTurnRevisionCopyAttempt(draft); |
| else void abandonTurnRevisionCopyAttempt(draft); |
| commitRevisionDraft(null); |
| }, [sessions, commitRevisionDraft]); |
| |
| const { |
| resumePendingSessionId, |
| resumeParkDescriptionBySession, |
| resumeInterruptedSession, |
| } = useShellResume({ activeId, toastApi, shellCopy, uiLocale }); |
| const rendererMountedRef = useRef(true); |
| // Active autonomous goal for the current session drives the header |
| // kill-switch pill (visible indicator + one-click clear). |
| const activeGoal = useSessionGoal(activeId); |
| // Set of session ids whose backend / connection is no longer usable — |
| // drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat |
| // header banner). Derivation is pure (see `stale-sessions.ts`) so the |
| // classifier is testable without a DOM. |
| const staleSessionIds = useMemo( |
| () => |
| deriveStaleSessionIds({ |
| sessions, |
| knownConnectionSlugs: new Set(connections.map((connection) => connection.slug)), |
| }), |
| [sessions, connections], |
| ); |
| // Status-grouped sidebar. The `chats` |
| // filter shows sessions grouped by SessionStatus (Pinned → |
| // Running → Waiting → Blocked → Active → Review → Done → Archived); |
| // `aborted` is dropped. Pinned (flagged) sessions float to the top |
| // in their own group, preserving the PR48 pin-floats behavior. |
| // One projection owns rail membership, active highlight, and titlebar parent: |
| // revision-tree roots (orphans stay; linked children with a live parent leave), |
| // flat nav / companion filter — never promote children past ancestors. |
| const { |
| sessions: visibleSessions, |
| activeRowId: sidebarActiveId, |
| activeParentSession: railParentSession, |
| } = useMemo( |
| () => |
| deriveSessionRail(sessions, activeId, (session) => |
| !hiddenCompanionForkIds.has(session.id) |
| ? sessionMatchesNavSelection(session, navSelection) |
| : false, |
| ), |
| [sessions, activeId, navSelection, hiddenCompanionForkIds], |
| ); |
| // PR-DAILY-REVIEW-MVP-0: bridge for the main Daily Review module. |
| // Memoized so the panel's `useEffect` cleanup keys |
| // off a stable reference instead of refetching on every render. |
| const dailyReviewBridge = useMemo(() => createAppShellDailyReviewBridge(uiLocale), [uiLocale]); |
| const { |
| appendDailyReviewMarkdown, |
| copyDailyReviewMarkdown, |
| saveDailyReviewMarkdown, |
| } = useStableActions(createAppShellDailyReviewActions, { |
| uiLocale, |
| composerRef, |
| toastApi, |
| }); |
| const activeInteraction = activeInteractionFor(interactionBySession, activeId); |
| const activeSandboxBoundary = |
| activeInteraction?.type === 'sandbox_boundary_request' ? activeInteraction : undefined; |
| const activeQuestion = activeInteraction?.type === 'user_question_request' ? activeInteraction : undefined; |
| const activeSession = sessions.find((session) => session.id === activeId); |
| // The shell's reading of the active live turn: streaming/settled flags, the |
| // in-flight tool signal, and the #646 turn-wait cues, all derived from the |
| // semantic snapshot rather than the projection (#1985). |
| const { |
| activeStreamingLive, |
| activeStreamingMessageId, |
| hasInFlightLiveTools, |
| hasLiveTurnContent, |
| turnActive, |
| showRunningStatus, |
| showProcessingIndicator, |
| showContinuingIndicator, |
| } = useShellLiveTurn({ |
| liveTurn: activeLiveTurnSnapshot, |
| activeSession, |
| }); |
| const petActivityState = derivePetActivityState({ |
| hasActiveSession: activeSession !== undefined, |
| hasActiveInteraction: activeInteraction !== undefined, |
| turnActive, |
| sessionStatus: activeSession?.status, |
| }); |
| // Surface a credential-lifecycle alert directly in the chat header when |
| // the active session's connection is in `needs_reauth` / `error` or has |
| // been deleted entirely with no usable default. Main resolves credential |
| // presence into the onboarding snapshot; a connection event starts an async |
| // snapshot pull, so the notice keeps the previous outcome only until that |
| // pull completes. Model / thinking selection + the hard-only health notice |
| // live in useShellChatModel (pure derivation of the snapshot + active session); |
| // openSettingsSection is injected so the notice can wrap the derived click |
| // target. |
| const { |
| chatModelChoices, |
| activeConnection, |
| activeConnectionLabel, |
| activeModel, |
| activeModelLabel, |
| activeThinkingLevels, |
| activeThinkingLevel, |
| newChatModel, |
| newChatModelLabel, |
| newChatThinkingLevels, |
| newChatThinkingLevel, |
| setPendingNewChatModel, |
| pendingNewChatThinkingLevel, |
| setPendingNewChatThinkingLevel, |
| sessionHealthNotice, |
| } = useShellChatModel({ |
| uiLocale, |
| connections, |
| snapshotChoices: onboarding.snapshot?.chatModelChoices, |
| sessionSendOutcome: activeSession |
| ? onboarding.snapshot?.sessionSendOutcomes[activeSession.id] |
| : undefined, |
| defaultConnection, |
| activationCandidate: onboardingActivationCandidate, |
| activeSession, |
| persistedComposerDefaults, |
| defaultThinkingLevel, |
| openSettingsSection, |
| }); |
| const newChatProviderType = newChatModel |
| ? connections.find((connection) => connection.slug === newChatModel.llmConnectionSlug)?.providerType |
| : undefined; |
| |
| // PR109d-b: turn footer actions per turn. Derived from the |
| // materialized turn list (status + lineage descendants) + pending |
| // mask. Per @kenji PR109d review: pending state prevents double-click |
| // duplicate sibling turns by disabling the action button between |
| // click and `sessions:changed turn-status-change` arriving. |
| // The four de-dup registries (turn-footer actions, session-row actions, |
| // per-session permission-mode / model changes) all share the same keyed-Set |
| // shape; see useKeyedPendingRegistry. Only the turn-footer registry mirrors |
| // into React state (drives the disabled mask) and arms a 5s auto-clear |
| // fallback timer; the other three stay ref-only and clear in their action's |
| // `finally`. |
| const turnActionRegistry = useKeyedPendingRegistry({ |
| trackState: true, |
| autoClearMs: 5000, |
| }); |
| const pendingTurnActions = turnActionRegistry.keys; |
| const sessionRowActionRegistry = useKeyedPendingRegistry(); |
| const permissionModeChangeRegistry = useKeyedPendingRegistry(); |
| const collaborationModeChangeRegistry = useKeyedPendingRegistry(); |
| const orchestrationModeChangeRegistry = useKeyedPendingRegistry(); |
| const sessionModelChangeRegistry = useKeyedPendingRegistry(); |
| const pendingKeyOf = (sessionId: string, turnId: string, actionId: string) => |
| `${sessionId}:${turnId}:${actionId}`; |
| function omitSessionKey<T>(current: Record<string, T>, sessionId: string): Record<string, T> { |
| if (!(sessionId in current)) return current; |
| const next = { ...current }; |
| delete next[sessionId]; |
| return next; |
| } |
| |
| function addPendingSessionAction( |
| sessionId: string, |
| pendingRef: { current: Set<string> }, |
| setPendingBySession: (updater: (current: Record<string, boolean>) => Record<string, boolean>) => void, |
| ): boolean { |
| if (pendingRef.current.has(sessionId)) return false; |
| pendingRef.current.add(sessionId); |
| setPendingBySession((current) => ({ ...current, [sessionId]: true })); |
| return true; |
| } |
| |
| function clearPendingSessionAction( |
| sessionId: string, |
| pendingRef: { current: Set<string> }, |
| setPendingBySession: (updater: (current: Record<string, boolean>) => Record<string, boolean>) => void, |
| ): void { |
| if (!pendingRef.current.has(sessionId)) return; |
| pendingRef.current.delete(sessionId); |
| setPendingBySession((current) => omitSessionKey(current, sessionId)); |
| } |
| |
| function clearSessionRendererState(sessionId: string): void { |
| clearOwnedSessionState(sessionId); |
| turnActionRegistry.clearForSession(sessionId); |
| permissionModeChangeRegistry.keysRef.current.delete(sessionId); |
| collaborationModeChangeRegistry.keysRef.current.delete(sessionId); |
| setPendingCollaborationModeBySession((current) => omitSessionKey(current, sessionId)); |
| orchestrationModeChangeRegistry.keysRef.current.delete(sessionId); |
| setPendingOrchestrationModeBySession((current) => omitSessionKey(current, sessionId)); |
| sessionModelChangeRegistry.keysRef.current.delete(sessionId); |
| } |
| |
| const sessionRowActionHandlers = useStableActions(createAppShellSessionRowActions, { |
| uiLocale, |
| activeIdRef, |
| clearSessionRendererState, |
| pendingSessionRowActionsRef: sessionRowActionRegistry.keysRef, |
| refreshSessions, |
| sessionsRef, |
| setActiveId, |
| setMessages, |
| toastApi, |
| }); |
| const sessionRowActions = useMemo<NonNullable<Parameters<typeof SessionListPanel>[0]['rowActions']>>( |
| () => ({ |
| onToggleFlag: (sessionId, next) => sessionRowActionHandlers.flagSession(sessionId, next), |
| onArchive: (sessionId) => sessionRowActionHandlers.archiveSession(sessionId), |
| onUnarchive: (sessionId) => sessionRowActionHandlers.unarchiveSession(sessionId), |
| onRename: (sessionId, name) => sessionRowActionHandlers.renameSession(sessionId, name), |
| onDelete: (sessionId) => sessionRowActionHandlers.deleteSession(sessionId), |
| }), |
| [], |
| ); |
| |
| const { |
| setPermissionMode, |
| setSessionModel, |
| setSessionThinkingLevel, |
| } = useStableActions(createAppShellSessionSettingsActions, { |
| uiLocale, |
| activeIdRef, |
| connections, |
| pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, |
| pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, |
| refreshSessions, |
| saveComposerDefaults, |
| sessionsRef, |
| setDefaultPermissionMode, |
| setPendingPermissionModeBySession, |
| setPendingSessionModelBySession, |
| setSessions, |
| toastApi, |
| }); |
| |
| async function setPlanMode(active: boolean): Promise<void> { |
| const sessionId = activeIdRef.current; |
| if (!sessionId) { |
| setNewChatPlanModeActive(active); |
| return; |
| } |
| if (!addPendingSessionAction( |
| sessionId, |
| collaborationModeChangeRegistry.keysRef, |
| setPendingCollaborationModeBySession, |
| )) return; |
| |
| try { |
| const planState = await window.maka.sessions.getPlanState(sessionId); |
| if (active && planState.activeExecutionId) { |
| toastApi.error( |
| shellCopy.planModeExecutionActiveTitle, |
| shellCopy.planModeExecutionActiveDescription, |
| ); |
| return; |
| } |
| const latestProposal = planState.proposals.find( |
| (proposal) => proposal.proposalId === planState.latestProposalId, |
| ); |
| if (!active && latestProposal?.status === 'pending_approval') { |
| const confirmed = await toastApi.confirm({ |
| title: shellCopy.planModeExitPendingTitle, |
| description: shellCopy.planModeExitPendingDescription(latestProposal.title), |
| confirmLabel: shellCopy.planModeExitConfirm, |
| cancelLabel: shellCopy.planModeExitCancel, |
| destructive: true, |
| }); |
| if (!confirmed) return; |
| await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); |
| setSessions((current) => current.map((session) => ( |
| session.id === sessionId ? { ...session, collaborationMode: 'agent' } : session |
| ))); |
| } else { |
| const next = await window.maka.sessions.setCollaborationMode( |
| sessionId, |
| active ? 'plan' : 'agent', |
| ); |
| setSessions((current) => current.map((session) => session.id === next.id ? next : session)); |
| } |
| await refreshSessions(); |
| } catch (error) { |
| if (activeIdRef.current === sessionId) { |
| toastApi.error( |
| shellCopy.planModeFailedTitle, |
| localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), |
| ); |
| } |
| } finally { |
| clearPendingSessionAction( |
| sessionId, |
| collaborationModeChangeRegistry.keysRef, |
| setPendingCollaborationModeBySession, |
| ); |
| } |
| } |
| |
| async function setSwarmMode(active: boolean): Promise<boolean> { |
| const sessionId = activeIdRef.current; |
| if (!sessionId) { |
| setNewChatSwarmModeActive(active); |
| if (active) setNewChatGraphModeActive(false); |
| return true; |
| } |
| if (!addPendingSessionAction( |
| sessionId, |
| orchestrationModeChangeRegistry.keysRef, |
| setPendingOrchestrationModeBySession, |
| )) return false; |
| |
| try { |
| const next = await window.maka.sessions.setOrchestrationMode( |
| sessionId, |
| active ? 'swarm' : 'default', |
| ); |
| setSessions((current) => current.map((session) => session.id === next.id ? next : session)); |
| await refreshSessions(); |
| return true; |
| } catch (error) { |
| if (activeIdRef.current === sessionId) { |
| toastApi.error( |
| shellCopy.swarmModeFailedTitle, |
| localizedShellErrorMessage(error, shellCopy.swarmModeFallback, uiLocale), |
| ); |
| } |
| return false; |
| } finally { |
| clearPendingSessionAction( |
| sessionId, |
| orchestrationModeChangeRegistry.keysRef, |
| setPendingOrchestrationModeBySession, |
| ); |
| } |
| } |
| |
| async function setGraphMode(active: boolean): Promise<boolean> { |
| const sessionId = activeIdRef.current; |
| if (!sessionId) { |
| setNewChatGraphModeActive(active); |
| if (active) setNewChatSwarmModeActive(false); |
| return true; |
| } |
| if (!addPendingSessionAction( |
| sessionId, |
| orchestrationModeChangeRegistry.keysRef, |
| setPendingOrchestrationModeBySession, |
| )) return false; |
| |
| try { |
| const next = await window.maka.sessions.setOrchestrationMode( |
| sessionId, |
| active ? 'graph' : 'default', |
| ); |
| setSessions((current) => current.map((session) => session.id === next.id ? next : session)); |
| await refreshSessions(); |
| return true; |
| } catch (error) { |
| if (activeIdRef.current === sessionId) { |
| toastApi.error( |
| shellCopy.graphModeFailedTitle, |
| localizedShellErrorMessage(error, shellCopy.graphModeFallback, uiLocale), |
| ); |
| } |
| return false; |
| } finally { |
| clearPendingSessionAction( |
| sessionId, |
| orchestrationModeChangeRegistry.keysRef, |
| setPendingOrchestrationModeBySession, |
| ); |
| } |
| } |
| |
| // Handed to ChatView, which calls it with the turns its transcript projection |
| // produced. The shell no longer materializes the transcript a second time to |
| // derive these props, so the turn objects the projection kept are also what |
| // keeps the props a memoized TurnView reads stable (#2030). |
| const deriveTurnPresentation = useAppShellTurnPresentation({ |
| activeId, |
| pendingTurnActions, |
| pendingKeyOf, |
| uiLocale, |
| }); |
| |
| // PR109e-e: click handler for lineage badge → scroll target turn into |
| // view. Avoids pulling a separate ref-tracker: relies on the |
| // `data-turn-id` attribute the renderer already sets on each TurnView. |
| // |
| // @kenji PR109e review + @xuan PR109f follow-up: scrollIntoView with |
| // `behavior: 'smooth'` must respect both reduced-motion AND the |
| // e2e-fixture capture entry (PR-IR-02). @xuan confirmed on main that |
| // e2e-fixture always writes `data-maka-e2e-fixture="true"` but |
| // `data-maka-reduced-motion="true"` is only set on the reduced |
| // variant — so the e2e-fixture attribute is the broader signal for |
| // "deterministic capture, no animations". Three triggers collapse to |
| // `auto`: |
| // 1. `data-maka-reduced-motion="true"` — PR-IR-04 reduced variant |
| // 2. `data-maka-e2e-fixture="true"` — PR-IR-02 any capture |
| // 3. `prefers-reduced-motion: reduce` — OS-level user preference |
| function handleLineageBadgeClick(targetTurnId: string): void { |
| requestAnimationFrame(() => { |
| const el = document.querySelector(`[data-turn-id="${CSS.escape(targetTurnId)}"]`); |
| if (!el || !('scrollIntoView' in el)) return; |
| (el as HTMLElement).scrollIntoView({ |
| behavior: readScrollMotionBehavior(), |
| block: 'center', |
| }); |
| }); |
| } |
| |
| function openSessionInChat(sessionId: string, turnId?: string): void { |
| setNavSelection({ section: 'sessions', filter: 'chats' }); |
| setActiveId(sessionId); |
| if (turnId) { |
| setSearchScrollTarget({ sessionId, turnId, nonce: Date.now() }); |
| } else { |
| setSearchScrollTarget(null); |
| } |
| } |
| |
| /* PR-FE-BUG-HUNT-0 (kenji bug-hunt 2026-06-24): SearchModal + |
| CommandPalette callbacks used to be inline arrows in JSX, so |
| their identity churned on every App re-render. SearchModal's |
| debounce effect lists `searchThread` in its dep array; during a |
| turn stream `App` re-renders many times per second and the |
| 180ms timeout was torn down + restarted on every render, so it |
| never reached its `setTimeout` fire — search was effectively |
| dead while a stream was active. Same root cause for the palette |
| selection effect that resets keyboard highlight on every deps |
| change. Stable refs + memos keep the timers alive. */ |
| const openSessionInChatRef = useRef(openSessionInChat); |
| openSessionInChatRef.current = openSessionInChat; |
| const { |
| searchModalOpen, |
| setSearchModalOpen, |
| searchScrollTarget, |
| setSearchScrollTarget, |
| closeSearchModal, |
| searchModalDeps, |
| searchModalOnNavigate, |
| } = useShellSearch({ openSessionInChatRef }); |
| /** 技能页 使用: jump to the chat view and seed the composer with a skill |
| * invocation. Same human-in-the-loop rule as maka://compose — we never |
| * auto-send; the user finishes the sentence and presses Enter. |
| * U4: append (not replace) so an in-progress draft survives — appendText |
| * falls back to a plain set when the draft is empty, so the empty-composer |
| * path is unchanged while a half-written message is no longer clobbered. */ |
| const useSkillInChat = useCallback( |
| (_skillId: string, skillName: string) => { |
| setNavSelection({ section: 'sessions', filter: 'chats' }); |
| const seed = () => { |
| composerRef.current?.appendText(shellCopy.useSkillPrompt(skillName)); |
| composerRef.current?.focus(); |
| }; |
| if (activeIdRef.current) { |
| window.requestAnimationFrame(seed); |
| return; |
| } |
| void createSession().then(() => window.requestAnimationFrame(seed)); |
| }, |
| [shellCopy], |
| ); |
| const sessionListSelectSession = useCallback((sessionId: string) => { |
| openSessionInChatRef.current(sessionId); |
| }, []); |
| |
| // PR109f: branched session context. When the active session was |
| // created via `sessions:branchFromTurn`, its `parentSessionId` is |
| // set; render a banner above the chat surface so the user knows |
| // they're in a derived conversation and can jump back to the parent. |
| // |
| // v1 intentionally omits the fromAbortedTurn hint because checking |
| // it requires loading the parent's full message log. The session |
| // banner stays at "分自 ${parentName}" until parent-message |
| // preloading lands; "从中断前" is only surfaced in the aborted |
| // turn's branch footer tooltip where the active turn status is known. |
| const branchBanner = useMemo( |
| () => deriveBranchBanner(activeSession, sessions), |
| [activeSession?.parentSessionId, sessions], |
| ); |
| const revisionNavigation = useMemo( |
| () => deriveSessionRevisionNavigation(sessions, activeId), |
| [sessions, activeId], |
| ); |
| |
| function handleBranchBannerClick(parentSessionId: string): void { |
| openSessionInChat(parentSessionId); |
| } |
| |
| // Same revision-aware parent row the rail uses — not a second physical-id walk. |
| const titlebarParentSession = useMemo(() => { |
| if (!railParentSession) return undefined; |
| const parentId = railParentSession.id; |
| return { |
| name: railParentSession.name, |
| onOpen: () => openSessionInChatRef.current(parentId), |
| }; |
| }, [railParentSession]); |
| |
| const activeSessionForView: SessionSummary | undefined = |
| activeSession ?? |
| (activeId |
| ? { |
| id: activeId, |
| name: shellCopy.newConversation, |
| isFlagged: false, |
| isArchived: false, |
| labels: [], |
| hasUnread: false, |
| status: 'active', |
| backend: 'fake', |
| llmConnectionSlug: 'default', |
| connectionLocked: false, |
| model: 'fake-model', |
| // Transient placeholder while the real SessionSummary loads -- |
| // matches the configured default so the composer doesn't flash a |
| // hardcoded value before the real session data settles. |
| permissionMode: defaultPermissionMode, |
| } |
| : undefined); |
| const { |
| boundary: activeExecutionBoundary, |
| unreadable: activeExecutionBoundaryUnreadable, |
| reading: activeExecutionBoundaryReading, |
| reload: reloadActiveExecutionBoundary, |
| } = useActiveExecutionBoundary(activeId, activeSessionForView?.permissionMode); |
| // The session view only subscribes to the session it shows, so a request |
| // raised while another session was active never reaches this surface as a |
| // live event — and neither does one raised before the window existed. The |
| // runtime holds every unanswered request, so read them back whenever the |
| // active session changes (#2072). |
| useEffect(() => { |
| if (!activeId) return; |
| let cancelled = false; |
| const hydrationEpoch = interactionHydrationEpochRef.current.get(activeId) ?? 0; |
| void window.maka.sessions |
| .listActiveInteractions(activeId) |
| .then((requests) => { |
| if ( |
| cancelled || |
| (interactionHydrationEpochRef.current.get(activeId) ?? 0) !== hydrationEpoch |
| ) { |
| return; |
| } |
| setInteractionBySession((current) => reconcileInteractions(current, activeId, requests)); |
| }) |
| .catch(() => {}); |
| return () => { |
| cancelled = true; |
| }; |
| }, [activeId, setInteractionBySession]); |
| const activeBoundarySurface = deriveDesktopExecutionBoundarySurface( |
| activeId, |
| activeExecutionBoundary, |
| activeId ? (activeSessionForView?.permissionMode ?? 'ask') : defaultPermissionMode, |
| ); |
| const activePermissionMode = activeBoundarySurface.permissionMode; |
| const planMode = usePlanModeState(activeSessionForView); |
| const planConversationItems = (planMode.state?.proposals ?? []).map((proposal) => ({ |
| id: proposal.proposalId, |
| afterTurnId: proposal.turnId, |
| content: <PlanProposalCard proposal={proposal} planMode={planMode} />, |
| })); |
| const activeMessageLoading = Boolean(activeId && messageLoadPending); |
| // PR110c: OnboardingState is now the single source of truth for |
| // first-run UI. The renderer never re-derives provider readiness; |
| // `useOnboardingSnapshot()` pulls the derived state from the main |
| // process (PR110a + PR110b contract) and reactively invalidates on |
| // `sessions:changed` + `connections:event`. The hero renders only |
| // when sessions.length === 0; any session (including archived / |
| // aborted) takes over with the existing chat surface. |
| // Re-entrancy lock only — a ref, not state, because nothing renders |
| // from it (#1433 removed its last reader with the first-run hero). |
| const sessionStartPendingRef = useRef(false); |
| // Seed sessions from the onboarding snapshot on first load — the snapshot |
| // already fetches the session list + connections internally, so separate |
| // `sessions:list` / `connections:list` / `getDefault` IPCs are redundant. |
| // This lets the UI show the sidebar + model picker immediately on first load. |
| const initialSnapshotSeededRef = useRef(false); |
| const mountedSnapshotSeededRef = useRef(false); |
| const bootstrapFallbackStartedRef = useRef(false); |
| // useLayoutEffect, NOT useEffect: the snapshot render flips |
| // `isOnboardingLoading` off while `sessions` is still []. A passive |
| // effect seeds sessions AFTER the browser paints that frame, so users |
| // with history saw a one-frame flash of the empty-state hero (the |
| // "配置页闪了一下" startup flash). Layout effects run before paint, |
| // so the seeded sessions and the un-gated frame commit together. |
| useLayoutEffect(() => { |
| // Snapshot IPC failed — the seed path will never run, so fall back |
| // to the classic boot pull or the sidebar stays empty forever. |
| if ( |
| onboarding.error && |
| !initialOnboardingSnapshot && |
| !onboarding.mountedSnapshotHandoff && |
| !bootstrapFallbackStartedRef.current |
| ) { |
| bootstrapFallbackStartedRef.current = true; |
| void bootstrapSessions(); |
| void refreshConnections(); |
| return; |
| } |
| let snapshot: OnboardingSnapshot | null = null; |
| let releaseSelectionLease = false; |
| if (!initialSnapshotSeededRef.current && initialOnboardingSnapshot) { |
| initialSnapshotSeededRef.current = true; |
| snapshot = initialOnboardingSnapshot; |
| } else if ( |
| !bootstrapFallbackStartedRef.current && |
| !mountedSnapshotSeededRef.current && |
| onboarding.mountedSnapshotHandoff |
| ) { |
| mountedSnapshotSeededRef.current = true; |
| snapshot = onboarding.mountedSnapshotHandoff; |
| releaseSelectionLease = true; |
| } |
| if (!snapshot) return; |
| // Seed sessions. Display normalization MUST run here too — this is |
| // a third renderer state entry alongside commitSessions / |
| // upsertSessionSummary (#452): without it, legacy blocked/unknown |
| // sessions flash an 已阻塞 group on first paint until the first |
| // refreshSessions() overwrites the seed. |
| const next = seedSessions(snapshot.sessions); |
| bootstrapSelectionLease.reconcile(collapseSessionRevisions(next)); |
| // Seed connections — avoids separate connections:list + getDefault IPCs |
| setConnections(snapshot.connections); |
| setDefaultConnection(snapshot.defaultSlug); |
| if (releaseSelectionLease) bootstrapSelectionLease.release(); |
| }, [initialOnboardingSnapshot, onboarding.mountedSnapshotHandoff, onboarding.error]); |
| // PR110c (@kenji review): suppress hero AND the fallback EmptyChatHero |
| // while the initial snapshot is in flight. Otherwise sessions.length===0 |
| // + snapshot===null flashes the prompt-suggestion EmptyChatHero before |
| // the state-routed OnboardingHero mounts. |
| const isOnboardingLoading = sessions.length === 0 && onboardingState === undefined && !onboardingSettled; |
| // Only unfinished setup takes the chat surface over. A configured user with |
| // no sessions is not onboarding: they land on the normal empty chat and use |
| // the one real Composer, which creates the session on its first send. |
| const showOnboardingHero = |
| sessions.length === 0 && |
| !onboardingSettled && |
| onboardingState !== undefined && |
| onboardingState.kind !== 'ready_with_history' && |
| onboardingState.kind !== 'ready_empty'; |
| const workspaceReadinessRecovery = deriveWorkspaceReadinessRecovery({ |
| state: onboardingState, |
| locale: uiLocale, |
| activeSessionId: activeId, |
| showOnboardingHero, |
| }); |
| const onboardingComposerHidden = isOnboardingLoading || (showOnboardingHero && onboardingState !== undefined); |
| // #1629: hiding the composer because the boundary is unknown is right, but |
| // hiding it silently and forever is not. Once the read has spent its retries |
| // the slot says so and hands the user another attempt; while it is still |
| // reading, or while onboarding owns the surface, there is nothing to say. |
| const boundaryUnreadableNotice = |
| activeId && activeExecutionBoundaryUnreadable && !onboardingComposerHidden |
| ? { |
| title: shellCopy.boundaryUnreadableTitle, |
| detail: shellCopy.boundaryUnreadableDetail, |
| retryLabel: shellCopy.boundaryUnreadableRetry, |
| retryPendingLabel: shellCopy.boundaryUnreadableRetrying, |
| retryPending: activeExecutionBoundaryReading, |
| onRetry: () => reloadActiveExecutionBoundary(activeId), |
| } |
| : undefined; |
| const { |
| sessionListWidth, |
| setSessionListWidth, |
| sessionListCollapsed, |
| setSessionListCollapsed, |
| workbarCollapsed, |
| setWorkbarCollapsed, |
| bottomPanelOpen, |
| setBottomPanelOpen, |
| workbarWidth, |
| workbarResizable, |
| bottomPanelHeight, |
| bottomPanelResizable, |
| workbarPanelsState, |
| openWorkbarTab, |
| openDynamicWorkbarTab, |
| activateWorkbarTab, |
| closeWorkbarTab, |
| closeWorkbarTabs, |
| reorderWorkbarTab, |
| moveWorkbarTab, |
| moveWorkbarTabToPanel, |
| titleWorkbarTab, |
| pinWorkbarTab, |
| openWorkbarLauncher, |
| } = useShellLayout(); |
| const revealWorkbarLauncher = useCallback(() => { |
| setWorkbarCollapsed(false); |
| openWorkbarLauncher('right'); |
| }, [openWorkbarLauncher, setWorkbarCollapsed]); |
| |
| const openNewSideConversation = useCallback( |
| (placement: SessionWorkbarPlacement, initialPrompt?: string) => { |
| const sourceSessionId = activeIdRef.current; |
| if (!sourceSessionId) return; |
| const panel = openCompanionPanel(null, { |
| sourceSessionId, |
| initialPrompt, |
| newId: () => crypto.randomUUID(), |
| }); |
| sideConversations.upsertPanel(panel, true); |
| openDynamicWorkbarTab( |
| { |
| id: `side-chat:${panel.id}`, |
| kind: 'side-chat', |
| title: sideChatTitleFromPrompt(initialPrompt ?? ''), |
| ordinal: nextSideChatOrdinal([ |
| ...workbarPanelsState.right.tabs, |
| ...workbarPanelsState.bottom.tabs, |
| ]), |
| }, |
| placement, |
| ); |
| if (placement === 'right') setWorkbarCollapsed(false); |
| else setBottomPanelOpen(true); |
| }, |
| [ |
| openDynamicWorkbarTab, |
| setBottomPanelOpen, |
| setWorkbarCollapsed, |
| sideConversations, |
| workbarPanelsState, |
| ], |
| ); |
| const openSideConversation = useCallback( |
| (initialPrompt?: string) => openNewSideConversation('right', initialPrompt), |
| [openNewSideConversation], |
| ); |
| const openSideConversationRef = useRef(openSideConversation); |
| openSideConversationRef.current = openSideConversation; |
| const sideChatSlashCommands = useMemo< |
| readonly ComposerSlashCommandOption[] |
| >( |
| () => |
| activeId |
| ? [ |
| { |
| id: 'side', |
| name: workbarCopy.sideChat, |
| description: workbarCopy.launcher.sideChat, |
| keywords: ['side', 'btw', '侧聊', '追问'], |
| Icon: MessageCircleQuestion, |
| onSelect: () => openSideConversationRef.current(), |
| }, |
| ] |
| : [], |
| [activeId, workbarCopy], |
| ); |
| const openSideConversationWithQuote = useCallback( |
| (quote: QuoteRef) => { |
| if (!activeId) return; |
| const activeSideChat = findPreferredSideChatWorkbarTab(workbarPanelsState); |
| const activeSideChatTab = activeSideChat?.tab; |
| const activePanelId = activeSideChatTab?.id.slice('side-chat:'.length); |
| const activePanel = quotePanels.find( |
| (panel) => |
| panel.id === activePanelId && panel.sourceSessionId === activeId, |
| ); |
| const panel = stageCompanionQuote( |
| activePanel ?? null, |
| { |
| sourceSessionId: activeId, |
| quote, |
| newId: () => crypto.randomUUID(), |
| }, |
| ); |
| sideConversations.upsertPanel(panel, !activePanel); |
| const targetPlacement = |
| activeSideChat?.placement ?? 'right'; |
| openDynamicWorkbarTab( |
| { |
| id: `side-chat:${panel.id}`, |
| kind: 'side-chat', |
| ordinal: |
| activeSideChatTab?.ordinal ?? |
| nextSideChatOrdinal([ |
| ...workbarPanelsState.right.tabs, |
| ...workbarPanelsState.bottom.tabs, |
| ]), |
| }, |
| targetPlacement, |
| ); |
| if (targetPlacement === 'right') setWorkbarCollapsed(false); |
| else setBottomPanelOpen(true); |
| }, |
| [ |
| activeId, |
| openDynamicWorkbarTab, |
| quotePanels, |
| setBottomPanelOpen, |
| setWorkbarCollapsed, |
| sideConversations, |
| workbarPanelsState, |
| ], |
| ); |
| |
| const requestOpenWorkbarTab = useCallback( |
| (placement: SessionWorkbarPlacement, kind: SessionWorkbarTabKind) => { |
| if (kind === 'terminal') { |
| if (!activeId) return; |
| const ownerSessionId = activeId; |
| const tabs = [ |
| ...workbarPanelsState.right.tabs, |
| ...workbarPanelsState.bottom.tabs, |
| ]; |
| void window.maka.shellRuns |
| .start(ownerSessionId) |
| .then((update) => { |
| const ref = update.result.ref; |
| openDynamicWorkbarTab( |
| { |
| id: terminalSessionWorkbarTabId(ref), |
| kind: 'terminal', |
| ordinal: nextTerminalOrdinal(tabs), |
| resourceRef: ref, |
| ownerSessionId, |
| }, |
| placement, |
| ); |
| if (placement === 'right') setWorkbarCollapsed(false); |
| else setBottomPanelOpen(true); |
| }) |
| .catch((error) => { |
| toastApi.error( |
| terminalPanelCopy.startFailed, |
| localizedShellErrorMessage( |
| error, |
| terminalPanelCopy.startFailed, |
| uiLocale, |
| ), |
| ); |
| }); |
| return; |
| } |
| if (kind === 'side-chat') { |
| openNewSideConversation(placement); |
| return; |
| } |
| openWorkbarTab(kind, placement); |
| if (placement === 'right') setWorkbarCollapsed(false); |
| else setBottomPanelOpen(true); |
| }, |
| [ |
| activeId, |
| openDynamicWorkbarTab, |
| openNewSideConversation, |
| openWorkbarTab, |
| setBottomPanelOpen, |
| setWorkbarCollapsed, |
| terminalPanelCopy, |
| toastApi, |
| uiLocale, |
| workbarPanelsState, |
| ], |
| ); |
| |
| const closeWorkbarTabsImmediately = useCallback( |
| ( |
| placement: SessionWorkbarPlacement, |
| tabs: readonly SessionWorkbarTab[], |
| ) => { |
| if (tabs.length === 0) return; |
| for (const tab of tabs) { |
| const ref = terminalRefFromWorkbarTab(tab); |
| if (!ref || !tab.ownerSessionId) continue; |
| void window.maka.shellRuns |
| .stop({ sessionId: tab.ownerSessionId, ref }) |
| .catch(() => {}); |
| } |
| closeWorkbarTabs(placement, tabs.map((tab) => tab.id)); |
| const panelIds = new Set( |
| tabs |
| .filter((tab) => tab.kind === 'side-chat') |
| .map((tab) => tab.id.slice('side-chat:'.length)), |
| ); |
| if (panelIds.size === 0) return; |
| sideConversations.removePanels(panelIds); |
| }, |
| [closeWorkbarTabs, sideConversations], |
| ); |
| |
| useEffect(() => { |
| const stale = (['right', 'bottom'] as const).flatMap((placement) => |
| workbarPanelsState[placement].tabs |
| .filter( |
| (tab) => |
| tab.kind === 'terminal' && |
| tab.ownerSessionId !== activeId, |
| ) |
| .map((tab) => ({ placement, tab })), |
| ); |
| for (const placement of ['right', 'bottom'] as const) { |
| const tabs = stale |
| .filter((candidate) => candidate.placement === placement) |
| .map((candidate) => candidate.tab); |
| closeWorkbarTabsImmediately(placement, tabs); |
| } |
| }, [activeId, closeWorkbarTabsImmediately, workbarPanelsState]); |
| |
| const closeWorkbarTabsAndSurfaces = useCallback( |
| ( |
| placement: SessionWorkbarPlacement, |
| tabs: readonly SessionWorkbarTab[], |
| ) => { |
| const closableTabs = tabs.filter( |
| (tab) => |
| tab.kind !== 'side-chat' || |
| !sideChatPreparingPanelIds.has(tab.id.slice('side-chat:'.length)), |
| ); |
| if (closableTabs.length === 0) return; |
| const needsConfirmation = |
| !skipSideChatCloseConfirmation && |
| closableTabs.some( |
| (tab) => |
| tab.kind === 'side-chat' && |
| sideChatContentPanelIds.has(tab.id.slice('side-chat:'.length)), |
| ); |
| if (needsConfirmation) { |
| setPendingSideChatClose( |
| closableTabs.map((tab) => ({ placement, tab })), |
| ); |
| return; |
| } |
| closeWorkbarTabsImmediately(placement, closableTabs); |
| }, |
| [ |
| closeWorkbarTabsImmediately, |
| sideChatContentPanelIds, |
| sideChatPreparingPanelIds, |
| skipSideChatCloseConfirmation, |
| ], |
| ); |
| |
| const closeWorkbarTabAndSurface = useCallback( |
| (placement: SessionWorkbarPlacement, tab: SessionWorkbarTab) => |
| closeWorkbarTabsAndSurfaces(placement, [tab]), |
| [closeWorkbarTabsAndSurfaces], |
| ); |
| |
| const toggleWorkbar = useCallback(() => { |
| if (workbarCollapsed) { |
| setWorkbarCollapsed(false); |
| if (workbarPanelsState.right.tabs.length === 0) { |
| openWorkbarLauncher('right'); |
| } |
| return; |
| } |
| setWorkbarCollapsed(true); |
| }, [ |
| openWorkbarLauncher, |
| setWorkbarCollapsed, |
| workbarCollapsed, |
| workbarPanelsState.right.tabs.length, |
| ]); |
| |
| // Side chats survive a panel collapse. They are cleaned up only when their |
| // tab closes or when navigation leaves the owning source session. |
| useEffect(() => { |
| const stalePanels = quotePanels.filter( |
| (panel) => panel.sourceSessionId !== activeId, |
| ); |
| if (stalePanels.length === 0) return; |
| const staleIds = new Set(stalePanels.map((panel) => panel.id)); |
| for (const panel of stalePanels) { |
| const tabId = `side-chat:${panel.id}`; |
| const placement = workbarPanelsState.right.tabs.some((tab) => tab.id === tabId) |
| ? 'right' |
| : 'bottom'; |
| closeWorkbarTab(placement, tabId); |
| } |
| sideConversations.removePanels(staleIds); |
| }, [quotePanels, activeId, closeWorkbarTab, sideConversations, workbarPanelsState]); |
| |
| function isAutomationsSurfaceActive(): boolean { |
| return navSelectionRef.current.section === 'automations' && navSelectionRef.current.module === 'plan-reminders'; |
| } |
| |
| function isSkillsSurfaceActive(): boolean { |
| return navSelectionRef.current.section === 'extensions' && navSelectionRef.current.module === 'skills'; |
| } |
| |
| function isDailyReviewSurfaceActive(): boolean { |
| return navSelectionRef.current.section === 'automations' && navSelectionRef.current.module === 'daily-review'; |
| } |
| |
| const { |
| skills, |
| managedSkillSources, |
| bundledSkillCatalog, |
| planReminders, |
| refreshPlanReminders, |
| createPlanReminder, |
| updatePlanReminder, |
| togglePlanReminder, |
| triggerPlanReminderNow, |
| snoozePlanReminder, |
| clearPlanReminderRunHistory, |
| deletePlanReminder, |
| refreshSkills, |
| refreshManagedSkillSources, |
| refreshBundledSkillCatalog, |
| importManagedSkillSource, |
| installManagedSkill, |
| installBundledSkill, |
| previewManagedSkillUpdate, |
| updateManagedSkill, |
| setSkillEnabled, |
| setSkillPinned, |
| deleteSkill, |
| openSkill, |
| } = useAppShellModuleData({ |
| uiLocale, |
| isSkillsSurfaceActive, |
| isAutomationsSurfaceActive, |
| toastApi, |
| }); |
| |
| // 保持系统唤醒 capability for the 定时任务 page: reads/writes |
| // settings.system.keepSystemAwake over the existing settings bridge. When |
| // the bridge is absent the panel hides the row (fail-soft). |
| const keepSystemAwakeController = useKeepSystemAwake(); |
| |
| const { |
| projectInfo, |
| projects, |
| selectedProjectId, |
| currentProjectId, |
| currentProject, |
| projectPickerPending, |
| projectPickerPendingRef, |
| projectPickerRequestRef, |
| refreshAppInfo, |
| refreshProjects, |
| addProject, |
| selectProject, |
| selectNoProject, |
| prepareDefaultProject, |
| prepareProject, |
| relinkProject, |
| renameProject, |
| archiveProject, |
| restoreProject, |
| openProjectFolder, |
| openWorkspaceFolder, |
| openSkillsFolder, |
| } = useAppShellProjectContext({ |
| uiLocale, |
| rendererMountedRef, |
| sessionId: activeId, |
| sessionCwd: activeSession?.cwd, |
| sessionProjectId: activeSession?.projectId, |
| onProjectSelected: (ownerSessionId) => { |
| if (ownerSessionId && activeIdRef.current === ownerSessionId) openNewTaskSurface(); |
| }, |
| toastApi, |
| }); |
| // Where a NEW chat starts. Built unconditionally and handed to the composer, |
| // which renders it only while no session owns it — the project is fixed once |
| // the first message creates one, so there is nothing to pick after that. |
| const workspacePicker: WorkspacePickerModel = { |
| label: |
| currentProject?.name ?? |
| (currentProjectId === null |
| ? getConversationCopy(uiLocale).workspace.noProject |
| : undefined), |
| branch: currentProjectId === null ? null : projectInfo?.projectGit.branch, |
| pending: projectPickerPending, |
| projects: projects.filter((project) => project.archivedAt === undefined), |
| selectedProjectId: currentProjectId, |
| onAdd: () => { |
| void addProject(); |
| }, |
| onSelectProject: (projectId: string) => { |
| void selectProject(projectId); |
| }, |
| onRelink: (projectId: string) => { |
| void relinkProject(projectId, true); |
| }, |
| onSelectNoProject: selectNoProject, |
| }; |
| // The titlebar names the directory the ACTIVE session runs in, so it reads |
| // the same projected project state the picker does — `projectInfo` already |
| // resolves to the session's own cwd once a session owns it. |
| const titlebarProjectName = deriveTitlebarProjectName({ |
| projectName: currentProject?.name, |
| projectPath: projectInfo?.projectPath, |
| }); |
| const sessionProjectGroups = useMemo( |
| () => deriveProjectGroups(visibleSessions, projects, uiLocale), |
| [visibleSessions, projects, uiLocale], |
| ); |
| const worktreeSessionIds = useMemo( |
| () => deriveWorktreeSessionIds(visibleSessions, projects), |
| [visibleSessions, projects], |
| ); |
| const { startModeSession } = useStableActions(createAppShellSessionStartActions, { |
| uiLocale, |
| activeIdRef, |
| captureComposerImportOwner, |
| composerRef, |
| isShellSurfaceOwnerActive, |
| openSessionInChat, |
| newChatProjectId: selectedProjectId, |
| sessionStartPendingRef, |
| refreshOnboarding: onboarding.refresh, |
| refreshSessions, |
| showModelSetupToast, |
| toastApi, |
| }); |
| const projectRowActions: NonNullable<Parameters<typeof SessionListPanel>[0]['projectActions']> = { |
| onNew: createSessionInProject, |
| onRename: renameProject, |
| onArchive: archiveProject, |
| onRestore: restoreProject, |
| onRelink: async (projectId) => { |
| await relinkProject(projectId); |
| }, |
| }; |
| |
| // Composer mention popups: `/` uses Runtime's session/project-aware, |
| // host-compatible projection; `@` uses workspace file search. Keep the |
| // resolved project path as a refresh key for new-chat project changes. |
| const { mentionSkills, searchMentionFiles } = useComposerMentions({ |
| skills, |
| sessionId: activeId, |
| projectPath: projectInfo?.projectPath, |
| newSessionModel: newChatModel, |
| newSessionCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', |
| }); |
| |
| const { applyE2eFixture } = useStableActions(createAppShellE2eFixtureActions, { |
| openPalette, |
| composerRef, |
| openSettingsSection, |
| openConnectionDetail, |
| refreshSessions, |
| setActiveId, |
| setLiveBrowserSessionIds, |
| setLiveTurnBySession, |
| setNavSelection, |
| setInteractionBySession, |
| setSearchModalOpen, |
| setSessionListCollapsed, |
| setWorkbarCollapsed, |
| openWorkbarTab, |
| setThemePref, |
| setUiLocaleOverride, |
| }); |
| |
| const { |
| send, |
| respondToSandboxBoundary, |
| respondToUserQuestion, |
| refreshMessages, |
| retryMessages, |
| } = useStableActions(createAppShellChatActions, { |
| uiLocale, |
| activeIdRef, |
| addPendingSessionAction, |
| captureComposerImportOwner, |
| clearPendingSessionAction, |
| isNewChatSendSurfaceActive, |
| isShellSurfaceOwnerActive, |
| markSessionReadLocally, |
| messageRetryPendingRef, |
| refreshSessions, |
| setActiveId, |
| setMessageLoadErrorBySession, |
| setMessageRetryPendingBySession, |
| setMessages, |
| setNavSelection, |
| setLiveTurnBySession, |
| setInteractionBySession, |
| onInteractionChanged: markInteractionChanged, |
| onExecutionBoundaryChanged: reloadActiveExecutionBoundary, |
| showModelSetupToast, |
| toastApi, |
| upsertSessionSummary, |
| newChatModel: newChatModel ?? null, |
| pendingNewChatThinkingLevel: newChatThinkingLevel ?? null, |
| newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', |
| newChatOrchestrationMode: newChatGraphModeActive |
| ? 'graph' |
| : newChatSwarmModeActive |
| ? 'swarm' |
| : 'default', |
| newChatProjectId: selectedProjectId, |
| }); |
| |
| const { handleTurnFooterAction } = useStableActions(createAppShellTurnActions, { |
| uiLocale, |
| activeIdRef, |
| addPendingTurnAction: turnActionRegistry.addKey, |
| clearPendingTurnAction: turnActionRegistry.clearKey, |
| openSessionInChat, |
| pendingKeyOf, |
| refreshMessages, |
| refreshSessions, |
| setMessages, |
| toastApi, |
| upsertSessionSummary, |
| }); |
| |
| const { |
| beginEditUserMessage, |
| prepareRevisionSend, |
| cancelRevisionDraft, |
| } = useStableActions(createAppShellRevisionActions, { |
| uiLocale, |
| activeIdRef, |
| composerRef, |
| messages, |
| hasPendingAttachments: () => pendingAttachments.length > 0, |
| openSessionInChat, |
| refreshMessages, |
| refreshSessions, |
| setMessages, |
| commitRevisionDraft, |
| revisionDraftRef, |
| toastApi, |
| upsertSessionSummary, |
| }); |
| |
| async function sendWithAttachments( |
| text: string, |
| metadata?: { workspaceFileReferences?: readonly WorkspaceFileReferencePosition[] }, |
| ): Promise<boolean | void> { |
| const revision = revisionDraftRef.current; |
| const revisionSend = Boolean( |
| revision && activeIdRef.current === revision.draftSessionId, |
| ); |
| const sideChatCommand = parseSideChatCommand(text); |
| const swarmCommand = parseSwarmCommand(text); |
| const graphCommand = parseGraphCommand(text); |
| if ( |
| revisionSend && |
| revision && |
| text.trim() === revision.originalText.trim() && |
| pendingAttachments.length === 0 |
| ) { |
| const actionCopy = getDesktopConversationCopy(uiLocale).actions; |
| toastApi.info(actionCopy.revisionReadyTitle, actionCopy.revisionUnchanged); |
| return false; |
| } |
| if (revisionSend && revision) { |
| const actionCopy = getDesktopConversationCopy(uiLocale).actions; |
| if (pendingAttachments.length > 0) { |
| toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionAttachmentsUnsupported); |
| return false; |
| } |
| if (text.trim() === '/compact' || sideChatCommand || swarmCommand || graphCommand) { |
| toastApi.info(actionCopy.revisionUnavailableTitle, actionCopy.revisionCommandUnsupported); |
| return false; |
| } |
| if (!(await prepareRevisionSend(text))) return false; |
| } |
| if (text.trim() === '/compact') { |
| const sessionId = activeIdRef.current; |
| if (!sessionId) return true; |
| try { |
| await window.maka.sessions.compact(sessionId); |
| return true; |
| } catch (error) { |
| if (activeIdRef.current !== sessionId) return false; |
| if (isSessionWorkspaceUnavailableError(error)) { |
| showSessionWorkspaceUnavailableToast(toastApi, uiLocale); |
| } else { |
| toastApi.error( |
| shellCopy.compactErrorTitle, |
| localizedShellErrorMessage(error, shellCopy.compactErrorFallback, uiLocale), |
| ); |
| } |
| return false; |
| } |
| } |
| if (sideChatCommand) { |
| if (!activeIdRef.current) { |
| toastApi.info( |
| shellCopy.sideChatUnavailableTitle, |
| shellCopy.sideChatUnavailableDescription, |
| ); |
| return false; |
| } |
| if ( |
| pendingAttachments.length > 0 || |
| pendingQuotes.length > 0 || |
| (metadata?.workspaceFileReferences?.length ?? 0) > 0 |
| ) { |
| toastApi.info( |
| shellCopy.sideChatContextPendingTitle, |
| shellCopy.sideChatContextPendingDescription, |
| ); |
| return false; |
| } |
| openSideConversation(sideChatCommand.prompt || undefined); |
| return true; |
| } |
| if (swarmCommand) { |
| if (swarmCommand.kind === 'status') { |
| const active = activeIdRef.current |
| ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' |
| : newChatSwarmModeActive; |
| toastApi.info( |
| active ? shellCopy.swarmModeEnabledTitle : shellCopy.swarmModeDisabledTitle, |
| shellCopy.swarmModeStatusDescription, |
| ); |
| return true; |
| } |
| if (swarmCommand.kind === 'set_mode') { |
| const changed = await setSwarmMode(swarmCommand.mode === 'swarm'); |
| if (changed) { |
| toastApi.info( |
| swarmCommand.mode === 'swarm' |
| ? shellCopy.swarmModeEnabledTitle |
| : shellCopy.swarmModeDisabledTitle, |
| shellCopy.swarmModeStatusDescription, |
| ); |
| } |
| return changed; |
| } |
| const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; |
| const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; |
| const ok = await send(swarmCommand.task, pending, { |
| turnOrchestration: { mode: 'swarm', source: 'slash_command' }, |
| ...(quotes ? { quotes } : {}), |
| ...(metadata?.workspaceFileReferences?.length |
| ? { |
| workspaceFileReferences: rebaseWorkspaceFileReferences( |
| text, |
| swarmCommand.task, |
| metadata.workspaceFileReferences, |
| ), |
| } |
| : {}), |
| }); |
| if (ok !== false && pending) clearSubmittedAttachments(pending); |
| if (ok !== false && quotes) clearQuotes(); |
| return ok; |
| } |
| if (graphCommand) { |
| if (graphCommand.kind === 'status') { |
| const active = activeIdRef.current |
| ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' |
| : newChatGraphModeActive; |
| toastApi.info( |
| active ? shellCopy.graphModeEnabledTitle : shellCopy.graphModeDisabledTitle, |
| shellCopy.graphModeStatusDescription, |
| ); |
| return true; |
| } |
| if (graphCommand.kind === 'set_mode') { |
| const changed = await setGraphMode(graphCommand.mode === 'graph'); |
| if (changed) { |
| toastApi.info( |
| graphCommand.mode === 'graph' |
| ? shellCopy.graphModeEnabledTitle |
| : shellCopy.graphModeDisabledTitle, |
| shellCopy.graphModeStatusDescription, |
| ); |
| } |
| return changed; |
| } |
| const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; |
| const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; |
| const ok = await send(graphCommand.task, pending, { |
| turnOrchestration: { mode: 'graph', source: 'slash_command' }, |
| ...(quotes ? { quotes } : {}), |
| ...(metadata?.workspaceFileReferences?.length |
| ? { |
| workspaceFileReferences: rebaseWorkspaceFileReferences( |
| text, |
| graphCommand.task, |
| metadata.workspaceFileReferences, |
| ), |
| } |
| : {}), |
| }); |
| if (ok !== false && pending) clearSubmittedAttachments(pending); |
| if (ok !== false && quotes) clearQuotes(); |
| return ok; |
| } |
| const pending = pendingAttachments.length > 0 ? pendingAttachments : undefined; |
| const expectedRevisionDraft = revisionSend |
| ? revisionDraftRef.current |
| : undefined; |
| const quotes = pendingQuotes.length > 0 ? pendingQuotes : undefined; |
| const ok = await send(text, pending, { |
| ...(quotes ? { quotes } : {}), |
| ...(metadata?.workspaceFileReferences?.length |
| ? { workspaceFileReferences: metadata.workspaceFileReferences } |
| : {}), |
| }); |
| if (ok !== false && pending) clearSubmittedAttachments(pending); |
| if (ok !== false && quotes) clearQuotes(); |
| if (ok !== false && revisionSend) { |
| if (expectedRevisionDraft) { |
| completeTurnRevisionCopyAttempt(expectedRevisionDraft); |
| composerRef.current?.clearDraft(expectedRevisionDraft.draftSessionId); |
| if (expectedRevisionDraft.sourceSessionId !== expectedRevisionDraft.draftSessionId) { |
| composerRef.current?.clearDraft(expectedRevisionDraft.sourceSessionId); |
| } |
| } |
| commitRevisionDraft(null); |
| } |
| return ok; |
| } |
| |
| async function steerWithText(text: string): Promise<boolean> { |
| const sessionId = activeIdRef.current; |
| if (!sessionId || !text.trim()) return false; |
| try { |
| const outcome = await window.maka.sessions.steer(sessionId, text.trim()); |
| return outcome.kind === 'queued'; |
| } catch (error) { |
| if (activeIdRef.current === sessionId) { |
| const copy = getDesktopConversationCopy(uiLocale).actions; |
| toastApi.error( |
| copy.operationFailedTitle, |
| localizedShellErrorMessage(error, copy.operationFailedFallback, uiLocale), |
| ); |
| } |
| return false; |
| } |
| } |
| |
| const stop = createAppShellStopAction({ |
| uiLocale, |
| activeIdRef, |
| addPendingSessionAction, |
| clearPendingSessionAction, |
| setStopPendingBySession, |
| stopPendingRef, |
| toastApi, |
| }); |
| |
| const { handleEvent, reconcilePersistedMessages, settleAssistantStreaming } = useStableActions(createAppShellSessionEventHandlers, { |
| uiLocale, |
| activeIdRef, |
| liveTurnBySessionRef, |
| refreshMessages, |
| refreshSessions, |
| setLiveTurnBySession, |
| setInteractionBySession, |
| onInteractionChanged: markInteractionChanged, |
| onExecutionBoundaryChanged: reloadActiveExecutionBoundary, |
| showModelSetupToast, |
| toastApi, |
| notifyRunEnded: ({ kind, sessionId, body }) => { |
| if (kind === 'completed' && activeIdRef.current === sessionId) { |
| setPetCompletionNonce((current) => current + 1); |
| } |
| const title = sessionsRef.current.find((session) => session.id === sessionId)?.name; |
| // Best-effort: swallow any main-side failure so a missed banner |
| // never surfaces as an unhandled promise rejection. |
| void window.maka.notifications.runEnded({ kind, title, body }).catch(() => {}); |
| }, |
| }); |
| |
| // Streaming-settle handoff, FALLBACK path only. The bubble's primary |
| // `onStreamingSettled` signal runs after Astryx commits the terminal text. |
| // Keep a delayed fallback because a stuck slot would otherwise hide the |
| // committed answer forever (`streamingMessageId` suppresses it while live). |
| useEffect(() => { |
| if (!activeId || !activeStreamingMessageId) return; |
| const committedAssistantArrived = messages.some( |
| (message) => message.type === 'assistant' && message.id === activeStreamingMessageId, |
| ); |
| if (!committedAssistantArrived) return; |
| const timer = window.setTimeout(() => { |
| void settleAssistantStreaming(activeId, activeStreamingMessageId); |
| }, SETTLE_FALLBACK_GRACE_MS); |
| return () => window.clearTimeout(timer); |
| }, [activeId, activeStreamingMessageId, messages, settleAssistantStreaming]); |
| |
| const hasModalOpen = helpOpen || paletteOpen || searchModalOpen || externalImportOpen; |
| |
| useEffect(() => { |
| const handleWorkbarShortcut = (event: KeyboardEvent) => { |
| if ( |
| hasModalOpen || |
| navSelectionRef.current.section !== 'sessions' || |
| !activeId |
| ) { |
| return; |
| } |
| const primary = navigator.platform.toLowerCase().includes('mac') |
| ? event.metaKey |
| : event.ctrlKey; |
| const key = event.key.toLowerCase(); |
| if (event.ctrlKey && event.shiftKey && !event.altKey && key === 'g') { |
| event.preventDefault(); |
| requestOpenWorkbarTab('right', 'review'); |
| } else if ( |
| event.ctrlKey && |
| !event.altKey && |
| !event.shiftKey && |
| (key === '`' || event.code === 'Backquote') |
| ) { |
| event.preventDefault(); |
| requestOpenWorkbarTab('right', 'terminal'); |
| } else if (primary && !event.altKey && !event.shiftKey && key === 't') { |
| event.preventDefault(); |
| requestOpenWorkbarTab('right', 'browser'); |
| } else if (primary && !event.altKey && !event.shiftKey && key === 'p') { |
| event.preventDefault(); |
| requestOpenWorkbarTab('right', 'files'); |
| } else if (primary && event.altKey && !event.shiftKey && key === 's') { |
| event.preventDefault(); |
| requestOpenWorkbarTab('right', 'side-chat'); |
| } |
| }; |
| window.addEventListener('keydown', handleWorkbarShortcut, true); |
| return () => window.removeEventListener('keydown', handleWorkbarShortcut, true); |
| }, [activeId, hasModalOpen, requestOpenWorkbarTab]); |
| |
| useAppShellNavRefSync({ |
| navSelection, |
| navSelectionRef, |
| }); |
| useAppShellHostEffects({ |
| activeId, |
| hasModalOpen, |
| setLiveBrowserSessionIds, |
| }); |
| useAppShellBootstrapSubscriptions({ |
| uiLocale, |
| activeIdRef, |
| applyE2eFixture, |
| bootstrapSessions, |
| clearPendingTurnActionsForSession: turnActionRegistry.clearForSession, |
| confirmLiveTurn, |
| clearSessionRendererState, |
| createSession, |
| handleConnectionEvent, |
| openHelp, |
| openSettings, |
| pendingPermissionModeChangesRef: permissionModeChangeRegistry.keysRef, |
| pendingSessionModelChangesRef: sessionModelChangeRegistry.keysRef, |
| pendingTurnActionTimersRef: turnActionRegistry.timersRef, |
| pendingTurnActionsRef: turnActionRegistry.keysRef, |
| projectPickerPendingRef, |
| projectPickerRequestRef, |
| refreshAppInfo, |
| refreshConnections, |
| refreshMemoryActive, |
| refreshMessages, |
| refreshPlanReminders, |
| refreshProjects, |
| refreshShellSettings, |
| refreshSkills, |
| refreshManagedSkillSources, |
| refreshBundledSkillCatalog, |
| refreshSessions, |
| rendererMountedRef, |
| setActiveId, |
| setMessages, |
| setNavSelection, |
| setSessionEventHealthBySession, |
| toastApi, |
| }); |
| useAppShellPersistenceEffects({ |
| navigationState, |
| sessionListCollapsed, |
| sessionListWidth, |
| workbarCollapsed, |
| workbarWidth, |
| bottomPanelOpen, |
| bottomPanelHeight, |
| workbarPanelsState, |
| themePalette, |
| themePref, |
| }); |
| useActiveSessionEvents({ |
| uiLocale, |
| activeId, |
| activeIdRef, |
| handleEvent, |
| markSessionReadLocally, |
| setMessageLoadErrorBySession, |
| setMessageLoadPending, |
| setMessages, |
| setSessionEventHealthBySession, |
| toastApi, |
| }); |
| useShellRunUpdates({ activeId, setShellRunUpdatesBySession }); |
| useSessionEventHealthPolling({ |
| activeId, |
| activeInteraction, |
| activeSession, |
| activeStreamingLive, |
| hasInFlightLiveTools, |
| refreshMessages, |
| refreshSessions, |
| sessionEventHealthBySessionRef, |
| setSessionEventHealthBySession, |
| }); |
| useSettledSessionTransientReconcile({ |
| activeId, |
| sessions, |
| liveTurnBySessionRef, |
| clearTurnTransientState, |
| }); |
| |
| function captureComposerImportOwner(): ComposerImportOwner { |
| return { |
| sessionId: activeIdRef.current, |
| navSection: navSelectionRef.current.section, |
| }; |
| } |
| |
| /** |
| * "Is this owner still the surface the user is looking at." One rule, both |
| * halves: an async result that lands after the user moved on must not toast, |
| * navigate or steal focus, and `selectNavigation` never clears `activeId` |
| * (nav-selection.ts) — so the session id alone answers yes long after the |
| * user left for 扩展 or 设置. |
| * |
| * The two below are this same question with a precondition on what KIND of |
| * owner the caller wants, not second opinions about the question. They were |
| * three independent spellings once, and the one that re-derived it from an |
| * id drifted: it lost the section half, which is exactly what let a failed |
| * send pull a user out of 技能 and into 设置 · 模型. |
| */ |
| function isShellSurfaceOwnerActive(owner: ComposerImportOwner): boolean { |
| return navSelectionRef.current.section === owner.navSection && activeIdRef.current === owner.sessionId; |
| } |
| |
| /** …and the owner was captured on the chat surface. */ |
| function isComposerImportOwnerActive(owner: ComposerImportOwner): boolean { |
| return owner.navSection === 'sessions' && isShellSurfaceOwnerActive(owner); |
| } |
| |
| /** …and it was the new-chat surface, which by definition has no session. */ |
| function isNewChatSendSurfaceActive(owner: ComposerImportOwner): boolean { |
| return owner.sessionId === undefined && isComposerImportOwnerActive(owner); |
| } |
| |
| async function bootstrapSessions() { |
| const next = await refreshSessions(); |
| bootstrapSelectionLease.reconcile(collapseSessionRevisions(next)); |
| bootstrapSelectionLease.release(); |
| } |
| |
| function openNewTaskSurface() { |
| startNewSession(); |
| setNewChatPlanModeActive(false); |
| setNavSelection({ section: 'sessions', filter: 'chats' }); |
| setSearchScrollTarget(null); |
| // New-task affordances reset to the empty-state composer; move focus |
| // there so the user can start typing immediately. |
| window.requestAnimationFrame(() => composerRef.current?.focus()); |
| } |
| |
| async function createSession() { |
| if (await prepareDefaultProject()) openNewTaskSurface(); |
| } |
| |
| async function createSessionInProject(projectId: string) { |
| if (await prepareProject(projectId)) openNewTaskSurface(); |
| } |
| |
| function openPlanReminderForm() { |
| setNavSelection({ section: 'automations', module: 'plan-reminders' }); |
| closePalette(); |
| setPlanReminderCreateRequestNonce((nonce) => nonce + 1); |
| } |
| |
| /** |
| * PR-UI-RENDER-2 - single chokepoint for the Markdown internal-URI |
| * router. Receives a typed `MakaUriDest` from the link override in |
| * `<Markdown>` and dispatches to the existing app navigation |
| * surfaces: |
| * |
| * - `kind: 'settings'` → `openSettingsSection(section)` (existing |
| * Settings modal jump, persisted via localStorage). |
| * - `kind: 'compose'` → write text into the composer via |
| * `composerRef.current.setText(...)` and focus it. We do NOT |
| * auto-submit the prompt; the user still presses Enter. That |
| * keeps an injected `maka://compose?text=ransfer my keys...` |
| * from sending without a human in the loop. |
| * |
| * No other cases exist today by design — the parser only emits |
| * these two discriminants. If a new variant is added in `MakaUriDest`, |
| * TypeScript's exhaustiveness check below trips and a new branch |
| * must be wired here with corresponding fixture and journey coverage. |
| */ |
| function dispatchMakaUri(dest: MakaUriDest) { |
| switch (dest.kind) { |
| case 'settings': |
| openSettingsSection(dest.section); |
| return; |
| case 'compose': |
| composerRef.current?.setText(dest.text); |
| composerRef.current?.focus(); |
| return; |
| default: { |
| const _exhaustive: never = dest; |
| return _exhaustive; |
| } |
| } |
| } |
| |
| function closeSettings() { |
| setSettingsOpen(false); |
| setSettingsProviderCatalogOpen(false); |
| // PR110c: re-pull onboarding snapshot when the user closes the |
| // Settings modal — they may have just configured a default |
| // connection or supplied a credential. Existing connections / |
| // sessions events cover most state changes, but a settings-only |
| // write (e.g. defaultSlug picked) may not always fire one. |
| onboarding.refresh(); |
| // PR-MEMORY-VISIBILITY-INDICATOR-0: same recompute path for the |
| // session-context memory state — user may have just flipped the |
| // agentReadEnabled switch. |
| void refreshMemoryActive(); |
| // Settings pages own optimistic local drafts, so the shell does not see |
| // every write live. Refresh its display mirrors on close (e.g. default |
| // permission mode) without requiring an app restart. |
| void refreshShellSettings(); |
| } |
| |
| function showModelSetupToast(description: string, reason?: string) { |
| const copy = modelSetupToastCopy(reason, description, uiLocale); |
| toastApi.toast({ |
| title: copy.title, |
| description: copy.description, |
| variant: 'error', |
| duration: 8000, |
| action: { |
| label: shellCopy.openModelSettings, |
| onClick: () => openSettingsSection('models'), |
| }, |
| }); |
| openSettingsSection('models'); |
| } |
| |
| const activeMessageLoadError = activeId ? messageLoadErrorBySession[activeId] : undefined; |
| const homeSurfaceActive = |
| navSelection.section === 'sessions' && |
| messages.length === 0 && |
| !hasLiveTurnContent && |
| !activeMessageLoadError; |
| const commandOptions: AppShellCommandListOptions = { |
| uiLocale, |
| activeId, |
| activePermissionMode, |
| canSetPermissionMode: activeBoundarySurface.localInteractionAvailable, |
| connections, |
| defaultConnection, |
| dailyReviewBridge, |
| messages, |
| sessions, |
| themePref, |
| visibleSessions, |
| captureComposerImportOwner, |
| composerRef, |
| createSession, |
| startModeSession, |
| isComposerImportOwnerActive, |
| openHelp, |
| openPlanReminderForm, |
| openProjectFolder, |
| openSessionInChat, |
| openSideConversation, |
| openSettings, |
| openSettingsSection, |
| openSkillsFolder, |
| openWorkspaceFolder, |
| refreshConnections, |
| saveDailyReviewMarkdown, |
| setNavSelection, |
| setPermissionMode, |
| setThemePref, |
| toastApi, |
| }; |
| |
| const agentsView = |
| navSelection.section === 'automations' |
| ? navSelection.module === 'daily-review' |
| ? 'daily-review' |
| : 'cron' |
| : navSelection.section === 'extensions' |
| ? navSelection.module |
| : 'im_hub'; |
| |
| return ( |
| <div |
| className="appFrame agents-layout-root" |
| data-agents-page |
| /* The single writer for sidebar state in the DOM. It sits on the frame, |
| above both the chrome strip and the shell, so every rule that keys on |
| it (shell-layout.css, sidebar.css) reaches its target as a descendant. |
| Copies on the shell and the detail panel bought nothing — one had no |
| readers at all — and three writers of the same value is three chances |
| for them to disagree. */ |
| data-sidebar-state={sessionListCollapsed ? 'collapsed' : 'expanded'} |
| /* Published here for the same reason `data-sidebar-state` is: the frame is |
| the only ancestor shared by the sidebar column and the titlebar strip, |
| and both need this number. The column is this wide; the titlebar's |
| session breadcrumb opens at that edge rather than straddling the seam |
| between the columns. |
| |
| Only the EXPANDED width, and only as an inline style, because that is |
| the half of the answer this component owns — the user's dragged width. |
| The collapsed width is a constant, so shell-layout.css states it off |
| `data-sidebar-state`. Writing both here would duplicate the constant; |
| writing this one unconditionally would bury the other, since an inline |
| custom property outranks any rule that redefines it. */ |
| style={ |
| sessionListCollapsed |
| ? undefined |
| : ({ '--maka-sidenav-width': `${sessionListWidth}px` } as CSSProperties) |
| } |
| > |
| <LiveTurnReconciler |
| controller={sessionUiController} |
| activeId={activeId} |
| messages={messages} |
| reconcile={reconcilePersistedMessages} |
| /> |
| {/* Window chrome is frame-level hit-test only (not AppShell topNav): a |
| transparent drag overlay so column surfaces paint to the window top. |
| It precedes the shell so Chromium applies app-region subtraction from |
| one frame-level hit-test surface. */} |
| <header |
| className="maka-window-titlebar" |
| aria-hidden={hasModalOpen ? 'true' : undefined} |
| inert={hasModalOpen ? true : undefined} |
| > |
| <AppShellTopbarActions |
| sidebarCollapsed={sessionListCollapsed} |
| onToggleSidebar={() => sessionSideNavHandleRef.current?.getCollapseState()?.toggle()} |
| sidebarToggleHidden={settingsOpen} |
| onOpenSearchModal={() => setSearchModalOpen(true)} |
| /> |
| {/* Only a session has an identity to state. The other views name |
| themselves in the nav column they are selected from, and the |
| new-task surface still shows its project in the composer's |
| WorkspacePicker — which stops rendering at the exact moment this |
| takes over, when the first message creates the session. */} |
| {/* `activeSessionForView`, not `activeSession`: opening or creating a |
| session runs a few hundred ms on a placeholder record while the real |
| summary loads, and the name this replaced (the context layer's) was |
| showing through that window. Hung on the real record alone, 新任务 |
| was named nowhere for the length of it. */} |
| {navSelection.section === 'sessions' && activeSessionForView && ( |
| <TitlebarSessionIdentity |
| /* Keyed by session: the open rename is local state and the field is |
| uncontrolled, so a switch that left the instance mounted would |
| carry one session's half-typed name — and its commit — onto the |
| next one. A remount ties the edit to the session it belongs to. */ |
| key={activeSessionForView.id} |
| sessionName={activeSessionForView.name} |
| onRenameSession={(name) => { |
| void sessionRowActionHandlers.renameSession(activeSessionForView.id, name); |
| }} |
| project={ |
| titlebarProjectName |
| ? { name: titlebarProjectName, onOpenFolder: () => void openProjectFolder() } |
| : undefined |
| } |
| parentSession={titlebarParentSession} |
| /> |
| )} |
| {!VIEWS_WITHOUT_WORKSPACE_ACTIONS.has(agentsView) && ( |
| <AppShellWorkspaceTopActions |
| workbarAvailable={navSelection.section === 'sessions' && Boolean(activeId)} |
| workbarCollapsed={workbarCollapsed} |
| onOpenWorkbarLauncher={revealWorkbarLauncher} |
| onToggleWorkbar={toggleWorkbar} |
| /> |
| )} |
| </header> |
| <AstryxAppShell |
| className="app maka-shell-astryx agents-layout-body" |
| /* Astryx's default: nav column takes --color-background-body, content takes |
| --color-background-surface. Both point at the product palette through |
| makaTheme.ts, so the shell follows a palette switch. Declared rather |
| than defaulted — it decides what separates the two columns. */ |
| variant="elevated" |
| height="fill" |
| contentPadding={0} |
| mobileNav={{ breakpoint: 'none', hasToggle: false }} |
| aria-hidden={hasModalOpen ? 'true' : undefined} |
| inert={hasModalOpen ? true : undefined} |
| sideNav={ |
| <SessionListPanel |
| collapseHandleRef={sessionSideNavHandleRef} |
| collapsed={sessionListCollapsed} |
| onCollapsedChange={setSessionListCollapsed} |
| width={sessionListWidth} |
| onWidthChange={(width) => { |
| if (width >= SESSION_LIST_EXPANDED_MIN_WIDTH) setSessionListWidth(width); |
| }} |
| minWidth={SESSION_LIST_EXPANDED_MIN_WIDTH} |
| maxWidth={SESSION_LIST_EXPANDED_MAX_WIDTH} |
| selection={navSelection} |
| sessions={visibleSessions} |
| activeId={sidebarActiveId} |
| planReminders={planReminders} |
| streamingSessionIds={streamingSessionIds} |
| staleSessionIds={staleSessionIds} |
| viewMode={viewMode} |
| onViewModeChange={setViewMode} |
| groups={viewMode === 'project' ? sessionProjectGroups : undefined} |
| worktreeSessionIds={worktreeSessionIds} |
| moduleMemory={navigationState.moduleMemory} |
| onSelect={setNavSelection} |
| onSelectSession={sessionListSelectSession} |
| onOpenSettings={openSettings} |
| updateReminder={updateReminder} |
| onOpenUpdate={openUpdateDownload} |
| onNew={createSession} |
| onImport={() => setExternalImportOpen(true)} |
| rowActions={sessionRowActions} |
| projectActions={projectRowActions} |
| /> |
| } |
| > |
| <AppShellDetailPanel agentsView={agentsView}> |
| {/* PR-UI-RENDER-2: install the internal-URI dispatcher |
| for any Markdown rendered inside ChatView (assistant |
| answers, thinking panels, streaming bubbles). Wrapping |
| at the detail-panel level keeps the provider scoped to |
| the chat surface — Markdown rendered elsewhere (e.g. |
| About settings) doesn't auto-route maka:// links, |
| which is correct: those surfaces shouldn't be a |
| navigation entry point. */} |
| <MakaUriContext.Provider value={dispatchMakaUri}> |
| <div className="maka-detail-with-artifacts"> |
| <div className="mainColumn" data-home-surface={homeSurfaceActive ? 'true' : undefined}> |
| {navSelection.section === 'extensions' && navSelection.module === 'skills' ? ( |
| <SkillsPage |
| hubHeader={extensionsHubHeader} |
| skills={skills} |
| planReminders={planReminders} |
| onRefreshSkills={() => refreshSkills()} |
| onRefreshManagedSkillSources={() => refreshManagedSkillSources()} |
| onOpenSkill={(skillId) => openSkill(skillId)} |
| onUseSkill={useSkillInChat} |
| onOpenSkillsFolder={() => openSkillsFolder()} |
| managedSkillSources={managedSkillSources} |
| onImportManagedSkillSource={() => importManagedSkillSource()} |
| onInstallManagedSkill={(sourceId) => installManagedSkill(sourceId)} |
| bundledSkillCatalog={bundledSkillCatalog} |
| onRefreshBundledSkillCatalog={() => refreshBundledSkillCatalog()} |
| onInstallBundledSkill={(id) => installBundledSkill(id)} |
| onPreviewManagedSkillUpdate={(skillId) => previewManagedSkillUpdate(skillId)} |
| onUpdateManagedSkill={(skillId, options) => updateManagedSkill(skillId, options)} |
| onSetSkillEnabled={(skillId, enabled) => setSkillEnabled(skillId, enabled)} |
| onSetSkillPinned={(skillRef, pinned) => setSkillPinned(skillRef, pinned)} |
| onDeleteSkill={(skillRef) => deleteSkill(skillRef)} |
| /> |
| ) : navSelection.section === 'extensions' && navSelection.module === 'mcp' ? ( |
| <McpPage hubHeader={extensionsHubHeader} /> |
| ) : navSelection.section === 'automations' && navSelection.module === 'plan-reminders' ? ( |
| <AutomationsPage |
| hubHeader={automationsHubHeader} |
| reminders={planReminders} |
| createRequestNonce={planReminderCreateRequestNonce} |
| onCreateRequestHandled={() => setPlanReminderCreateRequestNonce(0)} |
| keepSystemAwake={ |
| keepSystemAwakeController.supported |
| ? keepSystemAwakeController.keepSystemAwake |
| : undefined |
| } |
| onKeepSystemAwakeChange={ |
| keepSystemAwakeController.supported |
| ? keepSystemAwakeController.setKeepSystemAwake |
| : undefined |
| } |
| onRefresh={() => |
| refreshPlanReminders({ |
| shouldShowError: isAutomationsSurfaceActive, |
| }) |
| } |
| onCreate={(input) => createPlanReminder(input)} |
| onUpdate={(id, patch) => updatePlanReminder(id, patch)} |
| onToggle={(id, enabled) => togglePlanReminder(id, enabled)} |
| onTriggerNow={(id) => triggerPlanReminderNow(id)} |
| onSnooze={(id) => snoozePlanReminder(id)} |
| onClearRunHistory={(id) => clearPlanReminderRunHistory(id)} |
| onDelete={(id) => deletePlanReminder(id)} |
| /> |
| ) : navSelection.section === 'automations' && navSelection.module === 'daily-review' ? ( |
| <DailyReviewPage |
| hubHeader={automationsHubHeader} |
| bridge={dailyReviewBridge} |
| onSelectSession={openSessionInChat} |
| onCopyMarkdown={(input) => copyDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} |
| onAppendMarkdown={appendDailyReviewMarkdown} |
| onSaveMarkdown={(input) => saveDailyReviewMarkdown(input, { shouldShowFeedback: isDailyReviewSurfaceActive })} |
| /> |
| ) : null} |
| <ChatSurfaceLayout |
| // Reset conversation-owned scroll state without remounting the |
| // composer: its contenteditable DOM carries the live draft. |
| conversationKey={activeId} |
| hidden={navSelection.section !== 'sessions'} |
| composer={ |
| <> |
| {navSelection.section === 'sessions' && |
| activeId && |
| activeSessionForView && |
| !isLinkedSubagentSession(activeSessionForView) ? ( |
| <AgentGraphPanel |
| rootSessionId={activeId} |
| enabled={(activeSessionForView.orchestrationMode ?? 'default') === 'graph'} |
| locale={uiLocale} |
| onOpenSession={openSessionInChat} |
| /> |
| ) : null} |
| {navSelection.section === 'sessions' ? <PlanExecutionPanel planMode={planMode} /> : null} |
| <ChatComposerRegion |
| workspacePicker={workspacePicker} |
| composerRef={composerRef} |
| active={navSelection.section === 'sessions'} |
| onboardingComposerHidden={ |
| onboardingComposerHidden || !activeBoundarySurface.localInteractionAvailable |
| } |
| boundaryUnreadableNotice={boundaryUnreadableNotice} |
| activeInteraction={activeInteraction} |
| activeId={activeId} |
| stopPendingBySession={stopPendingBySession} |
| activeSandboxBoundary={activeSandboxBoundary} |
| respondToSandboxBoundary={respondToSandboxBoundary} |
| activeQuestion={activeQuestion} |
| respondToUserQuestion={respondToUserQuestion} |
| stop={stop} |
| // #646: Stop must be available for the WHOLE turn - the moment the |
| // user most wants to interrupt is a long wait with nothing on |
| // screen (first token, or a slow provider's step-to-step lull). |
| // `turnActive` unions the send's zero-lag local arm with the |
| // runtime's live `runningTurnIds` (turns this renderer did not |
| // send), so neither witness can veto the other — see |
| // `deriveTurnActive`. `activeStreamingLive` is folded in |
| // defensively for the rare replay where the arm was over-cleared. |
| streaming={turnActive || activeStreamingLive} |
| // #646: in the first-token wait (Stop up, nothing streams yet) the |
| // hint reads "Maka 正在处理…"; in a mid-turn lull it reads the calm |
| // "Maka 继续中…". Both are mutually exclusive with activeStreamingLive. |
| processing={showProcessingIndicator && !activeStreamingLive} |
| continuing={showContinuingIndicator && !activeStreamingLive} |
| onSend={sendWithAttachments} |
| onSteer={steerWithText} |
| onStop={stop} |
| revisionNotice={ |
| revisionDraft && activeId === revisionDraft.draftSessionId |
| ? { |
| title: getDesktopConversationCopy(uiLocale).actions.revisionBannerTitle, |
| detail: getDesktopConversationCopy(uiLocale).actions.revisionBannerDetail, |
| cancelLabel: getDesktopConversationCopy(uiLocale).actions.revisionCancelLabel, |
| onCancel: () => { void cancelRevisionDraft(); }, |
| } |
| : undefined |
| } |
| mentionSkills={mentionSkills} |
| slashCommands={sideChatSlashCommands} |
| onSearchMentionFiles={searchMentionFiles} |
| pendingAttachments={pendingAttachments} |
| onRemoveAttachment={removeAttachment} |
| pendingQuotes={pendingQuotes} |
| onRemoveQuote={removeQuote} |
| onPasteAsQuote={addQuote} |
| onPickAttachments={ |
| revisionDraft && activeId === revisionDraft.draftSessionId |
| ? undefined |
| : pickAttachments |
| } |
| onAttachFilePaths={ |
| revisionDraft && activeId === revisionDraft.draftSessionId |
| ? undefined |
| : attachFilePaths |
| } |
| modelLabel={activeModelLabel ?? newChatModelLabel ?? undefined} |
| activeSession={activeSessionForView} |
| activeConnectionLabel={activeConnectionLabel} |
| activeModel={activeModel} |
| activeModelLabel={activeModelLabel} |
| activeProviderType={activeConnection?.providerType} |
| modelChoices={chatModelChoices} |
| renderProviderMark={(type) => <ProviderBrandMark type={type} />} |
| modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} |
| onModelChange={(input) => setSessionModel(input)} |
| activeThinkingLevels={activeThinkingLevels} |
| activeThinkingLevel={activeThinkingLevel} |
| onThinkingLevelChange={(level) => setSessionThinkingLevel(level)} |
| newChatModel={newChatModel} |
| newChatProviderType={newChatProviderType} |
| onPickNewChatModel={(input) => { |
| setPendingNewChatModel(input); |
| saveComposerDefaults({ model: input }); |
| }} |
| newChatThinkingLevels={newChatThinkingLevels} |
| newChatThinkingLevel={newChatThinkingLevel} |
| onNewChatThinkingLevelChange={(level) => setPendingNewChatThinkingLevel(level ?? null)} |
| onOpenModelSettings={() => openSettingsSection('models')} |
| noModelConnection={connections.length === 0} |
| sendBlocked={ |
| Boolean(workspaceReadinessRecovery) || sessionHealthNotice?.tone === 'destructive' |
| } |
| permissionMode={activePermissionMode} |
| permissionModePending={activeId ? pendingPermissionModeBySession[activeId] === true : false} |
| // Every "cannot change this mid-turn" gate reads `turnActive`, |
| // the same witness Stop reads. Reading the persisted status |
| // here instead left these toggles live through the whole |
| // send→run-start window — long enough on a cold backend for a |
| // mode change to land before the run registers and alter the |
| // execution config of the turn already sent. |
| permissionModeDisabledReason={ |
| activeId && pendingPermissionModeBySession[activeId] === true |
| ? shellCopy.permissionModeChanging |
| : activeStreamingLive |
| ? shellCopy.permissionModeStreaming |
| : activeId && turnActive |
| ? shellCopy.permissionModeRunning |
| : activeId && activeSessionForView?.status === 'waiting_for_user' |
| ? shellCopy.permissionModeWaiting |
| : undefined |
| } |
| onPermissionModeChange={ |
| activeBoundarySurface.localInteractionAvailable |
| ? (mode) => setPermissionMode(mode) |
| : undefined |
| } |
| planModeActive={activeId |
| ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' |
| : newChatPlanModeActive} |
| planModePending={activeId ? pendingCollaborationModeBySession[activeId] === true : false} |
| planModeDisabledReason={ |
| activeId && pendingCollaborationModeBySession[activeId] === true |
| ? shellCopy.planModeChanging |
| : activeStreamingLive |
| ? shellCopy.planModeStreaming |
| : activeId && turnActive |
| ? shellCopy.planModeRunning |
| : activeId && activeSessionForView?.status === 'waiting_for_user' |
| ? shellCopy.planModeWaiting |
| : undefined |
| } |
| onPlanModeChange={setPlanMode} |
| swarmModeActive={activeId |
| ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' |
| : newChatSwarmModeActive} |
| swarmModePending={activeId ? pendingOrchestrationModeBySession[activeId] === true : false} |
| swarmModeDisabledReason={ |
| activeId && pendingOrchestrationModeBySession[activeId] === true |
| ? shellCopy.swarmModeChanging |
| : activeStreamingLive |
| ? shellCopy.swarmModeStreaming |
| : activeId && turnActive |
| ? shellCopy.swarmModeRunning |
| : activeId && activeSessionForView?.status === 'waiting_for_user' |
| ? shellCopy.swarmModeWaiting |
| : undefined |
| } |
| onSwarmModeChange={(active) => { |
| void setSwarmMode(active); |
| }} |
| graphModeActive={activeId |
| ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' |
| : newChatGraphModeActive} |
| graphModePending={activeId ? pendingOrchestrationModeBySession[activeId] === true : false} |
| graphModeDisabledReason={ |
| activeId && pendingOrchestrationModeBySession[activeId] === true |
| ? shellCopy.graphModeChanging |
| : activeStreamingLive |
| ? shellCopy.graphModeStreaming |
| : activeId && turnActive |
| ? shellCopy.graphModeRunning |
| : activeId && activeSessionForView?.status === 'waiting_for_user' |
| ? shellCopy.graphModeWaiting |
| : undefined |
| } |
| onGraphModeChange={(active) => { |
| void setGraphMode(active); |
| }} |
| /> |
| </> |
| } |
| > |
| {navSelection.section === 'sessions' ? ( |
| <ChatMessageSurface |
| sessionUiController={sessionUiController} |
| activeSessionId={activeId} |
| messages={messages} |
| messageLoading={activeMessageLoading} |
| runningStatus={showRunningStatus} |
| onStreamingSettled={ |
| activeId ? (messageId) => settleAssistantStreaming(activeId, messageId) : undefined |
| } |
| activeSession={activeSessionForView} |
| activeConnectionLabel={activeConnectionLabel} |
| activeModelLabel={activeModelLabel} |
| activeProviderType={activeConnection?.providerType} |
| renderProviderMark={(type) => <ProviderLogo type={type} compact />} |
| modelChoices={chatModelChoices} |
| modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} |
| onModelChange={(input) => setSessionModel(input)} |
| userLabel={userLabel} |
| memoryActive={memoryActive} |
| onOpenMemorySettings={() => openSettingsSection('memory')} |
| goalIndicator={ |
| activeGoal |
| ? { |
| condition: activeGoal.condition, |
| status: activeGoal.status, |
| iterations: activeGoal.iterations, |
| maxIterations: activeGoal.maxIterations, |
| onClear: () => { |
| void window.maka.goal.clear(activeGoal.sessionId).catch((error) => { |
| toastApi.error( |
| shellCopy.goalClearFailedTitle, |
| localizedShellErrorMessage( |
| error, |
| shellCopy.goalClearFailedFallback, |
| uiLocale, |
| ), |
| ); |
| }); |
| }, |
| } |
| : undefined |
| } |
| messageLoadError={activeId ? messageLoadErrorBySession[activeId] : undefined} |
| messageLoadRetryPending={activeId ? messageRetryPendingBySession[activeId] === true : false} |
| onRetryMessages={activeId ? () => void retryMessages(activeId) : undefined} |
| deriveTurnPresentation={deriveTurnPresentation} |
| onTurnFooterAction={handleTurnFooterAction} |
| onEditUserMessage={(turnId) => { void beginEditUserMessage(turnId); }} |
| safeResumeAction={activeId ? { |
| pending: resumePendingSessionId === activeId, |
| detail: resumeParkDescriptionBySession[activeId], |
| onResume: () => { void resumeInterruptedSession(); }, |
| } : undefined} |
| onLineageBadgeClick={handleLineageBadgeClick} |
| onReadAttachmentBytes={window.maka.attachments.readBytes} |
| onOpenLinkedSession={openSessionInChat} |
| scrollTargetTurn={ |
| activeId && searchScrollTarget?.sessionId === activeId |
| ? { |
| turnId: searchScrollTarget.turnId, |
| nonce: searchScrollTarget.nonce, |
| } |
| : undefined |
| } |
| scrollBehavior={readScrollMotionBehavior()} |
| branchBanner={branchBanner} |
| onBranchBannerClick={handleBranchBannerClick} |
| revisionNavigation={revisionNavigation} |
| onRevisionNavigate={openSessionInChat} |
| onNew={createSession} |
| onPromptSuggestion={(prompt) => composerRef.current?.appendText(prompt)} |
| onQuoteSelection={(selection) => { |
| addQuote(selection); |
| composerRef.current?.focus(); |
| }} |
| onAskAboutSelection={ |
| activeId |
| ? (input) => { |
| const quote: QuoteRef = { |
| text: input.text, |
| sourceTurnId: input.turnId, |
| }; |
| openSideConversationWithQuote(quote); |
| } |
| : undefined |
| } |
| onContinueDeepResearchHandoff={(run) => { |
| const prompt = run.implementationPrompt; |
| if (!prompt) return; |
| void createSession().then(() => { |
| window.requestAnimationFrame(() => { |
| composerRef.current?.setText(prompt); |
| composerRef.current?.focus(); |
| }); |
| }); |
| }} |
| sessionHealthNotice={sessionHealthNotice} |
| workspaceReadinessRecovery={workspaceReadinessRecovery} |
| showOnboardingHero={showOnboardingHero} |
| onboardingState={onboardingState} |
| isOnboardingLoading={isOnboardingLoading} |
| onOpenSettings={(section) => { |
| if (section) openSettingsSection(section); |
| else openSettings(); |
| }} |
| onOpenConnectionDetail={openConnectionDetail} |
| onAddProvider={openProviderCreate} |
| onBrowseProviders={openProviderCatalog} |
| connections={connections} |
| onRefreshConnections={refreshConnections} |
| onSkip={async () => { |
| try { |
| await window.maka.onboarding.setMilestone('initial_onboarding', 'skipped'); |
| onboarding.refresh(); |
| } catch (error) { |
| toastApi.error( |
| shellCopy.skipErrorTitle, |
| localizedShellErrorMessage(error, shellCopy.tryAgainLater, uiLocale), |
| ); |
| } |
| }} |
| conversationItems={planConversationItems} |
| /> |
| ) : null} |
| </ChatSurfaceLayout> |
| </div> |
| {/* Rendered collapsed too: ChatWorkbar's own box is what the |
| collapse animates, and it has to be in the tree on both sides of |
| the toggle for there to be an animation at all. The column |
| inside it still unmounts. */} |
| {navSelection.section === 'sessions' && activeId && ( |
| <ChatWorkbar |
| activeId={activeId} |
| rightCollapsed={workbarCollapsed} |
| bottomOpen={bottomPanelOpen} |
| hidden={hasModalOpen} |
| rightWidth={workbarWidth} |
| bottomHeight={bottomPanelHeight} |
| onDismissPanel={(placement) => { |
| if (placement === 'right') setWorkbarCollapsed(true); |
| else setBottomPanelOpen(false); |
| }} |
| panelsState={workbarPanelsState} |
| onActivateTab={activateWorkbarTab} |
| onCloseTab={closeWorkbarTabAndSurface} |
| onCloseTabs={closeWorkbarTabsAndSurfaces} |
| onReorderTab={reorderWorkbarTab} |
| onMoveTab={moveWorkbarTab} |
| onMoveTabToPanel={moveWorkbarTabToPanel} |
| onPinTab={pinWorkbarTab} |
| onOpenLauncher={(placement) => { |
| openWorkbarLauncher(placement); |
| if (placement === 'right') setWorkbarCollapsed(false); |
| else setBottomPanelOpen(true); |
| }} |
| onRequestOpenTab={requestOpenWorkbarTab} |
| rightResizable={workbarResizable} |
| bottomResizable={bottomPanelResizable} |
| quotes={quotePanels.filter( |
| (panel) => panel.sourceSessionId === activeId, |
| )} |
| onQuotesConsumed={(snapshot) => |
| sideConversations.updatePanel(snapshot.panelId, (panel) => |
| consumeCompanionQuoteSnapshot(panel, snapshot) ?? panel, |
| ) |
| } |
| onRemoveQuote={(target) => |
| sideConversations.updatePanel(target.panelId, (panel) => |
| removeStagedCompanionQuote(panel, target) ?? panel, |
| ) |
| } |
| onForkVisibilityChange={onCompanionForkVisibilityChange} |
| onContentStateChange={sideConversations.setContent} |
| preparingSideChatPanelIds={sideChatPreparingPanelIds} |
| activeSideChatPanelIds={sideChatActivePanelIds} |
| onPreparingStateChange={sideConversations.setPreparing} |
| onInitialPromptStarted={(panelId) => |
| sideConversations.updatePanel(panelId, (panel) => |
| consumeCompanionInitialPrompt(panel, panelId) ?? panel, |
| ) |
| } |
| onPromptAccepted={(panelId, prompt) => { |
| const title = sideChatTitleFromPrompt(prompt); |
| if (title) { |
| titleWorkbarTab(`side-chat:${panelId}`, title); |
| } |
| }} |
| onActivityStateChange={sideConversations.setActive} |
| sourceSession={activeSessionForView} |
| modelChoices={chatModelChoices} |
| mentionSkills={mentionSkills} |
| onSearchMentionFiles={searchMentionFiles} |
| /> |
| )} |
| </div> |
| </MakaUriContext.Provider> |
| </AppShellDetailPanel> |
| </AstryxAppShell> |
| {!hasModalOpen && !settingsOpen && ( |
| <CustomPetCompanion |
| activityState={petActivityState} |
| completionNonce={petCompletionNonce} |
| contextKey={activeId} |
| /> |
| )} |
| <SideChatCloseConfirmation |
| key={pendingSideChatClose.map(({ tab }) => tab.id).join(':') || 'closed'} |
| open={pendingSideChatClose.length > 0} |
| sideChatCount={ |
| pendingSideChatClose.filter(({ tab }) => tab.kind === 'side-chat').length |
| } |
| onCancel={() => setPendingSideChatClose([])} |
| onConfirm={(skipFutureConfirmations) => { |
| const pending = pendingSideChatClose; |
| if (pending.length === 0) return; |
| if (skipFutureConfirmations) { |
| setSkipSideChatCloseConfirmation(true); |
| safeLocalStorageSet(SKIP_SIDE_CHAT_CLOSE_CONFIRMATION_KEY, 'true'); |
| } |
| setPendingSideChatClose([]); |
| const byPlacement = { |
| right: pending |
| .filter(({ placement }) => placement === 'right') |
| .map(({ tab }) => tab), |
| bottom: pending |
| .filter(({ placement }) => placement === 'bottom') |
| .map(({ tab }) => tab), |
| }; |
| closeWorkbarTabsImmediately('right', byPlacement.right); |
| closeWorkbarTabsImmediately('bottom', byPlacement.bottom); |
| }} |
| /> |
| |
| <AppShellOverlays |
| settingsOpen={settingsOpen} |
| connections={connections} |
| defaultConnection={defaultConnection} |
| refreshConnections={refreshConnections} |
| closeSettings={closeSettings} |
| themePref={themePref} |
| setThemePref={setThemePref} |
| themePalette={themePalette} |
| setThemePalette={setThemePalette} |
| setUiLocalePreference={setUiLocalePreference} |
| uiLocaleUpdateGate={uiLocaleUpdateGate} |
| setUserLabel={setUserLabel} |
| setDefaultPermissionMode={setDefaultPermissionMode} |
| settingsRequestedSection={settingsRequestedSection} |
| settingsProviderCatalogOpen={settingsProviderCatalogOpen} |
| settingsConnectionDetailSlug={settingsConnectionDetailSlug} |
| settingsCreateProviderType={settingsCreateProviderType} |
| onOpenDailyReview={() => { |
| closeSettings(); |
| setNavSelection({ section: 'automations', module: 'daily-review' }); |
| }} |
| onOpenKeyboardHelp={openHelp} |
| onOpenSettingsSession={(sessionId) => { |
| closeSettings(); |
| openSessionInChat(sessionId); |
| }} |
| helpOpen={helpOpen} |
| closeHelp={closeHelp} |
| searchModalOpen={searchModalOpen} |
| closeSearchModal={closeSearchModal} |
| searchModalDeps={searchModalDeps} |
| searchModalOnNavigate={searchModalOnNavigate} |
| paletteOpen={paletteOpen} |
| closePalette={closePalette} |
| commandOptions={commandOptions} |
| externalImportOpen={externalImportOpen} |
| onExternalImportOpenChange={setExternalImportOpen} |
| onExternalSessionImported={(session) => { |
| upsertSessionSummary(session); |
| openSessionInChat(session.id); |
| }} |
| /> |
| </div> |
| ); |
| } |