@vibe80/vibe80 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +52 -0
  3. package/bin/vibe80.js +176 -0
  4. package/client/dist/assets/DiffPanel-C_IGzKI5.js +1 -0
  5. package/client/dist/assets/ExplorerPanel-BtlyAT00.js +11 -0
  6. package/client/dist/assets/LogsPanel-BW79JWzR.js +1 -0
  7. package/client/dist/assets/SettingsPanel-b9B7ygP_.js +1 -0
  8. package/client/dist/assets/TerminalPanel-C3fc1HbK.js +1 -0
  9. package/client/dist/assets/browser-e3WgtMs-.js +8 -0
  10. package/client/dist/assets/index-CgqGyssr.css +32 -0
  11. package/client/dist/assets/index-DnwKjoj7.js +706 -0
  12. package/client/dist/assets/vibe80_dark-D7OVPKcU.svg +51 -0
  13. package/client/dist/assets/vibe80_light-BJK37ybI.svg +50 -0
  14. package/client/dist/favicon.ico +0 -0
  15. package/client/dist/favicon.png +0 -0
  16. package/client/dist/favicon.svg +35 -0
  17. package/client/dist/index.html +14 -0
  18. package/client/index.html +16 -0
  19. package/client/package.json +34 -0
  20. package/client/public/favicon.ico +0 -0
  21. package/client/public/favicon.png +0 -0
  22. package/client/public/favicon.svg +35 -0
  23. package/client/public/pwa-192x192.png +0 -0
  24. package/client/public/pwa-512x512.png +0 -0
  25. package/client/src/App.jsx +3131 -0
  26. package/client/src/assets/logo_small.png +0 -0
  27. package/client/src/assets/vibe80_dark.svg +51 -0
  28. package/client/src/assets/vibe80_light.svg +50 -0
  29. package/client/src/components/Chat/ChatComposer.jsx +228 -0
  30. package/client/src/components/Chat/ChatMessages.jsx +811 -0
  31. package/client/src/components/Chat/ChatToolbar.jsx +109 -0
  32. package/client/src/components/Chat/useChatComposer.js +462 -0
  33. package/client/src/components/Diff/DiffPanel.jsx +129 -0
  34. package/client/src/components/Explorer/ExplorerPanel.jsx +449 -0
  35. package/client/src/components/Logs/LogsPanel.jsx +80 -0
  36. package/client/src/components/SessionGate/SessionGate.jsx +874 -0
  37. package/client/src/components/Settings/SettingsPanel.jsx +212 -0
  38. package/client/src/components/Terminal/TerminalPanel.jsx +39 -0
  39. package/client/src/components/Topbar/Topbar.jsx +101 -0
  40. package/client/src/components/WorktreeTabs.css +419 -0
  41. package/client/src/components/WorktreeTabs.jsx +604 -0
  42. package/client/src/hooks/useAttachments.jsx +125 -0
  43. package/client/src/hooks/useBacklog.js +254 -0
  44. package/client/src/hooks/useChatClear.js +90 -0
  45. package/client/src/hooks/useChatCollapse.js +42 -0
  46. package/client/src/hooks/useChatCommands.js +294 -0
  47. package/client/src/hooks/useChatExport.js +144 -0
  48. package/client/src/hooks/useChatMessagesState.js +69 -0
  49. package/client/src/hooks/useChatSend.js +158 -0
  50. package/client/src/hooks/useChatSocket.js +1239 -0
  51. package/client/src/hooks/useDiffNavigation.js +19 -0
  52. package/client/src/hooks/useExplorerActions.js +1184 -0
  53. package/client/src/hooks/useGitIdentity.js +114 -0
  54. package/client/src/hooks/useLayoutMode.js +31 -0
  55. package/client/src/hooks/useLocalPreferences.js +131 -0
  56. package/client/src/hooks/useMessageSync.js +30 -0
  57. package/client/src/hooks/useNotifications.js +132 -0
  58. package/client/src/hooks/usePaneNavigation.js +67 -0
  59. package/client/src/hooks/usePanelState.js +13 -0
  60. package/client/src/hooks/useProviderSelection.js +70 -0
  61. package/client/src/hooks/useRepoBranchesModels.js +218 -0
  62. package/client/src/hooks/useRepoStatus.js +350 -0
  63. package/client/src/hooks/useRpcLogActions.js +19 -0
  64. package/client/src/hooks/useRpcLogView.js +58 -0
  65. package/client/src/hooks/useSessionHandoff.js +97 -0
  66. package/client/src/hooks/useSessionLifecycle.js +287 -0
  67. package/client/src/hooks/useSessionReset.js +63 -0
  68. package/client/src/hooks/useSessionResync.js +77 -0
  69. package/client/src/hooks/useTerminalSession.js +328 -0
  70. package/client/src/hooks/useToolbarExport.js +27 -0
  71. package/client/src/hooks/useTurnInterrupt.js +43 -0
  72. package/client/src/hooks/useVibe80Forms.js +128 -0
  73. package/client/src/hooks/useWorkspaceAuth.js +932 -0
  74. package/client/src/hooks/useWorktreeCloseConfirm.js +46 -0
  75. package/client/src/hooks/useWorktrees.js +396 -0
  76. package/client/src/i18n.jsx +87 -0
  77. package/client/src/index.css +5147 -0
  78. package/client/src/locales/en.json +37 -0
  79. package/client/src/locales/fr.json +321 -0
  80. package/client/src/main.jsx +16 -0
  81. package/client/vite.config.js +62 -0
  82. package/docs/api/asyncapi.json +1511 -0
  83. package/docs/api/openapi.json +3242 -0
  84. package/git_hooks/prepare-commit-msg +35 -0
  85. package/package.json +36 -0
  86. package/server/package.json +29 -0
  87. package/server/scripts/rotate-workspace-secret.js +101 -0
  88. package/server/src/claudeClient.js +454 -0
  89. package/server/src/clientEvents.js +594 -0
  90. package/server/src/clientFactory.js +164 -0
  91. package/server/src/codexClient.js +468 -0
  92. package/server/src/config.js +27 -0
  93. package/server/src/helpers.js +138 -0
  94. package/server/src/index.js +1641 -0
  95. package/server/src/middleware/auth.js +93 -0
  96. package/server/src/middleware/debug.js +89 -0
  97. package/server/src/middleware/errorTypes.js +60 -0
  98. package/server/src/providerLogger.js +60 -0
  99. package/server/src/routes/files.js +114 -0
  100. package/server/src/routes/git.js +183 -0
  101. package/server/src/routes/health.js +13 -0
  102. package/server/src/routes/sessions.js +407 -0
  103. package/server/src/routes/workspaces.js +296 -0
  104. package/server/src/routes/worktrees.js +993 -0
  105. package/server/src/runAs.js +458 -0
  106. package/server/src/runtimeStore.js +32 -0
  107. package/server/src/services/auth.js +157 -0
  108. package/server/src/services/claudeThreadDirectory.js +33 -0
  109. package/server/src/services/session.js +918 -0
  110. package/server/src/services/workspace.js +858 -0
  111. package/server/src/storage/index.js +17 -0
  112. package/server/src/storage/redis.js +412 -0
  113. package/server/src/storage/sqlite.js +649 -0
  114. package/server/src/worktreeManager.js +717 -0
  115. package/server/tests/README.md +13 -0
  116. package/server/tests/factories/workspaceFactory.js +13 -0
  117. package/server/tests/fixtures/workspaceCredentials.json +4 -0
  118. package/server/tests/integration/routes/workspaces-routes.test.js +626 -0
  119. package/server/tests/setup/env.js +9 -0
  120. package/server/tests/unit/helpers.test.js +95 -0
  121. package/server/tests/unit/services/auth.test.js +181 -0
  122. package/server/tests/unit/services/workspace.test.js +115 -0
  123. package/server/vitest.config.js +23 -0
