返回 DeepSeek-Reasonix
AppRuntime.tsx
根目录 / desktop / frontend / src / AppRuntime.tsx
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
245 lines Plain Text