返回 DeepSeek-Reasonix
decisionFooterBuilders.ts
根目录 / desktop / frontend / src / app-shell / decisionFooterBuilders.ts
1 import type { Todo } from "../lib/tools";
2 import { modelSettingsAllowSubmission } from "../lib/authenticationTypes";
3 import { sessionIdentityFields, sessionIdentityRoute } from "../lib/sessionIdentity";
4 import type { RewindUndoState } from "../lib/rewindTypes";
5 import type { WorkspaceConflictView } from "../lib/types";
6 import type { DecisionSurfaceKind as MockDecisionSurfaceKind } from "../lib/decisionSurfaceMock";
7 import type { Translator } from "../lib/i18n";
8 import type { projectConversation } from "../app-runtime/conversationProjection";
9 import type { useSessionPromptCommands } from "../app-runtime/useSessionPromptCommands";
10 import type { useExtensionSurface } from "../app-runtime/useExtensionSurface";
11 import type { useSessionClearCommands } from "../app-runtime/useSessionClearCommands";
12 import type { useTabBarCommands } from "../app-runtime/useTabBarCommands";
13 import type { useComposerProfileProjection } from "../app-runtime/useComposerProfileProjection";
14 import type { useComposerInsertCommands } from "../app-runtime/useComposerInsertCommands";
15 import type { useComposerModeActions } from "../lib/useComposerModeActions";
16 import type { useComposerGoalCommands } from "../app-runtime/useComposerGoalCommands";
17 import type { useRemoteComposerRuntimeActions } from "../lib/useRemoteComposerIntegration";
18 import type { useControllerProfileCommands } from "../lib/useControllerProfileCommands";
19 import type {
20 ApprovalProps,
21 AskProps,
22 ComposerProps,
23 DecisionFooterRegionProps,
24 DecisionFooterSurface,
25 ExtensionProps,
26 McpProps,
27 RuntimeDecisionProps,
28 TodoProps,
29 } from "./DecisionFooterRegion";
30
31 type SurfaceKind = MockDecisionSurfaceKind | "extension_form";
32 type ComposerBase = ReturnType<typeof projectConversation>["composer"];
33 type PromptCommands = ReturnType<typeof useSessionPromptCommands>;
34 type ExtensionSurfaceApi = ReturnType<typeof useExtensionSurface>;
35 type ClearCommands = Pick<ReturnType<typeof useSessionClearCommands>, "cancelClearContext" | "confirmClearContext">;
36 type TabBarApi = Pick<ReturnType<typeof useTabBarCommands>, "pendingClose" | "setPendingClose" | "resolvePendingClose" | "revealWorkspaceWriter" | "continueInDeliveryWorktree">;
37
38 /** Pure prop builders for DecisionFooterRegion; every closure keeps the exact
39 * handler identity and branching the App body previously assembled inline. */
40
41 export function buildFooterTodo(input: {
42 show: boolean;
43 identity: string;
44 todos: Todo[];
45 running: boolean;
46 pendingPrompt: boolean;
47 continueReady: boolean;
48 onContinue: TodoProps["onContinue"];
49 onDismiss: TodoProps["onDismiss"];
50 }): DecisionFooterRegionProps["todo"] {
51 if (!input.show) return undefined;
52 return {
53 identity: input.identity,
54 props: {
55 stateKey: input.identity,
56 todos: input.todos,
57 running: input.running,
58 pendingPrompt: input.pendingPrompt,
59 onContinue: input.continueReady ? input.onContinue : undefined,
60 onDismiss: input.onDismiss,
61 },
62 };
63 }
64
65 export function buildFooterUndo(input: {
66 rewindState: RewindUndoState | null;
67 activeTabId: string | undefined;
68 onUndo: () => void;
69 }): DecisionFooterRegionProps["undo"] {
70 const { rewindState } = input;
71 if (!rewindState) return undefined;
72 return {
73 identity: `${input.activeTabId ?? ""}:${rewindState.transactionId ?? "rewind"}`,
74 props: {
75 meta: {
76 turns: rewindState.turnDiff,
77 filesRestored: rewindState.filesRestored ?? [],
78 filesRemoved: rewindState.filesRemoved ?? [],
79 onUndo: input.onUndo,
80 },
81 },
82 };
83 }
84
85 export type DecisionFooterSurfaceInput = {
86 view: {
87 surface: SurfaceKind | null;
88 activeTabId: string | undefined;
89 cwd: string | undefined;
90 workspaceScopeKey: string;
91 approval: ApprovalProps["approval"] | null | undefined;
92 ask: AskProps["ask"] | null | undefined;
93 mcpInteraction: McpProps["interaction"] | null | undefined;
94 extensionForm: ExtensionProps["surface"] | null | undefined;
95 workspaceConflict: WorkspaceConflictView | null;
96 toolApprovalMode: ApprovalProps["toolApprovalMode"];
97 insertRequest: ApprovalProps["insertRequest"];
98 };
99 prompts: PromptCommands;
100 extension: ExtensionSurfaceApi;
101 tabs: TabBarApi;
102 clear: ClearCommands;
103 onStop: ApprovalProps["onStop"];
104 cancelWorkspaceConflict: RuntimeDecisionProps["onCancel"];
105 onOpenLink: McpProps["onOpenLink"];
106 onRevisionActiveChange: ApprovalProps["onRevisionActiveChange"];
107 t: Translator;
108 };
109
110 export function buildDecisionFooterSurface(input: DecisionFooterSurfaceInput): DecisionFooterSurface | undefined {
111 const { view, prompts, extension, tabs, clear, t } = input;
112 const { surface, activeTabId } = view;
113 if ((surface === "tool_approval" || surface === "plan_approval") && view.approval) {
114 return {
115 kind: "approval",
116 identity: prompts.approvalTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.approval.id}`,
117 props: {
118 approval: view.approval,
119 cwd: view.cwd,
120 tabId: activeTabId,
121 workspaceScopeKey: view.workspaceScopeKey,
122 insertRequest: view.insertRequest,
123 onRevisionActiveChange: input.onRevisionActiveChange,
124 onAnswer: prompts.handleApprovalAnswer,
125 onResolveRecovery: prompts.handleRecoveryAnswer,
126 onRevisePlan: prompts.handleRevisePlan,
127 onExitPlan: prompts.handleExitPlan,
128 onStop: input.onStop,
129 toolApprovalMode: view.toolApprovalMode,
130 },
131 };
132 }
133 if (surface === "ask" && view.ask) {
134 return {
135 kind: "ask",
136 identity: prompts.questionTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.ask.id}`,
137 props: {
138 ask: view.ask,
139 draftScope: prompts.questionTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.ask.id}`,
140 onAnswer: prompts.handleQuestionAnswer,
141 onDismiss: prompts.handleQuestionDismiss,
142 onStop: input.onStop,
143 },
144 };
145 }
146 if (surface === "mcp_interaction" && view.mcpInteraction) {
147 return {
148 kind: "mcp",
149 identity: prompts.mcpTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.mcpInteraction.id}`,
150 props: {
151 interaction: view.mcpInteraction,
152 instanceKey: prompts.mcpTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.mcpInteraction.id}`,
153 busy: false,
154 onAnswer: prompts.handleMCPAnswer,
155 onOpenLink: input.onOpenLink,
156 },
157 };
158 }
159 if (surface === "extension_form" && view.extensionForm) {
160 return {
161 kind: "extension",
162 identity: `${activeTabId ?? ""}:${view.extensionForm.pluginId}:${view.extensionForm.surfaceId}:${view.extensionForm.formInstanceId}`,
163 props: {
164 surface: view.extensionForm,
165 busy: extension.extensionFormBusy,
166 onSubmit: (values) => void extension.submitExtensionForm(values),
167 onCancel: () => void extension.cancelExtensionForm(),
168 },
169 };
170 }
171 if (surface === "workspace_conflict" && view.workspaceConflict) {
172 const workspaceConflict = view.workspaceConflict;
173 return {
174 kind: "runtime",
175 identity: "workspace-conflict",
176 props: {
177 id: "workspace-conflict",
178 title: t("runtime.workspaceConflictTitle"),
179 badge: t("runtime.workspaceConflictBadge"),
180 meta: workspaceConflict.state === "local"
181 ? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask"), label: workspaceConflict.ownerLabel || t("workspace.title") })
182 : t("runtime.workspaceConflictExternal"),
183 note: t("runtime.workspaceConflictNote"),
184 onCancel: input.cancelWorkspaceConflict,
185 actions: [
186 ...(workspaceConflict.canReveal ? [{
187 key: "1", label: t("runtime.revealWriter"), description: t("runtime.revealWriterDesc"),
188 onClick: () => void tabs.revealWorkspaceWriter(),
189 }] : []),
190 ...(workspaceConflict.canCreateWorktree ? [{
191 key: "2", label: t("runtime.openWorktree"), description: t("runtime.openWorktreeDesc"),
192 onClick: () => void tabs.continueInDeliveryWorktree(),
193 }] : []),
194 ],
195 secondaryAction: {
196 key: "Esc", label: t("runtime.cancelWait"), description: t("runtime.cancelWaitDesc"),
197 onClick: input.cancelWorkspaceConflict,
198 },
199 },
200 };
201 }
202 if (surface === "close_active" && tabs.pendingClose) {
203 const pendingClose = tabs.pendingClose;
204 return {
205 kind: "runtime",
206 identity: "close-active",
207 props: {
208 id: "close-active",
209 title: t("runtime.closeTitle"),
210 badge: t("status.jobs", { n: pendingClose.work.jobs.length }),
211 meta: t("runtime.closeMeta"),
212 onCancel: () => tabs.setPendingClose(null),
213 actions: [
214 {
215 key: "1", label: t("runtime.keepRunning"), description: t("runtime.keepRunningDesc"),
216 onClick: () => void tabs.resolvePendingClose("keep_running"), disabled: pendingClose.stopping,
217 },
218 {
219 key: "2", label: pendingClose.stopping ? t("status.jobStopping") : t("runtime.stopAndClose"),
220 description: t("runtime.stopAndCloseDesc"), onClick: () => void tabs.resolvePendingClose("stop_and_close"),
221 danger: true, disabled: pendingClose.stopping,
222 },
223 ],
224 secondaryAction: {
225 key: "Esc", label: t("runtime.returnToTask"), description: t("runtime.closeCancelDesc"),
226 onClick: () => tabs.setPendingClose(null), disabled: pendingClose.stopping,
227 },
228 },
229 };
230 }
231 if (surface === "clear_context") {
232 return {
233 kind: "clear-context",
234 identity: "clear-context",
235 props: { onCancel: clear.cancelClearContext, onConfirm: () => void clear.confirmClearContext() },
236 };
237 }
238 return undefined;
239 }
240
241 export type ComposerSurfaceInput = {
242 empty?: { onCreate: () => void; onChooseProject: () => void };
243 view: {
244 hidden: boolean;
245 inert: boolean;
246 targetInputReady?: boolean;
247 hero: boolean;
248 headline: string;
249 remote: boolean;
250 rewindCommitting: boolean;
251 messageActionPending: boolean;
252 decisionActive: boolean;
253 runtimeTransitioning: boolean;
254 controllerReady: boolean;
255 showContextWindowRing: boolean;
256 submitDisabledReason?: string;
257 };
258 base: ComposerBase;
259 tab: { readOnly?: boolean; session?: import("../lib/sessionRef").SessionRef | null; sessionPath?: string; workspaceRoot?: string; authentication?: ComposerProps["authentication"]; modelSettingsPending?: boolean; remote?: { hostId: string; workspace: string } } | undefined;
260 tabId: string | undefined;
261 profile: ReturnType<typeof useComposerProfileProjection>;
262 router: { handleSend: ComposerProps["onSend"]; handleSteer: ComposerProps["onSteer"] };
263 modes: ReturnType<typeof useComposerModeActions>;
264 goals: ReturnType<typeof useComposerGoalCommands>;
265 remoteGoal: ReturnType<typeof useRemoteComposerRuntimeActions>;
266 modelSwitch: Pick<ReturnType<typeof useControllerProfileCommands>, "switchModelFromUi">;
267 inserts: Pick<ReturnType<typeof useComposerInsertCommands>, "composerInsertRequest" | "selectedTextRequest">;
268 control: { handleCancelActive: ComposerProps["onCancel"] };
269 remoteComposer: {
270 send: ComposerProps["onSend"];
271 cancel: ComposerProps["onCancel"];
272 ready: boolean;
273 profileReady: boolean;
274 liveStore: ComposerProps["liveStore"];
275 };
276 localLiveStore: ComposerProps["liveStore"];
277 onInvocationMetadataChange: ComposerProps["onInvocationMetadataChange"];
278 onCycleMode: ComposerProps["onCycleMode"];
279 transientDismissSignal: ComposerProps["transientDismissSignal"];
280 sessionKey: ComposerProps["sessionKey"];
281 workspaceScopeKey: ComposerProps["workspaceScopeKey"];
282 workspaceContext: ComposerProps["workspaceContext"];
283 fileRefRefreshKey: ComposerProps["fileRefRefreshKey"];
284 guidance: { key: string; itemId?: string; text: string } | null;
285 guidanceQueuePreviewItems: ComposerProps["guidanceQueuePreviewItems"];
286 };
287
288 export function buildComposerSurface(input: ComposerSurfaceInput): DecisionFooterRegionProps["composer"] {
289 const { base, view, profile, router, modes, goals, remoteGoal, modelSwitch, inserts, control, remoteComposer } = input;
290 const surface: DecisionFooterRegionProps["composer"] = {
291 empty: !input.tabId ? input.empty : undefined,
292 hidden: view.hidden,
293 // A bound local session owns its saved input even while its runtime starts.
294 // The old surface remains inert until navigation binds the new identity.
295 inert: view.inert && (view.remote || !view.targetInputReady),
296 hero: view.hero,
297 headline: view.headline,
298 props: {
299 ...base,
300 running: base.running || (!view.remote && view.rewindCommitting),
301 collaborationMode: profile.collaborationMode,
302 toolApprovalMode: profile.toolApprovalMode,
303 goal: profile.goal,
304 tabId: input.tabId,
305 formalSessionRef: view.remote ? undefined : input.tab?.session ?? undefined,
306 sessionIdentity: input.tab ? sessionIdentityFields(input.tab) : undefined,
307 workspaceRoot: input.tab?.workspaceRoot,
308 onSend: view.remote ? remoteComposer.send : router.handleSend,
309 onInvocationMetadataChange: input.onInvocationMetadataChange,
310 onSteer: router.handleSteer,
311 onCancel: view.remote ? remoteComposer.cancel : control.handleCancelActive,
312 onCycleMode: input.onCycleMode,
313 onSetMode: modes.applyMode,
314 onSetCollaborationMode: goals.setCollaborationModeFromUi,
315 onSetToolApprovalMode: modes.applyToolApprovalMode,
316 onClearGoal: goals.clearGoalFromUi,
317 onEditGoal: goals.editGoalFromUi,
318 onPauseGoal: remoteGoal.pauseGoal,
319 onResumeGoal: remoteGoal.resumeGoal,
320 onSwitchModel: modelSwitch.switchModelFromUi,
321 onSetEffort: remoteGoal.setEffort,
322 insertRequest: inserts.composerInsertRequest,
323 selectedTextRequest: inserts.selectedTextRequest,
324 readOnly: Boolean(input.tab?.readOnly),
325 disabled: view.runtimeTransitioning || view.rewindCommitting || view.messageActionPending || view.decisionActive,
326 submitDisabled: view.remote ? !remoteComposer.ready || !remoteComposer.profileReady : !modelSettingsAllowSubmission(view.controllerReady, input.tab),
327 submitDisabledReason: view.submitDisabledReason,
328 authentication: view.remote ? undefined : input.tab?.authentication,
329 decisionPending: view.rewindCommitting || view.messageActionPending || view.decisionActive,
330 ready: view.remote ? remoteComposer.ready && remoteComposer.profileReady : view.controllerReady,
331 liveStore: view.remote ? remoteComposer.liveStore : input.localLiveStore,
332 suspendedByDecision: view.decisionActive,
333 transientDismissSignal: input.transientDismissSignal,
334 sessionKey: input.sessionKey,
335 inboxSessionPath: sessionIdentityRoute(input.tab),
336 inboxHostId: input.tab?.remote?.hostId,
337 inboxWorkspace: input.tab?.remote?.workspace,
338 workspaceScopeKey: input.workspaceScopeKey,
339 // Match the workspace launcher's ownership boundary: project/branch
340 // selection configures a new empty session. Once the transcript has
341 // content, the session keeps its established workspace and the composer
342 // returns to the compact follow-up layout.
343 workspaceContext: view.hero ? input.workspaceContext : undefined,
344 fileRefRefreshKey: input.fileRefRefreshKey,
345 guidanceConsumedKey: input.guidance?.key,
346 guidanceConsumedItemId: input.guidance?.itemId,
347 guidanceConsumedText: input.guidance?.text,
348 guidanceQueuePreviewItems: input.guidanceQueuePreviewItems,
349 showContextWindowRing: view.showContextWindowRing,
350 heroMode: view.hero && view.showContextWindowRing,
351 },
352 };
353 return surface;
354 }
355
355 lines TYPESCRIPT