返回 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 type { RewindUndoState } from "../lib/rewindTypes";
4 import type { WorkspaceConflictView } from "../lib/types";
5 import type { DecisionSurfaceKind as MockDecisionSurfaceKind } from "../lib/decisionSurfaceMock";
6 import type { Translator } from "../lib/i18n";
7 import type { projectConversation } from "../app-runtime/conversationProjection";
8 import type { useSessionPromptCommands } from "../app-runtime/useSessionPromptCommands";
9 import type { useExtensionSurface } from "../app-runtime/useExtensionSurface";
10 import type { useSessionClearCommands } from "../app-runtime/useSessionClearCommands";
11 import type { useTabBarCommands } from "../app-runtime/useTabBarCommands";
12 import type { useComposerProfileProjection } from "../app-runtime/useComposerProfileProjection";
13 import type { useComposerInsertCommands } from "../app-runtime/useComposerInsertCommands";
14 import type { useComposerModeActions } from "../lib/useComposerModeActions";
15 import type { useComposerGoalCommands } from "../app-runtime/useComposerGoalCommands";
16 import type { useRemoteComposerRuntimeActions } from "../lib/useRemoteComposerIntegration";
17 import type { useControllerProfileCommands } from "../lib/useControllerProfileCommands";
18 import { draftSubmissionLocksEditing, type useSessionDraftSurface } from "../app-runtime/useSessionDraftSurface";
19 import { draftSurfaceNeedsAttention } from "./draftPresentation";
20 import type {
21 ApprovalProps,
22 AskProps,
23 ComposerProps,
24 DecisionFooterRegionProps,
25 DecisionFooterSurface,
26 ExtensionProps,
27 McpProps,
28 RuntimeDecisionProps,
29 TodoProps,
30 } from "./DecisionFooterRegion";
31
32 type SurfaceKind = MockDecisionSurfaceKind | "extension_form";
33 type ComposerBase = ReturnType<typeof projectConversation>["composer"];
34 type PromptCommands = ReturnType<typeof useSessionPromptCommands>;
35 type ExtensionSurfaceApi = ReturnType<typeof useExtensionSurface>;
36 type ClearCommands = Pick<ReturnType<typeof useSessionClearCommands>, "cancelClearContext" | "confirmClearContext">;
37 type TabBarApi = Pick<ReturnType<typeof useTabBarCommands>, "pendingClose" | "setPendingClose" | "resolvePendingClose" | "revealWorkspaceWriter" | "continueInDeliveryWorktree">;
38
39 /** Pure prop builders for DecisionFooterRegion; every closure keeps the exact
40 * handler identity and branching the App body previously assembled inline. */
41
42 export function buildFooterTodo(input: {
43 show: boolean;
44 identity: string;
45 todos: Todo[];
46 running: boolean;
47 pendingPrompt: boolean;
48 continueReady: boolean;
49 onContinue: TodoProps["onContinue"];
50 onDismiss: TodoProps["onDismiss"];
51 }): DecisionFooterRegionProps["todo"] {
52 if (!input.show) return undefined;
53 return {
54 identity: input.identity,
55 props: {
56 stateKey: input.identity,
57 todos: input.todos,
58 running: input.running,
59 pendingPrompt: input.pendingPrompt,
60 onContinue: input.continueReady ? input.onContinue : undefined,
61 onDismiss: input.onDismiss,
62 },
63 };
64 }
65
66 export function buildFooterUndo(input: {
67 rewindState: RewindUndoState | null;
68 activeTabId: string | undefined;
69 onUndo: () => void;
70 }): DecisionFooterRegionProps["undo"] {
71 const { rewindState } = input;
72 if (!rewindState) return undefined;
73 return {
74 identity: `${input.activeTabId ?? ""}:${rewindState.transactionId ?? "rewind"}`,
75 props: {
76 meta: {
77 turns: rewindState.turnDiff,
78 filesRestored: rewindState.filesRestored ?? [],
79 filesRemoved: rewindState.filesRemoved ?? [],
80 onUndo: input.onUndo,
81 },
82 },
83 };
84 }
85
86 export type DecisionFooterSurfaceInput = {
87 view: {
88 surface: SurfaceKind | null;
89 activeTabId: string | undefined;
90 cwd: string | undefined;
91 workspaceScopeKey: string;
92 approval: ApprovalProps["approval"] | null | undefined;
93 ask: AskProps["ask"] | null | undefined;
94 mcpInteraction: McpProps["interaction"] | null | undefined;
95 extensionForm: ExtensionProps["surface"] | null | undefined;
96 workspaceConflict: WorkspaceConflictView | null;
97 toolApprovalMode: ApprovalProps["toolApprovalMode"];
98 insertRequest: ApprovalProps["insertRequest"];
99 };
100 prompts: PromptCommands;
101 extension: ExtensionSurfaceApi;
102 tabs: TabBarApi;
103 clear: ClearCommands;
104 onStop: ApprovalProps["onStop"];
105 cancelWorkspaceConflict: RuntimeDecisionProps["onCancel"];
106 onOpenLink: McpProps["onOpenLink"];
107 onRevisionActiveChange: ApprovalProps["onRevisionActiveChange"];
108 t: Translator;
109 };
110
111 export function buildDecisionFooterSurface(input: DecisionFooterSurfaceInput): DecisionFooterSurface | undefined {
112 const { view, prompts, extension, tabs, clear, t } = input;
113 const { surface, activeTabId } = view;
114 if ((surface === "tool_approval" || surface === "plan_approval") && view.approval) {
115 return {
116 kind: "approval",
117 identity: prompts.approvalTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.approval.id}`,
118 props: {
119 approval: view.approval,
120 cwd: view.cwd,
121 tabId: activeTabId,
122 workspaceScopeKey: view.workspaceScopeKey,
123 insertRequest: view.insertRequest,
124 onRevisionActiveChange: input.onRevisionActiveChange,
125 onAnswer: prompts.handleApprovalAnswer,
126 onResolveRecovery: prompts.handleRecoveryAnswer,
127 onRevisePlan: prompts.handleRevisePlan,
128 onExitPlan: prompts.handleExitPlan,
129 onStop: input.onStop,
130 toolApprovalMode: view.toolApprovalMode,
131 },
132 };
133 }
134 if (surface === "ask" && view.ask) {
135 return {
136 kind: "ask",
137 identity: prompts.questionTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.ask.id}`,
138 props: {
139 ask: view.ask,
140 draftScope: prompts.questionTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.ask.id}`,
141 onAnswer: prompts.handleQuestionAnswer,
142 onDismiss: prompts.handleQuestionDismiss,
143 onStop: input.onStop,
144 },
145 };
146 }
147 if (surface === "mcp_interaction" && view.mcpInteraction) {
148 return {
149 kind: "mcp",
150 identity: prompts.mcpTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.mcpInteraction.id}`,
151 props: {
152 interaction: view.mcpInteraction,
153 instanceKey: prompts.mcpTarget?.instanceKey ?? `${activeTabId ?? ""}:${view.mcpInteraction.id}`,
154 busy: false,
155 onAnswer: prompts.handleMCPAnswer,
156 onOpenLink: input.onOpenLink,
157 },
158 };
159 }
160 if (surface === "extension_form" && view.extensionForm) {
161 return {
162 kind: "extension",
163 identity: `${activeTabId ?? ""}:${view.extensionForm.pluginId}:${view.extensionForm.surfaceId}:${view.extensionForm.formInstanceId}`,
164 props: {
165 surface: view.extensionForm,
166 busy: extension.extensionFormBusy,
167 onSubmit: (values) => void extension.submitExtensionForm(values),
168 onCancel: () => void extension.cancelExtensionForm(),
169 },
170 };
171 }
172 if (surface === "workspace_conflict" && view.workspaceConflict) {
173 const workspaceConflict = view.workspaceConflict;
174 return {
175 kind: "runtime",
176 identity: "workspace-conflict",
177 props: {
178 id: "workspace-conflict",
179 title: t("runtime.workspaceConflictTitle"),
180 badge: t("runtime.workspaceConflictBadge"),
181 meta: workspaceConflict.state === "local"
182 ? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask"), label: workspaceConflict.ownerLabel || t("workspace.title") })
183 : t("runtime.workspaceConflictExternal"),
184 note: t("runtime.workspaceConflictNote"),
185 onCancel: input.cancelWorkspaceConflict,
186 actions: [
187 ...(workspaceConflict.canReveal ? [{
188 key: "1", label: t("runtime.revealWriter"), description: t("runtime.revealWriterDesc"),
189 onClick: () => void tabs.revealWorkspaceWriter(),
190 }] : []),
191 ...(workspaceConflict.canCreateWorktree ? [{
192 key: "2", label: t("runtime.openWorktree"), description: t("runtime.openWorktreeDesc"),
193 onClick: () => void tabs.continueInDeliveryWorktree(),
194 }] : []),
195 ],
196 secondaryAction: {
197 key: "Esc", label: t("runtime.cancelWait"), description: t("runtime.cancelWaitDesc"),
198 onClick: input.cancelWorkspaceConflict,
199 },
200 },
201 };
202 }
203 if (surface === "close_active" && tabs.pendingClose) {
204 const pendingClose = tabs.pendingClose;
205 return {
206 kind: "runtime",
207 identity: "close-active",
208 props: {
209 id: "close-active",
210 title: t("runtime.closeTitle"),
211 badge: t("status.jobs", { n: pendingClose.work.jobs.length }),
212 meta: t("runtime.closeMeta"),
213 onCancel: () => tabs.setPendingClose(null),
214 actions: [
215 {
216 key: "1", label: t("runtime.keepRunning"), description: t("runtime.keepRunningDesc"),
217 onClick: () => void tabs.resolvePendingClose("keep_running"), disabled: pendingClose.stopping,
218 },
219 {
220 key: "2", label: pendingClose.stopping ? t("status.jobStopping") : t("runtime.stopAndClose"),
221 description: t("runtime.stopAndCloseDesc"), onClick: () => void tabs.resolvePendingClose("stop_and_close"),
222 danger: true, disabled: pendingClose.stopping,
223 },
224 ],
225 secondaryAction: {
226 key: "Esc", label: t("runtime.returnToTask"), description: t("runtime.closeCancelDesc"),
227 onClick: () => tabs.setPendingClose(null), disabled: pendingClose.stopping,
228 },
229 },
230 };
231 }
232 if (surface === "clear_context") {
233 return {
234 kind: "clear-context",
235 identity: "clear-context",
236 props: { onCancel: clear.cancelClearContext, onConfirm: () => void clear.confirmClearContext() },
237 };
238 }
239 return undefined;
240 }
241
242 export type ComposerSurfaceInput = {
243 view: {
244 hidden: boolean;
245 inert: boolean;
246 hero: boolean;
247 headline: string;
248 remote: boolean;
249 rewindCommitting: boolean;
250 messageActionPending: boolean;
251 decisionActive: boolean;
252 runtimeTransitioning: boolean;
253 controllerReady: boolean;
254 showContextWindowRing: boolean;
255 submitDisabledReason?: string;
256 draftHint?: string;
257 };
258 base: ComposerBase;
259 tab: { readOnly?: boolean; 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 draft?: ReturnType<typeof useSessionDraftSurface>;
287 };
288
289 export function buildComposerSurface(input: ComposerSurfaceInput): DecisionFooterRegionProps["composer"] {
290 const { base, view, profile, router, modes, goals, remoteGoal, modelSwitch, inserts, control, remoteComposer } = input;
291 const surface: DecisionFooterRegionProps["composer"] = {
292 hidden: view.hidden,
293 inert: view.inert,
294 hero: view.hero,
295 headline: view.headline,
296 props: {
297 ...base,
298 running: base.running || (!view.remote && view.rewindCommitting),
299 collaborationMode: profile.collaborationMode,
300 toolApprovalMode: profile.toolApprovalMode,
301 goal: profile.goal,
302 tabId: input.tabId,
303 workspaceRoot: input.tab?.workspaceRoot,
304 onSend: view.remote ? remoteComposer.send : router.handleSend,
305 onInvocationMetadataChange: input.onInvocationMetadataChange,
306 onSteer: router.handleSteer,
307 onCancel: view.remote ? remoteComposer.cancel : control.handleCancelActive,
308 onCycleMode: input.onCycleMode,
309 onSetMode: modes.applyMode,
310 onSetCollaborationMode: goals.setCollaborationModeFromUi,
311 onSetToolApprovalMode: modes.applyToolApprovalMode,
312 onClearGoal: goals.clearGoalFromUi,
313 onEditGoal: goals.editGoalFromUi,
314 onPauseGoal: remoteGoal.pauseGoal,
315 onResumeGoal: remoteGoal.resumeGoal,
316 onSwitchModel: modelSwitch.switchModelFromUi,
317 onSetEffort: remoteGoal.setEffort,
318 insertRequest: inserts.composerInsertRequest,
319 selectedTextRequest: inserts.selectedTextRequest,
320 readOnly: Boolean(input.tab?.readOnly),
321 disabled: view.runtimeTransitioning || view.rewindCommitting || view.messageActionPending || view.decisionActive,
322 submitDisabled: view.remote ? !remoteComposer.ready || !remoteComposer.profileReady : !modelSettingsAllowSubmission(view.controllerReady, input.tab),
323 submitDisabledReason: view.submitDisabledReason,
324 authentication: view.remote ? undefined : input.tab?.authentication,
325 decisionPending: view.rewindCommitting || view.messageActionPending || view.decisionActive,
326 ready: view.remote ? remoteComposer.ready && remoteComposer.profileReady : view.controllerReady,
327 liveStore: view.remote ? remoteComposer.liveStore : input.localLiveStore,
328 suspendedByDecision: view.decisionActive,
329 transientDismissSignal: input.transientDismissSignal,
330 sessionKey: input.sessionKey,
331 inboxSessionPath: input.tab?.sessionPath,
332 inboxHostId: input.tab?.remote?.hostId,
333 inboxWorkspace: input.tab?.remote?.workspace,
334 workspaceScopeKey: input.workspaceScopeKey,
335 // Match the workspace launcher's ownership boundary: project/branch
336 // selection configures a new empty session. Once the transcript has
337 // content, the session keeps its established workspace and the composer
338 // returns to the compact follow-up layout.
339 workspaceContext: view.hero || input.draft?.surface ? input.workspaceContext : undefined,
340 fileRefRefreshKey: input.fileRefRefreshKey,
341 guidanceConsumedKey: input.guidance?.key,
342 guidanceConsumedItemId: input.guidance?.itemId,
343 guidanceConsumedText: input.guidance?.text,
344 guidanceQueuePreviewItems: input.guidanceQueuePreviewItems,
345 showContextWindowRing: view.showContextWindowRing,
346 heroMode: view.hero && view.showContextWindowRing,
347 },
348 };
349 const draft = input.draft?.surface;
350 if (!draft || !input.draft) return surface;
351 const draftController = input.draft;
352 const operationActive = draft.preparingSubmission || draftSubmissionLocksEditing(draft.operation);
353 const needsAttention = draftSurfaceNeedsAttention(draft);
354 return {
355 hidden: false,
356 inert: false,
357 hero: !needsAttention,
358 headline: input.view.headline,
359 hint: needsAttention ? undefined : input.view.draftHint,
360 props: {
361 ...surface.props,
362 running: operationActive && draft.operation?.phase !== "accepted",
363 collaborationMode: (draft.settings.collaborationMode || "normal") as ComposerProps["collaborationMode"],
364 toolApprovalMode: (draft.settings.toolApprovalMode || "ask") as ComposerProps["toolApprovalMode"],
365 goal: draft.settings.goal,
366 cwd: draft.draft.workspaceRoot,
367 workspaceRoot: draft.draft.workspaceRoot,
368 modelLabel: draft.settings.model,
369 commandCatalog: draft.commands,
370 tabId: undefined,
371 onCaptureSubmit: () => draftController.captureSubmission(draft.draft.id, draft.generation),
372 onReleaseSubmit: draftController.releasePreparation,
373 onPrepareSubmit: draftController.flushPreparation,
374 onSend: (display, submit, tabId, structured, capture) => (
375 draftController.submitFrom(draft.draft.id, draft.generation, display, submit, tabId, structured, capture)
376 ),
377 onSteer: undefined,
378 onCycleMode: () => draftController.updateSettingsFor(draft.draft.id, draft.generation, { collaborationMode: draft.settings.collaborationMode === "plan" ? "normal" : "plan" }),
379 readOnly: false,
380 attachmentInputEnabled: true,
381 imageInputEnabled: draft.models?.find(model => model.ref === draft.settings.model)?.vision ?? false,
382 imageUnderstandingEnabled: false,
383 onCancel: async () => {
384 await draftController.cancelSubmission();
385 return { discardedItemIds: [] };
386 },
387 onSetMode: (mode) => draftController.updateSettingsFor(draft.draft.id, draft.generation, { mode }),
388 onSetCollaborationMode: (collaborationMode) => draftController.updateSettingsFor(draft.draft.id, draft.generation, { collaborationMode }),
389 onSetToolApprovalMode: (toolApprovalMode) => draftController.updateSettingsFor(draft.draft.id, draft.generation, { toolApprovalMode }),
390 onClearGoal: () => draftController.updateSettingsFor(draft.draft.id, draft.generation, { goal: "", collaborationMode: "normal" }),
391 onEditGoal: (goal) => draftController.updateSettingsFor(draft.draft.id, draft.generation, { goal, collaborationMode: goal ? "goal" : "normal" }),
392 onPauseGoal: () => {},
393 onResumeGoal: () => {},
394 onSwitchModel: (model) => { draftController.updateSettingsFor(draft.draft.id, draft.generation, { model, modelSource: "explicit" }); return true; },
395 onSetEffort: (effort) => draftController.updateSettingsFor(draft.draft.id, draft.generation, { effort }),
396 effort: {
397 supported: true,
398 current: draft.settings.effort || "auto",
399 default: "auto",
400 levels: ["auto", "low", "medium", "high", "max"],
401 },
402 disabled: operationActive,
403 submitDisabled: draft.saveState === "conflict" || draft.pendingTasks > 0 || operationActive,
404 submitDisabledReason: draft.saveState === "conflict" ? "Resolve the draft conflict before sending." : undefined,
405 decisionPending: operationActive,
406 ready: true,
407 liveStore: undefined,
408 suspendedByDecision: false,
409 sessionKey: `draft:${draft.draft.id}`,
410 inboxSessionPath: undefined,
411 inboxHostId: undefined,
412 inboxWorkspace: undefined,
413 workspaceScopeKey: `draft:${draft.draft.workspaceId}`,
414 workspaceContext: surface.props.workspaceContext ? {
415 ...surface.props.workspaceContext,
416 scope: draft.draft.scope === "project" ? "project" : "global",
417 workspaceRoot: draft.draft.workspaceRoot,
418 workspaceName: draft.draft.scope === "project"
419 ? draft.draft.workspaceRoot?.replace(/[\\/]+$/, "").split(/[\\/]/).filter(Boolean).pop()
420 : undefined,
421 // Drafts have no formal tab. Git RPCs require that tab's identity and
422 // must never target the session that happened to be open beforehand.
423 tabId: undefined,
424 gitBranch: undefined,
425 scopeKey: `draft:${draft.draft.workspaceId}`,
426 remote: false,
427 } : undefined,
428 persistentDraft: {
429 draftId: draft.draft.id,
430 generation: draft.generation,
431 initial: draft.content,
432 revision: draft.draft.revision,
433 onChange: draftController.updateContentFor,
434 onPatch: draftController.patchContentFor,
435 isCurrent: draftController.isCurrentHandle,
436 canEdit: draftController.canEditHandle,
437 trackTask: draftController.trackTask,
438 onTaskError: draftController.reportTaskError,
439 },
440 composerTarget: { kind: "draft", draftId: draft.draft.id },
441 },
442 };
443 }
444
444 lines TYPESCRIPT