返回 DeepSeek-Reasonix
composerProfile.ts
根目录 / desktop / frontend / src / lib / composerProfile.ts
1 import {
2 modeFromAxes,
3 modeHasAutoApproveTools,
4 modeHasPlan,
5 normalizeCollaborationMode,
6 normalizeMode,
7 normalizeToolApprovalMode,
8 type CollaborationMode,
9 type GoalStatus,
10 type GoalLifecycleView,
11 type Meta,
12 type Mode,
13 type TabMeta,
14 type QualityFloor,
15 type ToolApprovalMode,
16 } from "./types";
17
18 export type ComposerProfileField = "collaborationMode" | "toolApprovalMode" | "goal" | "qualityFloor";
19
20 export type ComposerProfilePending = Partial<Record<ComposerProfileField, true>>;
21
22 export interface ComposerProfile {
23 collaborationMode: CollaborationMode;
24 goalDraftMode: boolean;
25 toolApprovalMode: ToolApprovalMode;
26 goal: string;
27 qualityFloor: QualityFloor;
28 pending: ComposerProfilePending;
29 /** Session the values were read for; "" while the surface has none. */
30 owner?: string;
31 }
32
33 export type ComposerProfilesByTab = Record<string, ComposerProfile>;
34 export type UserPlanModeIntents = Record<string, true>;
35
36 const profileFields: ComposerProfileField[] = ["collaborationMode", "toolApprovalMode", "goal", "qualityFloor"];
37
38 export const defaultComposerProfile: ComposerProfile = Object.freeze({
39 collaborationMode: "normal",
40 goalDraftMode: false,
41 toolApprovalMode: "workspace-write",
42 goal: "",
43 qualityFloor: "standard",
44 pending: {},
45 });
46
47 function activeGoal(goal?: string, status?: GoalStatus, view?: GoalLifecycleView): string {
48 const trimmed = (view?.objective ?? goal ?? "").trim();
49 if (!trimmed) return "";
50 if (view) return view.phase === "complete" ? "" : trimmed;
51 if (status && status !== "running") return "";
52 return trimmed;
53 }
54
55 type ComposerProfileOwnerSource = { sessionId?: string; session?: { sessionId?: string } | null; sessionPath?: string } | null | undefined;
56
57 export function composerProfileOwner(source: ComposerProfileOwnerSource): string {
58 return (source?.session?.sessionId || source?.sessionId || source?.sessionPath || "").trim();
59 }
60
61 // A surface gaining its first session keeps its profile; moving between two
62 // sessions does not, because a preset belongs to the session it was set for.
63 export function composerProfileOwnersConflict(a: string | undefined, b: string | undefined): boolean {
64 return Boolean(a && b && a !== b);
65 }
66
67 export function composerProfileForOwner(profile: ComposerProfile | undefined, owner: string): ComposerProfile | undefined {
68 return profile && !composerProfileOwnersConflict(profile.owner, owner) ? profile : undefined;
69 }
70
71 function profileWithPending(profile: Omit<ComposerProfile, "pending">, pending: ComposerProfilePending = {}): ComposerProfile {
72 return { ...profile, pending };
73 }
74
75 function fallbackToolApprovalMode(rawMode: string | undefined, fallback?: ToolApprovalMode | null): ToolApprovalMode | undefined {
76 if ((rawMode ?? "").trim() !== "") return undefined;
77 return fallback ? normalizeToolApprovalMode(fallback) : undefined;
78 }
79
80 export function composerProfileFromTab(tab?: TabMeta | null, fallback?: ToolApprovalMode | null): ComposerProfile {
81 if (!tab) return { ...defaultComposerProfile, pending: {} };
82 const legacyMode = normalizeMode(tab.mode);
83 const goal = activeGoal(tab.goal, tab.goalStatus, tab.goalView);
84 return profileWithPending({
85 collaborationMode: normalizeCollaborationMode(tab.collaborationMode, goal, legacyMode),
86 goalDraftMode: false,
87 toolApprovalMode: normalizeToolApprovalMode(
88 tab.toolApprovalMode,
89 legacyMode,
90 false,
91 fallbackToolApprovalMode(tab.toolApprovalMode, fallback),
92 ),
93 goal,
94 qualityFloor: tab.qualityFloor ?? "standard",
95 owner: composerProfileOwner(tab),
96 });
97 }
98
99 export function composerProfileFromMeta(meta?: Meta | null, legacyMode?: Mode, fallback?: ToolApprovalMode | null): ComposerProfile {
100 if (!meta) return { ...defaultComposerProfile, pending: {} };
101 const fallbackMode = normalizeMode(legacyMode);
102 const goal = activeGoal(meta.goal, meta.goalStatus, meta.goalView);
103 const toolApprovalMode = normalizeToolApprovalMode(
104 meta.toolApprovalMode,
105 fallbackMode,
106 meta.autoApproveTools ?? meta.bypass,
107 fallbackToolApprovalMode(meta.toolApprovalMode, fallback),
108 );
109 return profileWithPending({
110 collaborationMode: normalizeCollaborationMode(meta.collaborationMode, goal, fallbackMode),
111 goalDraftMode: false,
112 toolApprovalMode,
113 goal,
114 qualityFloor: meta.qualityFloor ?? "standard",
115 owner: composerProfileOwner(meta),
116 });
117 }
118
119 function fieldValue(profile: ComposerProfile, field: ComposerProfileField): string {
120 return profile[field];
121 }
122
123 function assignField(profile: ComposerProfile, field: ComposerProfileField, value: string) {
124 switch (field) {
125 case "collaborationMode":
126 profile.collaborationMode = value as CollaborationMode;
127 return;
128 case "toolApprovalMode":
129 profile.toolApprovalMode = value as ToolApprovalMode;
130 return;
131 case "goal":
132 profile.goal = value;
133 return;
134 case "qualityFloor":
135 profile.qualityFloor = value as QualityFloor;
136 return;
137 }
138 }
139
140 function profilesEqual(a: ComposerProfile | undefined, b: ComposerProfile | undefined): boolean {
141 if (!a || !b) return a === b;
142 return a.collaborationMode === b.collaborationMode
143 && a.goalDraftMode === b.goalDraftMode
144 && a.toolApprovalMode === b.toolApprovalMode
145 && a.goal === b.goal
146 && a.qualityFloor === b.qualityFloor
147 && (a.owner ?? "") === (b.owner ?? "")
148 && profileFields.every((field) => Boolean(a.pending[field]) === Boolean(b.pending[field]));
149 }
150
151 export function reconcileComposerProfile(current: ComposerProfile | undefined, backend: ComposerProfile): ComposerProfile {
152 if (!current || composerProfileOwnersConflict(current.owner, backend.owner)) return { ...backend, pending: {} };
153
154 const pending: ComposerProfilePending = {};
155 const next: ComposerProfile = { ...backend, pending };
156
157 for (const field of profileFields) {
158 if (!current.pending[field]) continue;
159 if (fieldValue(current, field) === fieldValue(backend, field)) continue;
160 pending[field] = true;
161 assignField(next, field, fieldValue(current, field));
162 }
163
164 if (current.goalDraftMode && !backend.goal && !next.goal) {
165 next.goalDraftMode = true;
166 }
167 if (next.goal) {
168 next.goalDraftMode = false;
169 }
170
171 return next;
172 }
173
174 export function hydrateComposerProfilesFromTabs(current: ComposerProfilesByTab, tabs: TabMeta[]): ComposerProfilesByTab {
175 const next: ComposerProfilesByTab = {};
176 let changed = false;
177
178 for (const tab of tabs) {
179 const previous = composerProfileForOwner(current[tab.id], composerProfileOwner(tab));
180 const profile = reconcileComposerProfile(previous, composerProfileFromTab(tab, previous?.toolApprovalMode));
181 next[tab.id] = profile;
182 if (!profilesEqual(current[tab.id], profile)) changed = true;
183 }
184
185 for (const id of Object.keys(current)) {
186 if (!next[id]) changed = true;
187 }
188
189 return changed ? next : current;
190 }
191
192 export function hydrateComposerProfileFromMeta(current: ComposerProfilesByTab, tabId: string, meta: Meta, tab?: TabMeta | null): ComposerProfilesByTab {
193 const owner = composerProfileOwner(meta);
194 if (composerProfileOwnersConflict(composerProfileOwner(tab), owner)) return current;
195 const previous = composerProfileForOwner(current[tabId], owner);
196 const backend = composerProfileFromMeta(
197 meta,
198 previous ? composerProfileMode(previous) : undefined,
199 previous?.toolApprovalMode,
200 );
201 const profile = reconcileComposerProfile(previous, backend);
202 if (profilesEqual(previous, profile)) return current;
203 return { ...current, [tabId]: profile };
204 }
205
206 export function patchComposerProfile(
207 current: ComposerProfilesByTab,
208 tabId: string,
209 base: ComposerProfile | undefined,
210 patch: Partial<Omit<ComposerProfile, "pending">>,
211 pendingFields: ComposerProfileField[],
212 ): ComposerProfilesByTab {
213 const previous = composerProfileForOwner(current[tabId], base?.owner ?? "") ?? base ?? defaultComposerProfile;
214 const pending: ComposerProfilePending = { ...previous.pending };
215 for (const field of pendingFields) pending[field] = true;
216 const profile: ComposerProfile = {
217 ...previous,
218 ...patch,
219 pending,
220 };
221 if (profile.goal) {
222 profile.goalDraftMode = false;
223 }
224 if (profilesEqual(previous, profile)) return current;
225 return { ...current, [tabId]: profile };
226 }
227
228 export function composerProfileMode(profile: ComposerProfile): Mode {
229 return modeFromAxes(profile.collaborationMode === "plan", normalizeToolApprovalMode(profile.toolApprovalMode) === "danger-full-access");
230 }
231
232 export function displayedComposerProfileCollaborationMode(profile: ComposerProfile): CollaborationMode {
233 if (profile.goalDraftMode) return "goal";
234 return profile.collaborationMode;
235 }
236
237 export function controllerComposerProfileCollaborationMode(profile: ComposerProfile): CollaborationMode {
238 const displayed = displayedComposerProfileCollaborationMode(profile);
239 return displayed === "goal" && !profile.goal ? "normal" : displayed;
240 }
241
242 export function composerProfileWithMode(mode: Mode): Partial<Omit<ComposerProfile, "pending">> {
243 return {
244 collaborationMode: modeHasPlan(mode) ? "plan" : "normal",
245 goalDraftMode: false,
246 toolApprovalMode: modeHasAutoApproveTools(mode) ? "workspace-write" : "read-only",
247 goal: "",
248 };
249 }
250
251 export function updateUserPlanModeIntent(
252 current: UserPlanModeIntents,
253 tabId: string | null | undefined,
254 enabled: boolean,
255 ): UserPlanModeIntents {
256 if (!tabId) return current;
257 if (enabled) {
258 return current[tabId] ? current : { ...current, [tabId]: true };
259 }
260 if (!current[tabId]) return current;
261 const next = { ...current };
262 delete next[tabId];
263 return next;
264 }
265
266 export function pruneUserPlanModeIntents(current: UserPlanModeIntents, tabIds: Iterable<string>): UserPlanModeIntents {
267 const live = new Set(tabIds);
268 let changed = false;
269 const next: UserPlanModeIntents = {};
270 for (const tabId of Object.keys(current)) {
271 if (live.has(tabId)) {
272 next[tabId] = true;
273 } else {
274 changed = true;
275 }
276 }
277 return changed ? next : current;
278 }
279
280 export function shouldRestoreUserPlanMode(current: UserPlanModeIntents, tabId: string | null | undefined): boolean {
281 return Boolean(tabId && current[tabId]);
282 }
283
284 export function resolvePlanRestoreTabId(eventTabId: string | null | undefined, activeTabId: string | null | undefined): string | null {
285 return eventTabId || activeTabId || null;
286 }
287
288 export function shouldRestoreUserPlanModeForProfile(
289 current: UserPlanModeIntents,
290 tabId: string | null | undefined,
291 profile?: Pick<ComposerProfile, "goal"> | null,
292 ): boolean {
293 return shouldRestoreUserPlanMode(current, tabId) && !profile?.goal.trim();
294 }
295
295 lines TYPESCRIPT