@@ -0,0 +1,932 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ const WORKSPACE_TOKEN_KEY = "workspaceToken";
4
+ const WORKSPACE_REFRESH_TOKEN_KEY = "workspaceRefreshToken";
5
+ const WORKSPACE_ID_KEY = "workspaceId";
6
+ const WORKSPACE_AUTH_CHANNEL = "workspace-auth";
7
+ const WORKSPACE_REFRESH_LOCK_KEY = "workspaceRefreshLock";
8
+ const REFRESH_LOCK_TTL_MS = 15000;
9
+ const REFRESH_WAIT_TIMEOUT_MS = 5000;
10
+ const REFRESH_RETRY_WAIT_MS = 1500;
11
+ const MONO_AUTH_GRANT_TYPE = "mono_auth_token";
12
+
13
+ const readWorkspaceToken = () => {
14
+ try {
15
+ return localStorage.getItem(WORKSPACE_TOKEN_KEY) || "";
16
+ } catch {
17
+ return "";
18
+ }
19
+ };
20
+
21
+ const readWorkspaceRefreshToken = () => {
22
+ try {
23
+ return localStorage.getItem(WORKSPACE_REFRESH_TOKEN_KEY) || "";
24
+ } catch {
25
+ return "";
26
+ }
27
+ };
28
+
29
+ const readWorkspaceId = () => {
30
+ try {
31
+ return localStorage.getItem(WORKSPACE_ID_KEY) || "";
32
+ } catch {
33
+ return "";
34
+ }
35
+ };
36
+
37
+ const readMonoAuthTokenFromHash = () => {
38
+ const hash = String(window.location.hash || "");
39
+ if (!hash || !hash.startsWith("#")) {
40
+ return "";
41
+ }
42
+ const raw = hash.slice(1);
43
+ const params = new URLSearchParams(raw);
44
+ return (params.get("mono_auth") || "").trim();
45
+ };
46
+
47
+ const defaultProvidersState = () => ({
48
+ codex: {
49
+ enabled: false,
50
+ authType: "api_key",
51
+ authValue: "",
52
+ previousAuthType: "api_key",
53
+ },
54
+ claude: {
55
+ enabled: false,
56
+ authType: "api_key",
57
+ authValue: "",
58
+ previousAuthType: "api_key",
59
+ },
60
+ });
61
+
62
+ const defaultAuthExpanded = () => ({
63
+ codex: false,
64
+ claude: false,
65
+ });
66
+
67
+ const defaultAuthFiles = () => ({
68
+ codex: "",
69
+ claude: "",
70
+ });
71
+
72
+ export default function useWorkspaceAuth({
73
+ t,
74
+ encodeBase64,
75
+ copyTextToClipboard,
76
+ extractRepoName,
77
+ setSessionMode,
78
+ showToast,
79
+ getProviderAuthType,
80
+ }) {
81
+ const [workspaceStep, setWorkspaceStep] = useState(1);
82
+ const [workspaceMode, setWorkspaceMode] = useState("existing");
83
+ const [workspaceIdInput, setWorkspaceIdInput] = useState(readWorkspaceId());
84
+ const [workspaceSecretInput, setWorkspaceSecretInput] = useState("");
85
+ const [workspaceToken, setWorkspaceToken] = useState(readWorkspaceToken());
86
+ const [workspaceRefreshToken, setWorkspaceRefreshToken] = useState(
87
+ readWorkspaceRefreshToken()
88
+ );
89
+ const [workspaceId, setWorkspaceId] = useState(readWorkspaceId());
90
+ const [workspaceCreated, setWorkspaceCreated] = useState(null);
91
+ const [workspaceError, setWorkspaceError] = useState("");
92
+ const [workspaceBusy, setWorkspaceBusy] = useState(false);
93
+ const [workspaceSessions, setWorkspaceSessions] = useState([]);
94
+ const [workspaceSessionsLoading, setWorkspaceSessionsLoading] = useState(false);
95
+ const [workspaceSessionsError, setWorkspaceSessionsError] = useState("");
96
+ const [workspaceSessionDeletingId, setWorkspaceSessionDeletingId] = useState(null);
97
+ const [workspaceCopied, setWorkspaceCopied] = useState({
98
+ id: false,
99
+ secret: false,
100
+ });
101
+ const [workspaceProvidersEditing, setWorkspaceProvidersEditing] = useState(false);
102
+ const [providersBackStep, setProvidersBackStep] = useState(1);
103
+ const [workspaceAuthExpanded, setWorkspaceAuthExpanded] = useState(
104
+ defaultAuthExpanded
105
+ );
106
+ const [workspaceAuthFiles, setWorkspaceAuthFiles] = useState(
107
+ defaultAuthFiles
108
+ );
109
+ const [workspaceProviders, setWorkspaceProviders] = useState(
110
+ defaultProvidersState
111
+ );
112
+ const [deploymentMode, setDeploymentMode] = useState(null);
113
+
114
+ const tabIdRef = useRef(`tab-${Math.random().toString(36).slice(2)}-${Date.now()}`);
115
+ const workspaceTokenRef = useRef(workspaceToken);
116
+ const workspaceRefreshTokenRef = useRef(workspaceRefreshToken);
117
+ const refreshInFlightRef = useRef(null);
118
+ const refreshBroadcastChannelRef = useRef(null);
119
+ const refreshBroadcastWaitersRef = useRef([]);
120
+ const workspaceCopyTimersRef = useRef({ id: null, secret: null });
121
+
122
+ const applyWorkspaceTokens = useCallback(
123
+ ({ token = "", refreshToken = "" } = {}) => {
124
+ if (typeof token === "string") {
125
+ workspaceTokenRef.current = token;
126
+ setWorkspaceToken(token);
127
+ }
128
+ if (typeof refreshToken === "string") {
129
+ workspaceRefreshTokenRef.current = refreshToken;
130
+ setWorkspaceRefreshToken(refreshToken);
131
+ }
132
+ },
133
+ []
134
+ );
135
+
136
+ const notifyRefreshWaiters = useCallback((token) => {
137
+ const waiters = refreshBroadcastWaitersRef.current.splice(0);
138
+ waiters.forEach((resolve) => {
139
+ try {
140
+ resolve(token || null);
141
+ } catch {
142
+ // Ignore waiter errors.
143
+ }
144
+ });
145
+ }, []);
146
+
147
+ const waitForTokenBroadcast = useCallback((timeoutMs = REFRESH_WAIT_TIMEOUT_MS) => {
148
+ return new Promise((resolve) => {
149
+ const timeout = setTimeout(() => {
150
+ const index = refreshBroadcastWaitersRef.current.indexOf(onResolve);
151
+ if (index >= 0) {
152
+ refreshBroadcastWaitersRef.current.splice(index, 1);
153
+ }
154
+ resolve(null);
155
+ }, timeoutMs);
156
+ const onResolve = (token) => {
157
+ clearTimeout(timeout);
158
+ resolve(token || null);
159
+ };
160
+ refreshBroadcastWaitersRef.current.push(onResolve);
161
+ });
162
+ }, []);
163
+
164
+ const broadcastAuthEvent = useCallback((type, payload = {}) => {
165
+ const channel = refreshBroadcastChannelRef.current;
166
+ if (!channel) {
167
+ return;
168
+ }
169
+ try {
170
+ channel.postMessage({
171
+ type,
172
+ sourceTabId: tabIdRef.current,
173
+ ...payload,
174
+ });
175
+ } catch {
176
+ // Ignore broadcast failures.
177
+ }
178
+ }, []);
179
+
180
+ const getLatestWorkspaceTokens = useCallback(() => {
181
+ const token = readWorkspaceToken();
182
+ const refreshToken = readWorkspaceRefreshToken();
183
+ return {
184
+ token: token || workspaceTokenRef.current || "",
185
+ refreshToken: refreshToken || workspaceRefreshTokenRef.current || "",
186
+ };
187
+ }, []);
188
+
189
+ const acquireRefreshLock = useCallback(() => {
190
+ const now = Date.now();
191
+ const nextPayload = {
192
+ owner: tabIdRef.current,
193
+ expiresAt: now + REFRESH_LOCK_TTL_MS,
194
+ };
195
+ try {
196
+ const raw = localStorage.getItem(WORKSPACE_REFRESH_LOCK_KEY);
197
+ if (raw) {
198
+ const parsed = JSON.parse(raw);
199
+ if (
200
+ parsed &&
201
+ typeof parsed.expiresAt === "number" &&
202
+ parsed.expiresAt > now &&
203
+ parsed.owner !== tabIdRef.current
204
+ ) {
205
+ return false;
206
+ }
207
+ }
208
+ localStorage.setItem(WORKSPACE_REFRESH_LOCK_KEY, JSON.stringify(nextPayload));
209
+ const confirmedRaw = localStorage.getItem(WORKSPACE_REFRESH_LOCK_KEY);
210
+ if (!confirmedRaw) {
211
+ return false;
212
+ }
213
+ const confirmed = JSON.parse(confirmedRaw);
214
+ return confirmed?.owner === tabIdRef.current;
215
+ } catch {
216
+ return true;
217
+ }
218
+ }, []);
219
+
220
+ const releaseRefreshLock = useCallback(() => {
221
+ try {
222
+ const raw = localStorage.getItem(WORKSPACE_REFRESH_LOCK_KEY);
223
+ if (!raw) {
224
+ return;
225
+ }
226
+ const parsed = JSON.parse(raw);
227
+ if (parsed?.owner === tabIdRef.current) {
228
+ localStorage.removeItem(WORKSPACE_REFRESH_LOCK_KEY);
229
+ }
230
+ } catch {
231
+ // Ignore release errors.
232
+ }
233
+ }, []);
234
+
235
+ const handleLeaveWorkspace = useCallback(() => {
236
+ applyWorkspaceTokens({ token: "", refreshToken: "" });
237
+ setWorkspaceId("");
238
+ setWorkspaceIdInput("");
239
+ setWorkspaceSecretInput("");
240
+ setWorkspaceCreated(null);
241
+ setWorkspaceError("");
242
+ setWorkspaceMode("existing");
243
+ setWorkspaceSessions([]);
244
+ setWorkspaceSessionsError("");
245
+ setWorkspaceSessionsLoading(false);
246
+ setWorkspaceStep(1);
247
+ setWorkspaceProvidersEditing(false);
248
+ if (setSessionMode) {
249
+ setSessionMode("new");
250
+ }
251
+ broadcastAuthEvent("workspace_left", {});
252
+ notifyRefreshWaiters(null);
253
+ }, [applyWorkspaceTokens, broadcastAuthEvent, notifyRefreshWaiters, setSessionMode]);
254
+
255
+ const refreshWorkspaceToken = useCallback(async () => {
256
+ const activeRefreshToken =
257
+ workspaceRefreshTokenRef.current || getLatestWorkspaceTokens().refreshToken;
258
+ if (!activeRefreshToken) {
259
+ return null;
260
+ }
261
+ if (refreshInFlightRef.current) {
262
+ return refreshInFlightRef.current;
263
+ }
264
+
265
+ const promise = (async () => {
266
+ let lockAcquired = false;
267
+ try {
268
+ lockAcquired = acquireRefreshLock();
269
+ if (!lockAcquired) {
270
+ const syncedToken = await waitForTokenBroadcast();
271
+ if (syncedToken) {
272
+ return syncedToken;
273
+ }
274
+ const latest = getLatestWorkspaceTokens();
275
+ if (latest.token && latest.token !== workspaceTokenRef.current) {
276
+ applyWorkspaceTokens({
277
+ token: latest.token,
278
+ refreshToken: latest.refreshToken || workspaceRefreshTokenRef.current,
279
+ });
280
+ return latest.token;
281
+ }
282
+ lockAcquired = acquireRefreshLock();
283
+ if (!lockAcquired) {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ broadcastAuthEvent("refresh_started", { at: Date.now() });
289
+ const latestBeforeRefresh = getLatestWorkspaceTokens();
290
+ const refreshTokenForCall = latestBeforeRefresh.refreshToken || activeRefreshToken;
291
+ if (!refreshTokenForCall) {
292
+ return null;
293
+ }
294
+
295
+ const response = await fetch("/api/v1/workspaces/refresh", {
296
+ method: "POST",
297
+ headers: { "Content-Type": "application/json" },
298
+ body: JSON.stringify({ refreshToken: refreshTokenForCall }),
299
+ });
300
+ if (!response.ok) {
301
+ const syncedToken = await waitForTokenBroadcast(REFRESH_RETRY_WAIT_MS);
302
+ if (syncedToken) {
303
+ return syncedToken;
304
+ }
305
+ const latest = getLatestWorkspaceTokens();
306
+ if (latest.token && latest.token !== workspaceTokenRef.current) {
307
+ applyWorkspaceTokens({
308
+ token: latest.token,
309
+ refreshToken: latest.refreshToken || workspaceRefreshTokenRef.current,
310
+ });
311
+ return latest.token;
312
+ }
313
+ return null;
314
+ }
315
+ const data = await response.json();
316
+ const nextToken = data?.workspaceToken || "";
317
+ const nextRefresh = data?.refreshToken || "";
318
+ if (nextToken && nextRefresh) {
319
+ applyWorkspaceTokens({ token: nextToken, refreshToken: nextRefresh });
320
+ broadcastAuthEvent("refresh_succeeded", {
321
+ at: Date.now(),
322
+ workspaceToken: nextToken,
323
+ refreshToken: nextRefresh,
324
+ });
325
+ notifyRefreshWaiters(nextToken);
326
+ return nextToken;
327
+ }
328
+ return null;
329
+ } catch {
330
+ const syncedToken = await waitForTokenBroadcast(REFRESH_RETRY_WAIT_MS);
331
+ if (syncedToken) {
332
+ return syncedToken;
333
+ }
334
+ return null;
335
+ } finally {
336
+ if (lockAcquired) {
337
+ releaseRefreshLock();
338
+ }
339
+ refreshInFlightRef.current = null;
340
+ }
341
+ })();
342
+ refreshInFlightRef.current = promise;
343
+ return promise;
344
+ }, [
345
+ acquireRefreshLock,
346
+ applyWorkspaceTokens,
347
+ broadcastAuthEvent,
348
+ getLatestWorkspaceTokens,
349
+ notifyRefreshWaiters,
350
+ releaseRefreshLock,
351
+ waitForTokenBroadcast,
352
+ ]);
353
+
354
+ useEffect(() => {
355
+ workspaceTokenRef.current = workspaceToken;
356
+ }, [workspaceToken]);
357
+
358
+ useEffect(() => {
359
+ workspaceRefreshTokenRef.current = workspaceRefreshToken;
360
+ }, [workspaceRefreshToken]);
361
+
362
+ useEffect(() => {
363
+ if (typeof BroadcastChannel !== "function") {
364
+ return undefined;
365
+ }
366
+ const channel = new BroadcastChannel(WORKSPACE_AUTH_CHANNEL);
367
+ refreshBroadcastChannelRef.current = channel;
368
+ const onMessage = (event) => {
369
+ const payload = event?.data;
370
+ if (!payload || payload.sourceTabId === tabIdRef.current) {
371
+ return;
372
+ }
373
+ if (payload.type === "refresh_succeeded") {
374
+ applyWorkspaceTokens({
375
+ token: payload.workspaceToken || "",
376
+ refreshToken: payload.refreshToken || "",
377
+ });
378
+ notifyRefreshWaiters(payload.workspaceToken || null);
379
+ } else if (payload.type === "workspace_left") {
380
+ applyWorkspaceTokens({ token: "", refreshToken: "" });
381
+ setWorkspaceId("");
382
+ }
383
+ };
384
+ channel.addEventListener("message", onMessage);
385
+ return () => {
386
+ channel.removeEventListener("message", onMessage);
387
+ channel.close();
388
+ refreshBroadcastChannelRef.current = null;
389
+ };
390
+ }, [applyWorkspaceTokens, notifyRefreshWaiters]);
391
+
392
+ useEffect(() => {
393
+ const onStorage = (event) => {
394
+ if (!event || !event.key) {
395
+ return;
396
+ }
397
+ if (event.key === WORKSPACE_TOKEN_KEY) {
398
+ const nextToken = event.newValue || "";
399
+ workspaceTokenRef.current = nextToken;
400
+ setWorkspaceToken(nextToken);
401
+ if (nextToken) {
402
+ notifyRefreshWaiters(nextToken);
403
+ }
404
+ return;
405
+ }
406
+ if (event.key === WORKSPACE_REFRESH_TOKEN_KEY) {
407
+ const nextRefreshToken = event.newValue || "";
408
+ workspaceRefreshTokenRef.current = nextRefreshToken;
409
+ setWorkspaceRefreshToken(nextRefreshToken);
410
+ return;
411
+ }
412
+ if (event.key === WORKSPACE_ID_KEY) {
413
+ setWorkspaceId(event.newValue || "");
414
+ }
415
+ };
416
+ window.addEventListener("storage", onStorage);
417
+ return () => {
418
+ window.removeEventListener("storage", onStorage);
419
+ };
420
+ }, [notifyRefreshWaiters]);
421
+
422
+ const apiFetch = useCallback(
423
+ async (input, init = {}) => {
424
+ const headers = new Headers(init.headers || {});
425
+ const tokenForRequest = workspaceTokenRef.current || workspaceToken;
426
+ if (tokenForRequest) {
427
+ headers.set("Authorization", `Bearer ${tokenForRequest}`);
428
+ }
429
+ const response = await fetch(input, { ...init, headers });
430
+ if (response.status !== 401) {
431
+ return response;
432
+ }
433
+ const refreshedToken = await refreshWorkspaceToken();
434
+ if (!refreshedToken) {
435
+ const latestToken = workspaceTokenRef.current || readWorkspaceToken();
436
+ if (latestToken && latestToken !== tokenForRequest) {
437
+ const retryHeaders = new Headers(init.headers || {});
438
+ retryHeaders.set("Authorization", `Bearer ${latestToken}`);
439
+ return fetch(input, { ...init, headers: retryHeaders });
440
+ }
441
+ handleLeaveWorkspace();
442
+ return response;
443
+ }
444
+ const retryHeaders = new Headers(init.headers || {});
445
+ retryHeaders.set("Authorization", `Bearer ${refreshedToken}`);
446
+ return fetch(input, { ...init, headers: retryHeaders });
447
+ },
448
+ [workspaceToken, refreshWorkspaceToken, handleLeaveWorkspace]
449
+ );
450
+
451
+ useEffect(() => {
452
+ let cancelled = false;
453
+ const fetchHealth = async () => {
454
+ try {
455
+ const response = await fetch("/api/v1/health");
456
+ if (!response.ok) {
457
+ return;
458
+ }
459
+ const data = await response.json();
460
+ if (!cancelled && data?.deploymentMode) {
461
+ setDeploymentMode(data.deploymentMode);
462
+ }
463
+ } catch {
464
+ // Ignore health errors.
465
+ }
466
+ };
467
+ fetchHealth();
468
+ return () => {
469
+ cancelled = true;
470
+ };
471
+ }, []);
472
+
473
+ useEffect(() => {
474
+ const monoAuthToken = readMonoAuthTokenFromHash();
475
+ if (!monoAuthToken) {
476
+ return;
477
+ }
478
+ let cancelled = false;
479
+ const consumeMonoAuthToken = async () => {
480
+ try {
481
+ const response = await fetch("/api/v1/workspaces/login", {
482
+ method: "POST",
483
+ headers: { "Content-Type": "application/json" },
484
+ body: JSON.stringify({
485
+ grantType: MONO_AUTH_GRANT_TYPE,
486
+ monoAuthToken,
487
+ }),
488
+ });
489
+ if (!response.ok) {
490
+ const payload = await response.json().catch(() => null);
491
+ const errorMessage =
492
+ payload?.error || t("Automatic mono-user authentication failed.");
493
+ if (!cancelled) {
494
+ setWorkspaceError(errorMessage);
495
+ }
496
+ return;
497
+ }
498
+ const data = await response.json();
499
+ if (!data?.workspaceToken) {
500
+ return;
501
+ }
502
+ if (!cancelled) {
503
+ setWorkspaceToken(data.workspaceToken || "");
504
+ setWorkspaceRefreshToken(data.refreshToken || "");
505
+ setWorkspaceId("default");
506
+ setWorkspaceIdInput("default");
507
+ setWorkspaceStep(4);
508
+ setWorkspaceError("");
509
+ }
510
+ } catch {
511
+ if (!cancelled) {
512
+ setWorkspaceError(t("Automatic mono-user authentication failed."));
513
+ }
514
+ } finally {
515
+ const url = new URL(window.location.href);
516
+ if (url.hash.includes("mono_auth=")) {
517
+ url.hash = "";
518
+ window.history.replaceState({}, "", url);
519
+ }
520
+ }
521
+ };
522
+ consumeMonoAuthToken();
523
+ return () => {
524
+ cancelled = true;
525
+ };
526
+ }, [
527
+ workspaceToken,
528
+ t,
529
+ setWorkspaceId,
530
+ setWorkspaceIdInput,
531
+ setWorkspaceRefreshToken,
532
+ setWorkspaceStep,
533
+ setWorkspaceToken,
534
+ setWorkspaceError,
535
+ ]);
536
+
537
+ useEffect(() => {
538
+ try {
539
+ if (workspaceToken) {
540
+ localStorage.setItem(WORKSPACE_TOKEN_KEY, workspaceToken);
541
+ } else {
542
+ localStorage.removeItem(WORKSPACE_TOKEN_KEY);
543
+ }
544
+ } catch {
545
+ // Ignore storage errors (private mode, quota).
546
+ }
547
+ }, [workspaceToken]);
548
+
549
+ useEffect(() => {
550
+ try {
551
+ if (workspaceRefreshToken) {
552
+ localStorage.setItem(WORKSPACE_REFRESH_TOKEN_KEY, workspaceRefreshToken);
553
+ } else {
554
+ localStorage.removeItem(WORKSPACE_REFRESH_TOKEN_KEY);
555
+ }
556
+ } catch {
557
+ // Ignore storage errors (private mode, quota).
558
+ }
559
+ }, [workspaceRefreshToken]);
560
+
561
+ useEffect(() => {
562
+ try {
563
+ if (workspaceId) {
564
+ localStorage.setItem(WORKSPACE_ID_KEY, workspaceId);
565
+ } else {
566
+ localStorage.removeItem(WORKSPACE_ID_KEY);
567
+ }
568
+ } catch {
569
+ // Ignore storage errors (private mode, quota).
570
+ }
571
+ }, [workspaceId]);
572
+
573
+ useEffect(() => {
574
+ if (!workspaceToken) {
575
+ setWorkspaceStep(1);
576
+ return;
577
+ }
578
+ setWorkspaceStep((current) => {
579
+ if (current >= 3) {
580
+ return current;
581
+ }
582
+ return 4;
583
+ });
584
+ }, [workspaceToken]);
585
+
586
+ const loadWorkspaceSessions = useCallback(async () => {
587
+ if (!workspaceToken) {
588
+ return;
589
+ }
590
+ setWorkspaceSessionsLoading(true);
591
+ setWorkspaceSessionsError("");
592
+ try {
593
+ const response = await apiFetch("/api/v1/sessions");
594
+ if (!response.ok) {
595
+ const payload = await response.json().catch(() => null);
596
+ throw new Error(payload?.error || t("Unable to load sessions."));
597
+ }
598
+ const data = await response.json();
599
+ const list = Array.isArray(data?.sessions) ? data.sessions : [];
600
+ setWorkspaceSessions(list);
601
+ } catch (error) {
602
+ setWorkspaceSessionsError(
603
+ error.message || t("Unable to load sessions.")
604
+ );
605
+ } finally {
606
+ setWorkspaceSessionsLoading(false);
607
+ }
608
+ }, [apiFetch, workspaceToken, handleLeaveWorkspace, t]);
609
+
610
+ useEffect(() => {
611
+ if (!workspaceToken || workspaceStep !== 4) {
612
+ return;
613
+ }
614
+ loadWorkspaceSessions();
615
+ }, [workspaceStep, workspaceToken, loadWorkspaceSessions]);
616
+
617
+ const loadWorkspaceProviders = useCallback(async () => {
618
+ const activeWorkspaceId = (workspaceId || workspaceIdInput || "").trim();
619
+ if (!activeWorkspaceId) {
620
+ return;
621
+ }
622
+ setWorkspaceBusy(true);
623
+ setWorkspaceError("");
624
+ try {
625
+ const response = await apiFetch(
626
+ `/api/v1/workspaces/${encodeURIComponent(activeWorkspaceId)}`
627
+ );
628
+ if (!response.ok) {
629
+ const payload = await response.json().catch(() => null);
630
+ throw new Error(payload?.error || t("Unable to load providers."));
631
+ }
632
+ const data = await response.json();
633
+ const providers = data?.providers || {};
634
+ setWorkspaceProviders((current) => ({
635
+ codex: {
636
+ ...current.codex,
637
+ enabled: Boolean(providers?.codex?.enabled),
638
+ authType: providers?.codex?.auth?.type || "api_key",
639
+ previousAuthType: providers?.codex?.auth?.type || "api_key",
640
+ authValue: "",
641
+ },
642
+ claude: {
643
+ ...current.claude,
644
+ enabled: Boolean(providers?.claude?.enabled),
645
+ authType: providers?.claude?.auth?.type || "api_key",
646
+ previousAuthType: providers?.claude?.auth?.type || "api_key",
647
+ authValue: "",
648
+ },
649
+ }));
650
+ setWorkspaceAuthExpanded((current) => ({
651
+ ...current,
652
+ codex: Boolean(providers?.codex?.enabled),
653
+ claude: Boolean(providers?.claude?.enabled),
654
+ }));
655
+ setWorkspaceAuthFiles(defaultAuthFiles());
656
+ } catch (error) {
657
+ setWorkspaceError(error.message || t("Unable to load providers."));
658
+ } finally {
659
+ setWorkspaceBusy(false);
660
+ }
661
+ }, [apiFetch, handleLeaveWorkspace, t, workspaceId, workspaceIdInput]);
662
+
663
+ const handleWorkspaceSubmit = async (event) => {
664
+ event.preventDefault();
665
+ setWorkspaceError("");
666
+ setWorkspaceBusy(true);
667
+ try {
668
+ if (workspaceMode === "existing") {
669
+ const workspaceIdValue = workspaceIdInput.trim();
670
+ const secretValue = workspaceSecretInput.trim();
671
+ if (!workspaceIdValue || !secretValue) {
672
+ throw new Error(t("Workspace ID and secret are required."));
673
+ }
674
+ const response = await apiFetch("/api/v1/workspaces/login", {
675
+ method: "POST",
676
+ headers: { "Content-Type": "application/json" },
677
+ body: JSON.stringify({
678
+ workspaceId: workspaceIdValue,
679
+ workspaceSecret: secretValue,
680
+ }),
681
+ });
682
+ if (!response.ok) {
683
+ const payload = await response.json().catch(() => null);
684
+ throw new Error(payload?.error || t("Authentication failed."));
685
+ }
686
+ const data = await response.json();
687
+ setWorkspaceToken(data.workspaceToken || "");
688
+ setWorkspaceRefreshToken(data.refreshToken || "");
689
+ setWorkspaceId(workspaceIdValue);
690
+ setWorkspaceStep(4);
691
+ return;
692
+ }
693
+ setWorkspaceProvidersEditing(false);
694
+ setProvidersBackStep(1);
695
+ setWorkspaceStep(2);
696
+ } catch (error) {
697
+ setWorkspaceError(
698
+ error.message || t("Workspace configuration failed.")
699
+ );
700
+ } finally {
701
+ setWorkspaceBusy(false);
702
+ }
703
+ };
704
+
705
+ const handleWorkspaceProvidersSubmit = async (event) => {
706
+ event.preventDefault();
707
+ setWorkspaceError("");
708
+ setWorkspaceBusy(true);
709
+ try {
710
+ const providersPayload = {};
711
+ ["codex", "claude"].forEach((provider) => {
712
+ const config = workspaceProviders[provider];
713
+ if (!config?.enabled) {
714
+ providersPayload[provider] = { enabled: false };
715
+ return;
716
+ }
717
+ const trimmedValue = (config.authValue || "").trim();
718
+ const type = getProviderAuthType(provider, config) || "api_key";
719
+ if (
720
+ workspaceProvidersEditing &&
721
+ config.previousAuthType &&
722
+ type !== config.previousAuthType &&
723
+ !trimmedValue
724
+ ) {
725
+ throw new Error(t("Key required for {{provider}}.", { provider }));
726
+ }
727
+ if (!workspaceProvidersEditing && !trimmedValue) {
728
+ throw new Error(t("Key required for {{provider}}.", { provider }));
729
+ }
730
+ if (trimmedValue) {
731
+ const value =
732
+ type === "auth_json_b64" && encodeBase64
733
+ ? encodeBase64(trimmedValue)
734
+ : trimmedValue;
735
+ providersPayload[provider] = {
736
+ enabled: true,
737
+ auth: { type, value },
738
+ };
739
+ } else {
740
+ providersPayload[provider] = {
741
+ enabled: true,
742
+ auth: { type },
743
+ };
744
+ }
745
+ });
746
+ if (Object.keys(providersPayload).length === 0) {
747
+ throw new Error(t("Select at least one provider."));
748
+ }
749
+ if (workspaceProvidersEditing) {
750
+ const activeWorkspaceId = (workspaceId || workspaceIdInput || "").trim();
751
+ if (!activeWorkspaceId) {
752
+ throw new Error(t("Workspace ID required."));
753
+ }
754
+ const updateResponse = await apiFetch(
755
+ `/api/v1/workspaces/${encodeURIComponent(activeWorkspaceId)}`,
756
+ {
757
+ method: "PATCH",
758
+ headers: { "Content-Type": "application/json" },
759
+ body: JSON.stringify({ providers: providersPayload }),
760
+ }
761
+ );
762
+ if (!updateResponse.ok) {
763
+ const payload = await updateResponse.json().catch(() => null);
764
+ throw new Error(
765
+ payload?.error || t("Workspace update failed.")
766
+ );
767
+ }
768
+ await updateResponse.json().catch(() => null);
769
+ setWorkspaceProvidersEditing(false);
770
+ setWorkspaceStep(4);
771
+ showToast?.(t("AI providers updated."), "success");
772
+ return;
773
+ }
774
+ const createResponse = await apiFetch("/api/v1/workspaces", {
775
+ method: "POST",
776
+ headers: { "Content-Type": "application/json" },
777
+ body: JSON.stringify({ providers: providersPayload }),
778
+ });
779
+ if (!createResponse.ok) {
780
+ const payload = await createResponse.json().catch(() => null);
781
+ throw new Error(payload?.error || t("Workspace creation failed."));
782
+ }
783
+ const created = await createResponse.json();
784
+ setWorkspaceCreated(created);
785
+ setWorkspaceId(created.workspaceId);
786
+ setWorkspaceIdInput(created.workspaceId);
787
+ const loginResponse = await apiFetch("/api/v1/workspaces/login", {
788
+ method: "POST",
789
+ headers: { "Content-Type": "application/json" },
790
+ body: JSON.stringify({
791
+ workspaceId: created.workspaceId,
792
+ workspaceSecret: created.workspaceSecret,
793
+ }),
794
+ });
795
+ if (!loginResponse.ok) {
796
+ const payload = await loginResponse.json().catch(() => null);
797
+ throw new Error(payload?.error || t("Authentication failed."));
798
+ }
799
+ const loginData = await loginResponse.json();
800
+ setWorkspaceToken(loginData.workspaceToken || "");
801
+ setWorkspaceRefreshToken(loginData.refreshToken || "");
802
+ setWorkspaceStep(3);
803
+ } catch (error) {
804
+ setWorkspaceError(
805
+ error.message || t("Workspace configuration failed.")
806
+ );
807
+ } finally {
808
+ setWorkspaceBusy(false);
809
+ }
810
+ };
811
+
812
+ const handleWorkspaceCopy = useCallback((key, value) => {
813
+ if (!value) {
814
+ return;
815
+ }
816
+ if (copyTextToClipboard) {
817
+ copyTextToClipboard(value);
818
+ }
819
+ setWorkspaceCopied((current) => ({ ...current, [key]: true }));
820
+ const timers = workspaceCopyTimersRef.current;
821
+ if (timers[key]) {
822
+ clearTimeout(timers[key]);
823
+ }
824
+ timers[key] = setTimeout(() => {
825
+ setWorkspaceCopied((current) => ({ ...current, [key]: false }));
826
+ timers[key] = null;
827
+ }, 2000);
828
+ }, []);
829
+
830
+ useEffect(() => {
831
+ return () => {
832
+ Object.values(workspaceCopyTimersRef.current || {}).forEach((timer) => {
833
+ if (timer) {
834
+ clearTimeout(timer);
835
+ }
836
+ });
837
+ };
838
+ }, []);
839
+
840
+ const handleDeleteSession = async (session) => {
841
+ const sessionId = session?.sessionId;
842
+ if (!sessionId) {
843
+ return;
844
+ }
845
+ const repoName = extractRepoName?.(session?.repoUrl || "");
846
+ const title = session?.name || repoName || sessionId;
847
+ const shouldDelete = window.confirm(
848
+ t("Supprimer la session \"{{title}}\" ? Cette action est irreversible.", {
849
+ title,
850
+ })
851
+ );
852
+ if (!shouldDelete) {
853
+ return;
854
+ }
855
+ try {
856
+ setWorkspaceSessionDeletingId(sessionId);
857
+ setWorkspaceSessionsError("");
858
+ const response = await apiFetch(
859
+ `/api/v1/sessions/${encodeURIComponent(sessionId)}`,
860
+ { method: "DELETE" }
861
+ );
862
+ if (!response.ok) {
863
+ let details = "";
864
+ try {
865
+ const payload = await response.json();
866
+ if (typeof payload?.error === "string") {
867
+ details = payload.error;
868
+ }
869
+ } catch {
870
+ // Ignore parse errors.
871
+ }
872
+ const suffix = details ? `: ${details}` : "";
873
+ throw new Error(
874
+ t("Unable to delete the session{{suffix}}.", { suffix })
875
+ );
876
+ }
877
+ await loadWorkspaceSessions();
878
+ showToast?.(t("Session \"{{title}}\" supprimee.", { title }), "success");
879
+ } catch (error) {
880
+ setWorkspaceSessionsError(
881
+ error.message || t("Unable to delete the session.")
882
+ );
883
+ } finally {
884
+ setWorkspaceSessionDeletingId(null);
885
+ }
886
+ };
887
+
888
+ return {
889
+ apiFetch,
890
+ deploymentMode,
891
+ handleDeleteSession,
892
+ handleLeaveWorkspace,
893
+ handleWorkspaceCopy,
894
+ handleWorkspaceProvidersSubmit,
895
+ handleWorkspaceSubmit,
896
+ loadWorkspaceProviders,
897
+ loadWorkspaceSessions,
898
+ providersBackStep,
899
+ refreshWorkspaceToken,
900
+ setProvidersBackStep,
901
+ setWorkspaceAuthExpanded,
902
+ setWorkspaceAuthFiles,
903
+ setWorkspaceError,
904
+ setWorkspaceId,
905
+ setWorkspaceIdInput,
906
+ setWorkspaceMode,
907
+ setWorkspaceProviders,
908
+ setWorkspaceProvidersEditing,
909
+ setWorkspaceRefreshToken,
910
+ setWorkspaceSecretInput,
911
+ setWorkspaceStep,
912
+ setWorkspaceToken,
913
+ workspaceAuthExpanded,
914
+ workspaceAuthFiles,
915
+ workspaceBusy,
916
+ workspaceCopied,
917
+ workspaceCreated,
918
+ workspaceError,
919
+ workspaceId,
920
+ workspaceIdInput,
921
+ workspaceMode,
922
+ workspaceProviders,
923
+ workspaceProvidersEditing,
924
+ workspaceSecretInput,
925
+ workspaceSessionDeletingId,
926
+ workspaceSessions,
927
+ workspaceSessionsError,
928
+ workspaceSessionsLoading,
929
+ workspaceStep,
930
+ workspaceToken,
931
+ };
932
+ }