Home

mweinbach / agent-coworker

publicmweinbach/agent-coworker
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues
mweinbach/agent-coworker/apps/desktop/src/app/store.actions/workspace.ts
Raw
1import { defaultModelForProvider } from "@cowork/providers/catalog";2import { sameWorkspacePath } from "@cowork/utils/workspacePath";3 4import { captureProductEvent } from "../../lib/analytics";5import { pickWorkspaceDirectory, stopWorkspaceServer } from "../../lib/desktopCommands";6import { getDesktopPlatformInfo } 
from
"../../lib/desktopPlatform"
;
7import { applyWorkspaceOrder, reorderSidebarItemsById } from "../../ui/sidebarHelpers";
8import {
9 type AppStoreActions,
10 basename,
11 bumpWorkspaceJsonRpcSocketGeneration,
12 bumpWorkspaceStartGeneration,
13 clearPendingThreadSteers,
14 clearWorkspaceJsonRpcSocketGeneration,
15 clearWorkspaceStartState,
16 disposeWorkspaceJsonRpcState,
17 ensureControlSocket,
18 ensureServerRunning,
19 ensureWorkspaceRuntime,
20 makeId,
21 markWorkspaceServerStale,
22 nowIso,
23 persistNow,
24 RUNTIME,
25 requestWorkspaceSessions,
26 type StoreGet,
27 type StoreSet,
28 sendThread,
29 waitForWorkspaceServerRestartBackoff,
30} from "../store.helpers";
31import { resolveCurrentWorkspaceDefaultsSource } from "../store.helpers/oneOffWorkspaceRecord";
32import {
33 invalidateNavigationIntent,
34 isCreationNavigationIntentCurrent,
35} from "../store.helpers/operationIntent";
36import { isStandardChatThread } from "../threadFilters";
37import { getThreadSelectionIntent } from "../threadSelectionContext";
38import type { WorkspaceRecord } from "../types";
39import { hydrateThreadSelection } from "./thread";
40 
41export function createWorkspaceActions(
42 set: StoreSet,
43 get: StoreGet,
44): Pick<
45 AppStoreActions,
46 | "addWorkspace"
47 | "removeWorkspace"
48 | "selectWorkspace"
49 | "reorderWorkspaces"
50 | "setWorkspacesOrder"
51 | "restartWorkspaceServer"
52 | "handleWorkspaceServerExited"
53 | "setWorkspaceServerStartupProgress"
54> {
55 const closeThreadSession = (threadId: string) => {
56 sendThread(get, threadId, (sessionId) => ({ type: "session_close", sessionId }));
57 };
58 
59 const preferredThreadIdForWorkspace = (workspaceId: string): string | null => {
60 const state = get();
61 const currentThreadId = state.selectedThreadId;
62 const currentThread = currentThreadId
63 ? (state.threads.find((thread) => thread.id === currentThreadId) ?? null)
64 : null;
65 
66 if (
67 currentThread?.workspaceId === workspaceId &&
68 isStandardChatThread(currentThread, { includeDrafts: true })
69 ) {
70 return currentThread.id;
71 }
72 
73 const workspaceThreads = state.threads
74 .filter(
75 (thread) =>
76 thread.workspaceId === workspaceId &&
77 isStandardChatThread(thread, { includeDrafts: true }),
78 )
79 .sort((left, right) => right.lastMessageAt.localeCompare(left.lastMessageAt));
80 
81 return workspaceThreads[0]?.id ?? null;
82 };
83 
84 const preferredTaskThreadId = (taskId: string, currentThreadId: string | null): string | null => {
85 const state = get();
86 const task = state.tasksById[taskId];
87 if (currentThreadId) {
88 const currentThread = state.threads.find((thread) => thread.id === currentThreadId);
89 if (
90 currentThread?.taskId === taskId ||
91 task?.threads.some((thread) => thread.sessionId === currentThreadId)
92 ) {
93 return currentThreadId;
94 }
95 }
96 
97 return (
98 task?.threads[0]?.sessionId ??
99 state.threads
100 .filter((thread) => thread.taskId === taskId)
101 .sort((left, right) => right.lastMessageAt.localeCompare(left.lastMessageAt))[0]?.id ??
102 null
103 );
104 };
105 
106 const taskBelongsToWorkspace = (taskId: string | null, workspaceId: string): boolean => {
107 if (!taskId) return false;
108 const state = get();
109 if ((state.taskSummariesByWorkspaceId[workspaceId] ?? []).some((task) => task.id === taskId)) {
110 return true;
111 }
112 const task = state.tasksById[taskId];
113 const workspace = state.workspaces.find((item) => item.id === workspaceId);
114 if (!task || !workspace) return false;
115 return sameWorkspacePath(
116 task.workspacePath,
117 workspace.path,
118 getDesktopPlatformInfo().rawPlatform as NodeJS.Platform,
119 );
120 };
121 
122 const isWorkspaceLifecycleEnabled = () => get().desktopFeatureFlags.workspaceLifecycle !== false;
123 
124 return {
125 addWorkspace: async (options = {}) => {
126 if (!isWorkspaceLifecycleEnabled()) return;
127 if (RUNTIME.workspacePickerOpen) return;
128 if (!options.intent) invalidateNavigationIntent();
129 const canNavigate = () =>
130 !options.intent || isCreationNavigationIntentCurrent(options.intent);
131 RUNTIME.workspacePickerOpen = true;
132 
133 let dir: string | null = null;
134 try {
135 dir = await pickWorkspaceDirectory();
136 } finally {
137 RUNTIME.workspacePickerOpen = false;
138 }
139 if (!dir) return;
140 
141 const existing = get().workspaces.find((w) => w.path === dir);
142 if (existing) {
143 await get().selectWorkspace(existing.id, { intent: options.intent });
144 return;
145 }
146 
147 const stayInSettings = get().view === "settings";
148 const source = resolveCurrentWorkspaceDefaultsSource(get);
149 const defaultProvider = source?.defaultProvider ?? "google";
150 const defaultModel =
151 source?.defaultModel?.trim() ||
152 get().providerDefaultModelByProvider[defaultProvider] ||
153 defaultModelForProvider(defaultProvider);
154 const defaultPreferredChildModel = source?.defaultPreferredChildModel?.trim() || defaultModel;
155 const defaultChildModelRoutingMode = source?.defaultChildModelRoutingMode ?? "same-provider";
156 const defaultPreferredChildModelRef =
157 source?.defaultPreferredChildModelRef?.trim() ||
158 `${defaultProvider}:${defaultPreferredChildModel || defaultModel}`;
159 const ws: WorkspaceRecord = {
160 id: makeId(),
161 name: basename(dir),
162 path: dir,
163 workspaceKind: "project",
164 createdAt: nowIso(),
165 lastOpenedAt: nowIso(),
166 wsProtocol: "jsonrpc",
167 defaultProvider,
168 defaultModel,
169 defaultPreferredChildModel,
170 defaultChildModelRoutingMode,
171 defaultPreferredChildModelRef,
172 defaultAllowedChildModelRefs: [...(source?.defaultAllowedChildModelRefs ?? [])],
173 defaultToolOutputOverflowChars: source?.defaultToolOutputOverflowChars,
174 providerOptions: source?.providerOptions,
175 userName: source?.userName,
176 userProfile: source?.userProfile,
177 defaultEnableMcp: source?.defaultEnableMcp ?? true,
178 defaultBackupsEnabled: source?.defaultBackupsEnabled ?? false,
179 yolo: source?.yolo ?? true,
180 };
181 
182 set((s) => {
183 const next = { workspaces: [ws, ...s.workspaces] };
184 return canNavigate()
185 ? {
186 ...next,
187 selectedWorkspaceId: ws.id,
188 view: stayInSettings ? ("settings" as const) : ("chat" as const),
189 }
190 : next;
191 });
192 captureProductEvent("workspace_added", {
193 eventSource: "renderer",
194 workspaceCount: get().workspaces.length,
195 mcpEnabled: ws.defaultEnableMcp,
196 yoloEnabled: ws.yolo,
197 });
198 ensureWorkspaceRuntime(get, set, ws.id);
199 await persistNow(get);
200 await ensureServerRunning(get, set, ws.id);
201 ensureControlSocket(get, set, ws.id);
202 void requestWorkspaceSessions(get, set, ws.id);
203 },
204 
205 removeWorkspace: async (workspaceId: string) => {
206 if (!isWorkspaceLifecycleEnabled()) return;
207 bumpWorkspaceStartGeneration(workspaceId);
208 bumpWorkspaceJsonRpcSocketGeneration(workspaceId);
209 
210 for (const thread of get().threads) {
211 if (thread.workspaceId !== workspaceId) continue;
212 closeThreadSession(thread.id);
213 RUNTIME.optimisticUserMessageIds.delete(thread.id);
214 RUNTIME.pendingThreadMessages.delete(thread.id);
215 RUNTIME.pendingThreadAttachments.delete(thread.id);
216 RUNTIME.threadSelectionRequests.delete(thread.id);
217 RUNTIME.pendingWorkspaceDefaultApplyByThread.delete(thread.id);
218 RUNTIME.modelStreamByThread.delete(thread.id);
219 clearPendingThreadSteers(thread.id);
220 }
221 
222 const jsonRpcSocket = RUNTIME.jsonRpcSockets.get(workspaceId);
223 try {
224 jsonRpcSocket?.close();
225 } catch {
226 // ignore
227 }
228 RUNTIME.jsonRpcSockets.delete(workspaceId);
229 clearWorkspaceJsonRpcSocketGeneration(workspaceId);
230 
231 try {
232 await stopWorkspaceServer({ workspaceId });
233 } catch {
234 // ignore
235 } finally {
236 disposeWorkspaceJsonRpcState(get, workspaceId);
237 }
238 
239 set((s) => {
240 const remainingWorkspaces = s.workspaces.filter((w) => w.id !== workspaceId);
241 const remainingThreads = s.threads.filter((t) => t.workspaceId !== workspaceId);
242 const selectedWorkspaceId =
243 s.selectedWorkspaceId === workspaceId
244 ? (remainingWorkspaces[0]?.id ?? null)
245 : s.selectedWorkspaceId;
246 const selectedTaskId =
247 selectedWorkspaceId && taskBelongsToWorkspace(s.selectedTaskId, selectedWorkspaceId)
248 ? s.selectedTaskId
249 : null;
250 const threadSelectionIntent = getThreadSelectionIntent(
251 s.view,
252 s.lastNonSettingsView,
253 selectedTaskId,
254 );
255 const selectedThread = s.selectedThreadId
256 ? (remainingThreads.find((t) => t.id === s.selectedThreadId) ?? null)
257 : null;
258 let selectedThreadId: string | null = null;
259 if (selectedWorkspaceId && selectedThread?.workspaceId === selectedWorkspaceId) {
260 if (threadSelectionIntent.context === "task" && threadSelectionIntent.selectedTaskId) {
261 const selectedTask = s.tasksById[threadSelectionIntent.selectedTaskId];
262 const selectedThreadBelongsToTask =
263 selectedThread.taskId === threadSelectionIntent.selectedTaskId ||
264 selectedTask?.threads.some((thread) => thread.sessionId === selectedThread.id) ===
265 true;
266 selectedThreadId = selectedThreadBelongsToTask ? selectedThread.id : null;
267 } else if (
268 threadSelectionIntent.context === "chat" &&
269 isStandardChatThread(selectedThread, { includeDrafts: true })
270 ) {
271 selectedThreadId = selectedThread.id;
272 }
273 }
274 return {
275 workspaces: remainingWorkspaces,
276 threads: remainingThreads,
277 selectedWorkspaceId,
278 selectedThreadId,
279 selectedTaskId,
280 newTaskWorkspaceId: s.newTaskWorkspaceId === workspaceId ? null : s.newTaskWorkspaceId,
281 };
282 });
283 get().pruneComposerDrafts();
284 clearWorkspaceStartState(workspaceId);
285 await persistNow(get);
286 captureProductEvent("workspace_removed", {
287 eventSource: "renderer",
288 workspaceCount: get().workspaces.length,
289 });
290 },
291 
292 selectWorkspace: async (workspaceId: string, options = {}) => {
293 if (options.signal?.aborted) return;
294 if (!options.intent) invalidateNavigationIntent();
295 const isCurrent = () =>
296 options.signal?.aborted !== true &&
297 (!options.intent || isCreationNavigationIntentCurrent(options.intent));
298 if (!isCurrent()) return;
299 const wasSelected = get().selectedWorkspaceId === workspaceId;
300 const currentState = get();
301 const threadSelectionIntent = getThreadSelectionIntent(
302 currentState.view,
303 currentState.lastNonSettingsView,
304 currentState.selectedTaskId,
305 );
306 const selectedTaskId =
307 threadSelectionIntent.context === "task" &&
308 threadSelectionIntent.selectedTaskId &&
309 taskBelongsToWorkspace(threadSelectionIntent.selectedTaskId, workspaceId)
310 ? threadSelectionIntent.selectedTaskId
311 : null;
312 const nextThreadId =
313 threadSelectionIntent.context === "task"
314 ? selectedTaskId
315 ? preferredTaskThreadId(selectedTaskId, currentState.selectedThreadId)
316 : null
317 : preferredThreadIdForWorkspace(workspaceId);
318 const hydrateSelectedThreadPromise = nextThreadId
319 ? hydrateThreadSelection(get, set, nextThreadId, {
320 preserveView: true,
321 reconnectAfterHydration: true,
322 skipWorkspaceSelectOnReconnect: true,
323 signal: options.signal,
324 })
325 : null;
326 if (!isCurrent()) return;
327 set((s) => {
328 const retargetNewTask =
329 getThreadSelectionIntent(s.view, s.lastNonSettingsView, selectedTaskId).context ===
330 "task" && selectedTaskId === null;
331 return {
332 selectedWorkspaceId: workspaceId,
333 selectedThreadId: nextThreadId,
334 selectedTaskId,
335 newTaskWorkspaceId: retargetNewTask ? workspaceId : null,
336 newTaskWorkspaceRequestId: retargetNewTask
337 ? s.newTaskWorkspaceRequestId + 1
338 : s.newTaskWorkspaceRequestId,
339 view: s.view === "settings" ? "settings" : s.view,
340 };
341 });
342 if (!isCurrent()) return;
343 ensureWorkspaceRuntime(get, set, workspaceId);
344 
345 const ws = get().workspaces.find((w) => w.id === workspaceId);
346 if (!ws) return;
347 
348 if (!wasSelected) {
349 if (!isCurrent()) return;
350 set((s) => ({
351 workspaces: s.workspaces.map((w) =>
352 w.id === workspaceId ? { ...w, lastOpenedAt: nowIso() } : w,
353 ),
354 }));
355 await persistNow(get);
356 if (!isCurrent()) return;
357 }
358 
359 await ensureServerRunning(get, set, workspaceId, { signal: options.signal });
360 if (!isCurrent()) return;
361 ensureControlSocket(get, set, workspaceId);
362 if (!isCurrent()) return;
363 const requestSessionsPromise = requestWorkspaceSessions(get, set, workspaceId, {
364 signal: options.signal,
365 });
366 const refreshTasksPromise = get().refreshTasks(workspaceId, { signal: options.signal });
367 await Promise.all([
368 requestSessionsPromise,
369 refreshTasksPromise,
370 hydrateSelectedThreadPromise ?? Promise.resolve(),
371 ]);
372 },
373 
374 reorderWorkspaces: async (sourceWorkspaceId: string, targetWorkspaceId: string) => {
375 if (!isWorkspaceLifecycleEnabled()) return;
376 const nextWorkspaces = reorderSidebarItemsById(
377 get().workspaces,
378 sourceWorkspaceId,
379 targetWorkspaceId,
380 );
381 
382 if (nextWorkspaces === get().workspaces) {
383 return;
384 }
385 
386 set({ workspaces: nextWorkspaces });
387 await persistNow(get);
388 },
389 
390 setWorkspacesOrder: async (orderedIds: string[]) => {
391 if (!isWorkspaceLifecycleEnabled()) return;
392 const nextWorkspaces = applyWorkspaceOrder(get().workspaces, orderedIds);
393 
394 if (nextWorkspaces === get().workspaces) {
395 return;
396 }
397 
398 set({ workspaces: nextWorkspaces });
399 await persistNow(get);
400 },
401 
402 setWorkspaceServerStartupProgress: ({ workspaceId, progress }) => {
403 set((state) => {
404 const runtime = state.workspaceRuntimeById[workspaceId];
405 if (!runtime?.starting || runtime.serverUrl) return {};
406 return {
407 workspaceRuntimeById: {
408 ...state.workspaceRuntimeById,
409 [workspaceId]: { ...runtime, startupProgress: progress },
410 },
411 };
412 });
413 },
414 
415 restartWorkspaceServer: async (workspaceId) => {
416 if (!isWorkspaceLifecycleEnabled()) return;
417 bumpWorkspaceStartGeneration(workspaceId);
418 bumpWorkspaceJsonRpcSocketGeneration(workspaceId);
419 
420 for (const thread of get().threads) {
421 if (thread.workspaceId !== workspaceId) continue;
422 closeThreadSession(thread.id);
423 RUNTIME.threadSelectionRequests.delete(thread.id);
424 RUNTIME.pendingWorkspaceDefaultApplyByThread.delete(thread.id);
425 }
426 
427 const jsonRpcSocket = RUNTIME.jsonRpcSockets.get(workspaceId);
428 try {
429 jsonRpcSocket?.close();
430 } catch {
431 // ignore
432 }
433 RUNTIME.jsonRpcSockets.delete(workspaceId);
434 
435 try {
436 await stopWorkspaceServer({ workspaceId });
437 } catch {
438 // ignore
439 }
440 
441 set((s) => ({
442 workspaceRuntimeById: {
443 ...s.workspaceRuntimeById,
444 [workspaceId]: {
445 ...s.workspaceRuntimeById[workspaceId],
446 serverUrl: null,
447 startupProgress: null,
448 controlSessionId: null,
449 controlConfig: null,
450 controlSessionConfig: null,
451 workspaceBackupsPath: null,
452 workspaceBackups: [],
453 workspaceBackupsLoading: false,
454 workspaceBackupsError: null,
455 workspaceBackupPendingActionKeys: {},
456 workspaceBackupDelta: null,
457 workspaceBackupDeltaLoading: false,
458 workspaceBackupDeltaError: null,
459 },
460 },
461 }));
462 
463 await ensureServerRunning(get, set, workspaceId);
464 ensureControlSocket(get, set, workspaceId);
465 void requestWorkspaceSessions(get, set, workspaceId);
466 },
467 
468 handleWorkspaceServerExited: (event) => {
469 if (!isWorkspaceLifecycleEnabled()) return;
470 const { workspaceId } = event;
471 const current = get();
472 if (!current.workspaces.some((workspace) => workspace.id === workspaceId)) return;
473 const currentUrl = current.workspaceRuntimeById[workspaceId]?.serverUrl ?? null;
474 if (event.url && currentUrl && event.url !== currentUrl) return;
475 bumpWorkspaceStartGeneration(workspaceId);
476 markWorkspaceServerStale(get, set, workspaceId, "Workspace server exited");
477 void (async () => {
478 await waitForWorkspaceServerRestartBackoff(workspaceId);
479 await ensureServerRunning(get, set, workspaceId);
480 ensureControlSocket(get, set, workspaceId);
481 void requestWorkspaceSessions(get, set, workspaceId);
482 })();
483 },
484 };
485}
486