| 1 | import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { useRuntimeStateSync } from "./lib/useRuntimeState"; |
| 3 | import { useCommittedCommand } from "./lib/useCommittedCommand"; |
| 4 | import { app, onLegacyEmptySessionCleanupChanged, openExternal } from "./lib/bridge"; |
| 5 | import type { LegacyEmptySessionCleanupStatus } from "./generated/desktopContract.generated"; |
| 6 | import { useT, useI18n } from "./lib/i18n"; |
| 7 | import { useToast } from "./lib/toast"; |
| 8 | import { useGoalActionHandler } from "./lib/goalAction"; |
| 9 | import { useActiveRemoteSession } from "./lib/useRemoteSession"; |
| 10 | import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel"; |
| 11 | import { setReasoningDisplayPending } from "./lib/reasoningDisplayPreference"; |
| 12 | import type { ComposerProfile, UserPlanModeIntents } from "./lib/composerProfile"; |
| 13 | import type { TabMeta } from "./lib/types"; |
| 14 | import type { HistoryViewState } from "./app-runtime/historyViewProjection"; |
| 15 | import { useNavigationSurface } from "./lib/useNavigationSurface"; |
| 16 | import { projectNavigationSurfaceTarget } from "./app-runtime/conversationProjection"; |
| 17 | import { useSessionOperations } from "./app-runtime/useSessionOperations"; |
| 18 | import { createSessionSurfaceFence, sessionIdentityKey } from "./app-runtime/sessionTarget"; |
| 19 | import { commitAppRenderToken, createAppRenderToken } from "./app-runtime/appLifecycleProbe"; |
| 20 | import { useAppRuntimeAdapter } from "./app-runtime/useAppRuntimeAdapter"; |
| 21 | import { useAppShellStores } from "./app-runtime/useAppShellStores"; |
| 22 | import { useAppSessionComposition } from "./app-runtime/useAppSessionComposition"; |
| 23 | import { useAppNavigationComposition } from "./app-runtime/useAppNavigationComposition"; |
| 24 | import { useSessionDraftSurface } from "./app-runtime/useSessionDraftSurface"; |
| 25 | import { draftLandingTargetForTab, type DraftLandingTarget } from "./app-runtime/draftLandingTarget"; |
| 26 | import { useRetiredProjectTreeUiMigration } from "./app-runtime/useLocalUiLifecycles"; |
| 27 | import logoSymbol from "./assets/logo-symbol.svg"; |
| 28 | |
| 29 | const AppRuntimeView = lazy(() => import("./app-shell/AppRuntimeView").then((module) => ({ default: module.AppRuntimeView }))); |
| 30 | |
| 31 | function AppRuntimeViewFallback() { |
| 32 | return ( |
| 33 | <div className="boot-shell" role="status" aria-label="Reasonix is starting"> |
| 34 | <div className="boot-shell__card"> |
| 35 | <div className="boot-shell__mark" aria-hidden="true"><img src={logoSymbol} alt="" draggable={false} /></div> |
| 36 | <div className="boot-shell__name">Reasonix</div> |
| 37 | <div className="boot-shell__dots" aria-hidden="true"><span /><span /><span /></div> |
| 38 | </div> |
| 39 | </div> |
| 40 | ); |
| 41 | } |
| 42 | |
| 43 | // Hold reasoning UI until the authoritative desktop startup settings arrive; |
| 44 | // this prevents a hidden preference from flashing content during first paint. |
| 45 | setReasoningDisplayPending(); |
| 46 | |
| 47 | /** |
| 48 | * Composition root: owns the controller adapter, the session identity/fence, |
| 49 | * the navigation surface and every store-backed state, then delegates all |
| 50 | * command domains to the session/navigation compositions and the tree to the |
| 51 | * shell view. Wiring only — no domain logic lives here. |
| 52 | */ |
| 53 | export function AppRuntime() { |
| 54 | useRuntimeStateSync(); |
| 55 | const appRenderToken = createAppRenderToken(); |
| 56 | useLayoutEffect(() => commitAppRenderToken(appRenderToken)); |
| 57 | const runtime = useAppRuntimeAdapter(); |
| 58 | const { state, liveStore, activeTabId, notice } = runtime.snapshot; |
| 59 | const t = useT(); |
| 60 | const { locale } = useI18n(); |
| 61 | const { showToast } = useToast(); |
| 62 | const { runGoalAction, handleGoalActionError } = useGoalActionHandler(); |
| 63 | const [composerProfilesByTab, setComposerProfilesByTab] = useState<Record<string, ComposerProfile>>({}); |
| 64 | const userPlanModeByTabRef = useRef<UserPlanModeIntents>({}); |
| 65 | const [tabMetas, setTabMetas] = useState<TabMeta[]>([]); |
| 66 | const [tabOrderIds, setTabOrderIds] = useState<string[]>([]); |
| 67 | const activeTab = useMemo( |
| 68 | () => tabMetas.find((tab) => tab.id === activeTabId) ?? tabMetas.find((tab) => tab.active), |
| 69 | [activeTabId, tabMetas], |
| 70 | ); |
| 71 | const { active: remoteSurfaceActive, session: remoteSession, ready: remoteComposerReady, onSend: remoteSend, onCancel: remoteCancel } = useActiveRemoteSession(activeTab, showToast); |
| 72 | const activeSessionIdentity = sessionIdentityKey({ |
| 73 | tabId: activeTabId, |
| 74 | session: activeTab?.session ?? state.meta?.session, |
| 75 | sessionId: activeTab?.sessionId, |
| 76 | remote: activeTab?.remote, |
| 77 | sessionPath: activeTab?.sessionPath ?? state.meta?.sessionPath, |
| 78 | sessionGeneration: activeTab?.sessionGeneration ?? state.meta?.sessionGeneration ?? state.sessionGen, |
| 79 | scope: activeTab?.scope, |
| 80 | workspaceRoot: activeTab?.workspaceRoot ?? state.meta?.cwd, |
| 81 | topicId: activeTab?.topicId, |
| 82 | }); |
| 83 | const sessionSurfaceFenceRef = useRef<ReturnType<typeof createSessionSurfaceFence> | null>(null); |
| 84 | if (!sessionSurfaceFenceRef.current) sessionSurfaceFenceRef.current = createSessionSurfaceFence(); |
| 85 | const sessionSurfaceFence = sessionSurfaceFenceRef.current; |
| 86 | const sessionOperations = useSessionOperations({ |
| 87 | visible: { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, |
| 88 | resources: [ |
| 89 | { tabId: activeTabId ?? "", sessionKey: activeSessionIdentity }, |
| 90 | ...tabMetas.filter(tab => tab.id !== activeTabId).map(tab => ({ |
| 91 | tabId: tab.id, |
| 92 | sessionKey: sessionIdentityKey({ tabId: tab.id, sessionPath: tab.sessionPath, |
| 93 | session: tab.session, sessionId: tab.sessionId, remote: tab.remote, sessionGeneration: tab.sessionGeneration, |
| 94 | scope: tab.scope, workspaceRoot: tab.workspaceRoot, topicId: tab.topicId }), |
| 95 | })), |
| 96 | ], |
| 97 | }); |
| 98 | useLayoutEffect(() => { |
| 99 | sessionSurfaceFence.commit(activeTabId, activeSessionIdentity); |
| 100 | return () => sessionSurfaceFence.dispose(); |
| 101 | }, [activeSessionIdentity, activeTabId, sessionSurfaceFence]); |
| 102 | const navigationSurface = useNavigationSurface(projectNavigationSurfaceTarget({ |
| 103 | activeTabId, sessionKey: activeSessionIdentity, local: state, remote: remoteSurfaceActive ? remoteSession : undefined, |
| 104 | })); |
| 105 | const shell = useAppShellStores(); |
| 106 | const [tabRevealSignal, setTabRevealSignal] = useState(0); |
| 107 | const [histView, setHistView] = useState<HistoryViewState | null>(null); |
| 108 | const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState(""); |
| 109 | useRetiredProjectTreeUiMigration(); |
| 110 | const [tasksOpen, setTasksOpen] = useState<false | "session" | "all">(false); |
| 111 | const workspaceScopeActiveTabRef = useRef(activeTabId); |
| 112 | const [workspaceControllerEpoch, setWorkspaceControllerEpoch] = useState(0); |
| 113 | workspaceScopeActiveTabRef.current = activeTabId; |
| 114 | const { mounted: terminalContentVisible, fitEnabled: terminalFitEnabled, prefetch: prefetchTerminalPanel } = useWarmTerminalPanel(shell.terminalPanelOpen, shell.terminalResizing, !shell.managementActive); |
| 115 | const [dockRefreshKey, setDockRefreshKey] = useState(0); |
| 116 | const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0); |
| 117 | const refreshComposerFileRefs = useCommittedCommand(() => setFileRefRefreshKey((value) => value + 1)); |
| 118 | const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`; |
| 119 | const [projectRevision, setProjectRevision] = useState(0); |
| 120 | const acceptedDraftSessionRef = useRef<((ref: import("./lib/sessionRef").SessionRef) => Promise<void>) | null>(null); |
| 121 | const openAcceptedDraftSession = useCommittedCommand(async (ref: import("./lib/sessionRef").SessionRef) => { |
| 122 | await acceptedDraftSessionRef.current?.(ref); |
| 123 | }); |
| 124 | const markDraftChanged = useCommittedCommand(() => setProjectRevision((value) => value + 1)); |
| 125 | const drafts = useSessionDraftSurface({ |
| 126 | onAccepted: openAcceptedDraftSession, |
| 127 | onChanged: markDraftChanged, |
| 128 | claimNavigationIntent: runtime.navigation.noteNavigationIntent, |
| 129 | currentNavigationIntent: runtime.navigation.currentNavigationIntent, |
| 130 | isNavigationIntentCurrent: runtime.navigation.isNavigationIntentCurrent, |
| 131 | }); |
| 132 | useEffect(() => { void drafts.initializeEmptySurface(); }, [drafts.initializeEmptySurface]); |
| 133 | // Archiving or closing the last formal surface leaves no tab behind (the |
| 134 | // backend opens no replacement blank session), so the draft of the workspace |
| 135 | // the user was working in becomes the landing surface. |
| 136 | const lastFormalTargetRef = useRef<DraftLandingTarget | null>(null); |
| 137 | useEffect(() => { |
| 138 | if (activeTab) lastFormalTargetRef.current = draftLandingTargetForTab(activeTab); |
| 139 | }, [activeTab]); |
| 140 | const hadFormalSurfaceRef = useRef(false); |
| 141 | useEffect(() => { |
| 142 | if (tabMetas.length > 0) { |
| 143 | hadFormalSurfaceRef.current = true; |
| 144 | return; |
| 145 | } |
| 146 | if (!hadFormalSurfaceRef.current) return; |
| 147 | hadFormalSurfaceRef.current = false; |
| 148 | const target = lastFormalTargetRef.current ?? draftLandingTargetForTab(); |
| 149 | void drafts.open(target.scope, target.workspaceRoot); |
| 150 | }, [drafts.open, tabMetas.length]); |
| 151 | |
| 152 | const session = useAppSessionComposition({ |
| 153 | runtime, |
| 154 | t, |
| 155 | showToast, |
| 156 | shell, |
| 157 | core: { |
| 158 | state, liveStore, activeTabId, notice, activeTab, remoteSurfaceActive, remoteSession, remoteComposerReady, |
| 159 | remoteSend, remoteCancel, activeSessionIdentity, sessionSurfaceFence, sessionOperations, |
| 160 | }, |
| 161 | surface: navigationSurface, |
| 162 | stores: { |
| 163 | composerProfilesByTab, setComposerProfilesByTab, tabMetas, setTabMetas, tabOrderIds, setTabOrderIds, |
| 164 | userPlanModeByTabRef, |
| 165 | }, |
| 166 | local: { |
| 167 | setHistView, setTabRevealSignal, |
| 168 | sidebarImDetailConnectionId, setSidebarImDetailConnectionId, |
| 169 | workspaceScopeActiveTabRef, workspaceControllerEpoch, setWorkspaceControllerEpoch, |
| 170 | dockRefreshKey, setDockRefreshKey, fileRefRefreshKey, setFileRefRefreshKey, projectRevision, setProjectRevision, |
| 171 | }, |
| 172 | goal: { runGoalAction, handleGoalActionError }, |
| 173 | }); |
| 174 | const navigation = useAppNavigationComposition({ |
| 175 | runtime, |
| 176 | t, |
| 177 | notice, |
| 178 | showToast, |
| 179 | shell, |
| 180 | state, |
| 181 | activeTab, |
| 182 | activeTabId, |
| 183 | activeSessionIdentity, |
| 184 | remoteSurfaceActive, |
| 185 | surface: navigationSurface, |
| 186 | local: { |
| 187 | setHistView, setProjectRevision, |
| 188 | setSidebarImDetailConnectionId, setTasksOpen, |
| 189 | }, |
| 190 | session, |
| 191 | draft: drafts, |
| 192 | }); |
| 193 | const openCleanupTrash = useCommittedCommand(() => navigation.historyCommands.openTrash()); |
| 194 | const cleanupNoticeBatchRef = useRef(""); |
| 195 | useEffect(() => { |
| 196 | let live = true; |
| 197 | const showCleanupNotice = (status: LegacyEmptySessionCleanupStatus) => { |
| 198 | if (!live || status.removed <= 0 || !status.batchId) return; |
| 199 | const storageKey = `reasonix.legacy-empty-session-cleanup.notice.${status.batchId}`; |
| 200 | let shown = false; |
| 201 | try { |
| 202 | shown = window.localStorage.getItem(storageKey) === "shown"; |
| 203 | } catch { |
| 204 | // Hardened webviews may disable storage. The in-memory fence still |
| 205 | // prevents duplicate notices for this renderer lifetime. |
| 206 | } |
| 207 | if (cleanupNoticeBatchRef.current === status.batchId || shown) return; |
| 208 | cleanupNoticeBatchRef.current = status.batchId; |
| 209 | try { window.localStorage.setItem(storageKey, "shown"); } catch { /* best effort */ } |
| 210 | showToast(t("history.legacyCleanupComplete", { n: status.removed }), "info", { |
| 211 | actionLabel: t("history.viewTrash"), |
| 212 | onAction: () => void openCleanupTrash(), |
| 213 | durationMs: 8000, |
| 214 | }); |
| 215 | }; |
| 216 | const unsubscribe = onLegacyEmptySessionCleanupChanged(showCleanupNotice); |
| 217 | void app.GetLegacyEmptySessionCleanupStatus().then(showCleanupNotice).catch(() => {}); |
| 218 | return () => { live = false; unsubscribe(); }; |
| 219 | }, [openCleanupTrash, showToast, t]); |
| 220 | acceptedDraftSessionRef.current = navigation.navigationCommands.openCanonicalSession; |
| 221 | |
| 222 | return ( |
| 223 | <Suspense fallback={<AppRuntimeViewFallback />}> |
| 224 | <AppRuntimeView |
| 225 | core={{ |
| 226 | state, activeTab, activeTabId, liveStore, remoteSurfaceActive, remoteSession, remoteComposerReady, |
| 227 | remoteCancel, surface: navigationSurface, t, locale, onOpenLink: openExternal, |
| 228 | }} |
| 229 | shell={shell} |
| 230 | session={session} |
| 231 | navigation={navigation} |
| 232 | runtime={runtime} |
| 233 | draft={drafts} |
| 234 | local={{ |
| 235 | tasksOpen, setTasksOpen, |
| 236 | sidebarImDetailConnectionId, setSidebarImDetailConnectionId, |
| 237 | tabRevealSignal, histView, |
| 238 | projectRevision, dockRefreshKey, composerFileRefRefreshKey, refreshComposerFileRefs, |
| 239 | terminalContentVisible, terminalFitEnabled, prefetchTerminalPanel, |
| 240 | }} |
| 241 | /> |
| 242 | </Suspense> |
| 243 | ); |
| 244 | } |
| 245 |