返回 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 type { Dispatch, SetStateAction } from "react";
11 import type { SessionRef } from "../lib/sessionRef";
12
13 const loadNavigationOwner = () => import("./navigationOwner");
14
15 export type SessionNavigationCommandsInput = {
16 activeTab: TabMeta | undefined;
17 showToast: (message: string, level: "error") => void;
18 closeTransientOverlays: () => void;
19 clearImDetail: () => void;
20 prepareBlankWorkspace: (workspaceRoot?: string) => void;
21 navigation: Pick<ReturnType<typeof useDesktopNavigation>, "enqueueNavigation" | "enqueueNavigationWithIntent" | "openRemoteProject">;
22 noteNavigationIntent: () => number;
23 beginNavigationSurface: (seq: number) => void;
24 settleNavigationSurface: (seq: number) => void;
25 isNavigationIntentCurrent: (seq: number) => boolean;
26 markProjectChanged: Dispatch<SetStateAction<number>>;
27 refreshTabMetas: (apply?: () => boolean, options?: { afterMutation?: boolean }) => Promise<unknown>;
28 refreshHistoryView: () => void;
29 enterConversation: () => void;
30 pickWorkspace: WorkspaceNavigationPorts["pickWorkspace"];
31 switchWorkspace: WorkspaceNavigationPorts["switchWorkspace"];
32 draft: {
33 target?: { scope: string; workspaceRoot: string };
34 open(scope: string, workspaceRoot: string): Promise<void>;
35 dismiss(): Promise<void> | void;
36 };
37 ports: {
38 openTaskSessionForTab(tabId: string, taskId: string): Promise<ControlResult>;
39 listSessionsForTab(tabId: string): Promise<SessionMeta[]>;
40 };
41 };
42
43 /**
44 * Owns the session-level navigation commands: local draft opening,
45 * topic/resume/sidebar-IM enqueues, new-tab routing (remote hosts reopen
46 * remotely), recovery refresh pairs, folder switching through the lazy
47 * navigation owner and the task-monitor session lookup with its
48 * navigation-intent fence. Formal-session navigation coalesces through the
49 * shared navigation epoch from useDesktopNavigation; local drafts use their
50 * own target/revision fencing.
51 */
52 export function useSessionNavigationCommands(input: SessionNavigationCommandsInput) {
53 const { activeTab, showToast, navigation, ports } = input;
54
55 const blankSessionTarget = useCommittedCommand(() => input.draft.target ?? draftLandingTargetForTab(activeTab));
56
57 const openBlankSession = useCommittedCommand((scope: string, workspaceRoot: string): Promise<void> => {
58 const targetRoot = scope === "project" ? workspaceRoot : "";
59 // UI preferences use the actual directory; global navigation uses an empty wire root.
60 input.prepareBlankWorkspace(workspaceRoot);
61 input.enterConversation();
62 return input.draft.open(scope, targetRoot);
63 });
64
65 const handleNewTab = useCommittedCommand(async () => {
66 input.closeTransientOverlays();
67 input.clearImDetail();
68 if (input.draft.target) {
69 const target = input.draft.target;
70 await openBlankSession(target.scope, target.workspaceRoot);
71 return;
72 }
73 if (activeTab?.remote) {
74 const navigationIntentSeq = input.noteNavigationIntent();
75 await input.draft.dismiss();
76 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
77 input.prepareBlankWorkspace();
78 const outcome = await navigation.openRemoteProject(activeTab.remote, { newSession: true });
79 if (outcome.status === "failed") showToast(outcome.error instanceof Error ? outcome.error.message : String(outcome.error), "error");
80 return;
81 }
82 const target = blankSessionTarget();
83 await openBlankSession(target.scope, target.workspaceRoot);
84 });
85
86 const handleOpenTopic = useCommittedCommand(async (scope: string, workspaceRoot: string, topicId: string, sessionPath?: string): Promise<void> => {
87 const navigationIntentSeq = input.noteNavigationIntent();
88 await input.draft.dismiss();
89 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
90 input.closeTransientOverlays();
91 input.clearImDetail();
92 if (sessionPath?.startsWith("session-id:")) {
93 return navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref: { hostId: "local", sessionId: sessionPath.slice("session-id:".length) } }, navigationIntentSeq);
94 }
95 return navigation.enqueueNavigationWithIntent({ kind: "topic", scope, workspaceRoot, topicId, sessionPath }, navigationIntentSeq);
96 });
97
98 const openSidebarImConnectionSession = useCommittedCommand(async (connection: SidebarImConnection): Promise<void> => {
99 const navigationIntentSeq = input.noteNavigationIntent();
100 await input.draft.dismiss();
101 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
102 input.clearImDetail();
103 return navigation.enqueueNavigationWithIntent({ kind: "sidebar-im", connection }, navigationIntentSeq);
104 });
105
106 const onResumeSession = useCommittedCommand(async (session: SessionMeta): Promise<void> => {
107 const navigationIntentSeq = input.noteNavigationIntent();
108 await input.draft.dismiss();
109 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
110 return navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq);
111 });
112 const openCanonicalSession = useCommittedCommand(async (ref: SessionRef): Promise<void> => {
113 const navigationIntentSeq = input.noteNavigationIntent();
114 await input.draft.dismiss();
115 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
116 input.closeTransientOverlays();
117 input.clearImDetail();
118 return navigation.enqueueNavigationWithIntent({ kind: "canonical-session", ref }, navigationIntentSeq);
119 });
120
121 const onRecoveryCreated = useCommittedCommand(() => {
122 input.markProjectChanged((value) => value + 1);
123 void input.refreshTabMetas(undefined, { afterMutation: true });
124 });
125 const onRecoveryLineageChanged = useCommittedCommand(() => {
126 input.markProjectChanged((value) => value + 1);
127 input.refreshHistoryView();
128 });
129
130 const openTaskMonitorSession = useCommittedCommand(async (tabID: string, taskID: string): Promise<boolean> => {
131 const navigationIntentSeq = input.noteNavigationIntent();
132 await input.draft.dismiss();
133 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return false;
134 // Claim the navigation epoch before the first bridge await. If the user
135 // switches tabs while the task/session lookup is pending, its completion is
136 // stale and must not enqueue a newer navigation request.
137 input.beginNavigationSurface(navigationIntentSeq);
138 let session: SessionMeta | null;
139 try {
140 session = await resolveTaskMonitorSession({
141 tabID,
142 taskID,
143 intentSeq: navigationIntentSeq,
144 isIntentCurrent: input.isNavigationIntentCurrent,
145 openTaskSessionForTab: (sourceTabID, sourceTaskID) => ports.openTaskSessionForTab(sourceTabID, sourceTaskID),
146 listSessionsForTab: async (sourceTabID) => asArray(await ports.listSessionsForTab(sourceTabID)),
147 sessionIDFromPath: taskSessionIDFromPath,
148 });
149 } catch (error) {
150 input.settleNavigationSurface(navigationIntentSeq);
151 throw error;
152 }
153 if (!session) {
154 input.settleNavigationSurface(navigationIntentSeq);
155 return false;
156 }
157 await navigation.enqueueNavigationWithIntent({ kind: "resume-session", session }, navigationIntentSeq);
158 return input.isNavigationIntentCurrent(navigationIntentSeq);
159 });
160
161 const refreshTabsAfterMutation = useCommittedCommand((latest: () => boolean) => (
162 input.refreshTabMetas(latest, { afterMutation: true })
163 ));
164 const switchFolder = useCommittedCommand(async (path?: string) => {
165 const navigationIntentSeq = input.noteNavigationIntent();
166 await input.draft.dismiss();
167 if (!input.isNavigationIntentCurrent(navigationIntentSeq)) return;
168 input.enterConversation();
169 return loadNavigationOwner().then(({ navigateWorkspace }) => navigateWorkspace(path, {
170 claimIntent: input.noteNavigationIntent,
171 beginSurface: input.beginNavigationSurface,
172 isIntentCurrent: input.isNavigationIntentCurrent,
173 pickWorkspace: input.pickWorkspace,
174 switchWorkspace: input.switchWorkspace,
175 markProjectChanged: input.markProjectChanged,
176 refreshTabsAfterMutation,
177 maskTarget: input.settleNavigationSurface,
178 }));
179 });
180
181 const handleNavigateTopic = useCommittedCommand((entry: TopicShortcutEntry) => {
182 void handleOpenTopic(entry.scope, entry.workspaceRoot, entry.topicId, entry.sessionPath);
183 });
184
185 return {
186 openCanonicalSession,
187 openBlankSession,
188 handleNewTab,
189 handleOpenTopic,
190 openSidebarImConnectionSession,
191 onResumeSession,
192 onRecoveryCreated,
193 onRecoveryLineageChanged,
194 openTaskMonitorSession,
195 refreshTabsAfterMutation,
196 switchFolder,
197 handleNavigateTopic,
198 };
199 }
200
200 lines TYPESCRIPT