返回 DeepSeek-Reasonix
useSessionNavigationCommands.ts
根目录 / desktop / frontend / src / app-runtime / useSessionNavigationCommands.ts
1 import { useCommittedCommand } from "../lib/useCommittedCommand";
2 import { asArray } from "../lib/array";
3 import { resolveTaskMonitorSession } from "../lib/taskMonitorNavigation";
4 import { taskSessionIDFromPath, type SidebarImConnection } from "./sidebarImProjection";
5 import { draftLandingTargetForTab } from "./draftLandingTarget";
6 import type { useDesktopNavigation } from "./useDesktopNavigation";
7 import type { WorkspaceNavigationPorts } from "./navigationOwner";
8 import type { ControlResult, SessionMeta, TabMeta } from "../lib/types";
9 import type { TopicShortcutEntry } from "../lib/topicShortcuts";
10 import { useRef, useState, type Dispatch, type SetStateAction } from "react";
11 import type { SessionRef } from "../lib/sessionRef";
12 import type { ManualCreationObservation } from "../lib/manualCreationRequests";
13
14 const loadNavigationOwner = () => import("./navigationOwner");
15
16 export type SessionNavigationCommandsInput = {
17 activeTab: TabMeta | undefined;
18 showToast: (message: string, level: "error") => void;
19 closeTransientOverlays: () => void;
20 clearImDetail: () => void;
21 prepareBlankWorkspace: (workspaceRoot?: string) => void;
22 navigation: Pick<ReturnType<typeof useDesktopNavigation>, "enqueueNavigation" | "enqueueNavigationWithIntent" | "openRemoteProject">;
23 noteNavigationIntent: () => number;
24 beginNavigationSurface: (seq: number) => void;
25 settleNavigationSurface: (seq: number) => void;
26 isNavigationIntentCurrent: (seq: number) => boolean;
27 markProjectChanged: Dispatch<SetStateAction<number>>;
28 refreshTabMetas: (apply?: () => boolean, options?: { afterMutation?: boolean }) => Promise<unknown>;
29 refreshHistoryView: () => void;
30 enterConversation: () => void;
31 pickWorkspace: WorkspaceNavigationPorts["pickWorkspace"];
32 switchWorkspace: WorkspaceNavigationPorts["switchWorkspace"];
33 ports: {
34 openTaskSessionForTab(tabId: string, taskId: string): Promise<ControlResult>;
35 listSessionsForTab(tabId: string): Promise<SessionMeta[]>;
36 };
37 };
38
39 /**
40 * Owns explicit local creation and session navigation. Each creation is an
41 * independent mutation; only its selection follows the shared navigation
42 * epoch. Remote routing retains its own commands. Navigation never reads or
43 * waits for the retired workspace-draft store.
44 */
45 export function useSessionNavigationCommands(input: SessionNavigationCommandsInput) {
46 const { activeTab, showToast, navigation, ports } = input;
47 const lastLocalTarget = useRef(draftLandingTargetForTab(activeTab));
48 const [creation, setCreation] = useState<(ManualCreationObservation & { seq: number }) | null>(null);
49 if (activeTab && !activeTab.remote) lastLocalTarget.current = draftLandingTargetForTab(activeTab);
50
51 const blankSessionTarget = useCommittedCommand(() => lastLocalTarget.current);
52
53 const observeCreation = useCommittedCommand(async (attempt: ManualCreationObservation & { seq: number }, retry = false) => {
54 const { seq, request } = attempt;
55 const current = () => input.isNavigationIntentCurrent(seq) === true;
56 setCreation({ ...attempt, pending: true, failed: false });
57 try {
58 const { createManualSession } = await import("../lib/manualCreationRequests");
59 await createManualSession(request, {
60 retry,
61 isObservationCurrent: current,
62 onProgress: operation => { if (current()) setCreation({ ...attempt, operation, pending: true, failed: false }); },
63 onSurfaceReady: async (reserved) => {
64 if (current()) await navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref: reserved.ref }, seq);
65 },
66 });
67 input.markProjectChanged(value => value + 1);
68 } catch {
69 if (current()) setCreation(value => value?.seq === seq ? { ...value, failed: true } : value);
70 } finally {
71 if (current()) setCreation(value => value?.seq === seq ? { ...value, pending: false } : value);
72 input.settleNavigationSurface(seq);
73 }
74 });
75
76 const openBlankSession = useCommittedCommand(async (scope: string, workspaceRoot: string): Promise<void> => {
77 const seq = input.noteNavigationIntent();
78 const operationId = `manual-${crypto.randomUUID()}`;
79 const targetRoot = scope === "project" ? workspaceRoot : "";
80 lastLocalTarget.current = { scope: scope === "project" ? "project" : "global", workspaceRoot: targetRoot };
81 // UI preferences use the actual directory; global navigation uses an empty wire root.
82 input.prepareBlankWorkspace(workspaceRoot);
83 input.enterConversation();
84 input.beginNavigationSurface(seq);
85 // Creation is an explicit mutation, not a coalescible navigation request.
86 // Even if another click wins selection, this accepted operation survives.
87 await observeCreation({ seq, request: { operationId, workspaceId: "", scope, workspaceRoot: targetRoot }, pending: true, failed: false });
88 });
89 const retryCreation = useCommittedCommand(async () => {
90 if (creation && !creation.pending && input.isNavigationIntentCurrent(creation.seq)) await observeCreation(creation, true);
91 });
92
93 const handleNewTab = useCommittedCommand(async () => {
94 input.closeTransientOverlays();
95 input.clearImDetail();
96 if (activeTab?.remote) {
97 const navigationIntentSeq = input.noteNavigationIntent();
98 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
99 input.prepareBlankWorkspace();
100 const outcome = await navigation.openRemoteProject(activeTab.remote, { newSession: true });
101 if (outcome.status === "failed") showToast(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), "error");
102 return;
103 }
104 const target = blankSessionTarget();
105 await openBlankSession(target.scope, target.workspaceRoot);
106 });
107
108 const handleOpenTopic = useCommittedCommand(async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise<void> => {
109 const navigationIntentSeq = input.noteNavigationIntent();
110 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
111 input.closeTransientOverlays();
112 input.clearImDetail();
113 if (sessionPath?.startsWith("session-id:")) {
114 return navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref: { hostId: "local", sessionId: sessionPath.slice("session-id:".length) } }, navigationIntentSeq);
115 }
116 return navigation.enqueueNavigationWithIntent({ kind: "topic", scope, workspaceRoot, topicId, sessionPath }, navigationIntentSeq);
117 });
118
119 const openSidebarImConnectionSession = useCommittedCommand(async (connection: SidebarImConnection): Promise<void> => {
120 const navigationIntentSeq = input.noteNavigationIntent();
121 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
122 input.clearImDetail();
123 return navigation.enqueueNavigationWithIntent({ kind: "sidebar-im", connection }, navigationIntentSeq);
124 });
125
126 const onResumeSession = useCommittedCommand(async (session: SessionMeta): Promise<void> => {
127 const navigationIntentSeq = input.noteNavigationIntent();
128 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
129 return navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq);
130 });
131 const openCanonicalSession = useCommittedCommand(async (ref: SessionRef): Promise<void> => {
132 const navigationIntentSeq = input.noteNavigationIntent();
133 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
134 input.closeTransientOverlays();
135 input.clearImDetail();
136 return navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref }, navigationIntentSeq);
137 });
138
139 const onRecoveryCreated = useCommittedCommand(() => {
140 input.markProjectChanged((value) => value + 1);
141 void input.refreshTabMetas(undefined, { afterMutation: true });
142 });
143 const onRecoveryLineageChanged = useCommittedCommand(() => {
144 input.markProjectChanged((value) => value + 1);
145 input.refreshHistoryView();
146 });
147
148 const openTaskMonitorSession = useCommittedCommand(async (tabID: string, taskID: string): Promise<boolean> => {
149 const navigationIntentSeq = input.noteNavigationIntent();
150 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return false;
151 // Claim the navigation epoch before the first bridge await. If the user
152 // switches tabs while the task/session lookup is pending, its completion is
153 // stale and must not enqueue a newer navigation request.
154 input.beginNavigationSurface(navigationIntentSeq);
155 let session: SessionMeta | null;
156 try {
157 session = await resolveTaskMonitorSession({
158 tabID,
159 taskID,
160 intentSeq: navigationIntentSeq,
161 isIntentCurrent: input.isNavigationIntentCurrent,
162 openTaskSessionForTab: (sourceTabID, sourceTaskID) => ports.openTaskSessionForTab(sourceTabID, sourceTaskID),
163 listSessionsForTab: async (sourceTabID) => asArray(await ports.listSessionsForTab(sourceTabID)),
164 sessionIDFromPath: taskSessionIDFromPath,
165 });
166 } catch (error) {
167 input.settleNavigationSurface(navigationIntentSeq);
168 throw error;
169 }
170 if (!session) {
171 input.settleNavigationSurface(navigationIntentSeq);
172 return false;
173 }
174 await navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq);
175 return input.isNavigationIntentCurrent(navigationIntentSeq);
176 });
177
178 const refreshTabsAfterMutation = useCommittedCommand((latest: () => boolean) => (
179 input.refreshTabMetas(latest, { afterMutation: true })
180 ));
181 const switchFolder = useCommittedCommand(async (path?: string) => {
182 const navigationIntentSeq = input.noteNavigationIntent();
183 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
184 input.enterConversation();
185 return loadNavigationOwner().then(({ navigateWorkspace }) => navigateWorkspace(path, {
186 claimIntent: input.noteNavigationIntent,
187 beginSurface: input.beginNavigationSurface,
188 isIntentCurrent: input.isNavigationIntentCurrent,
189 pickWorkspace: input.pickWorkspace,
190 switchWorkspace: input.switchWorkspace,
191 markProjectChanged: input.markProjectChanged,
192 refreshTabsAfterMutation,
193 maskTarget: input.settleNavigationSurface,
194 }));
195 });
196
197 const handleNavigateTopic = useCommittedCommand((entry: TopicShortcutEntry) => {
198 void handleOpenTopic(entry.scope, entry.workspaceRoot, entry.topicId, entry.sessionPath);
199 });
200
201 return {
202 manualCreation: creation && input.isNavigationIntentCurrent(creation.seq) ? creation : null,
203 retryCreation,
204 openCanonicalSession,
205 openBlankSession,
206 handleNewTab,
207 handleOpenTopic,
208 openSidebarImConnectionSession,
209 onResumeSession,
210 onRecoveryCreated,
211 onRecoveryLineageChanged,
212 openTaskMonitorSession,
213 refreshTabsAfterMutation,
214 switchFolder,
215 handleNavigateTopic,
216 };
217 }
218
218 lines TYPESCRIPT