@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,3131 @@
1
+ import {
2
+ lazy,
3
+ Suspense,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import "@uiw/react-markdown-preview/markdown.css";
11
+ import { parseDiff } from "react-diff-view";
12
+ import "react-diff-view/style/index.css";
13
+ import "@xterm/xterm/css/xterm.css";
14
+ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
15
+ import {
16
+ faChevronDown,
17
+ faChevronRight,
18
+ faFileLines,
19
+ faGear,
20
+ faPaperclip,
21
+ faXmark,
22
+ } from "@fortawesome/free-solid-svg-icons";
23
+ import ChatMessages from "./components/Chat/ChatMessages.jsx";
24
+ import ChatComposer from "./components/Chat/ChatComposer.jsx";
25
+ import ChatToolbar from "./components/Chat/ChatToolbar.jsx";
26
+ import useChatComposer from "./components/Chat/useChatComposer.js";
27
+ import useChatSocket from "./hooks/useChatSocket.js";
28
+ import useWorkspaceAuth from "./hooks/useWorkspaceAuth.js";
29
+ import useWorktrees from "./hooks/useWorktrees.js";
30
+ import useTerminalSession from "./hooks/useTerminalSession.js";
31
+ import useNotifications from "./hooks/useNotifications.js";
32
+ import useRepoStatus from "./hooks/useRepoStatus.js";
33
+ import useAttachments from "./hooks/useAttachments.jsx";
34
+ import useBacklog from "./hooks/useBacklog.js";
35
+ import useChatCommands from "./hooks/useChatCommands.js";
36
+ import useRepoBranchesModels from "./hooks/useRepoBranchesModels.js";
37
+ import useSessionLifecycle from "./hooks/useSessionLifecycle.js";
38
+ import useSessionHandoff from "./hooks/useSessionHandoff.js";
39
+ import useGitIdentity from "./hooks/useGitIdentity.js";
40
+ import useVibe80Forms from "./hooks/useVibe80Forms.js";
41
+ import useLocalPreferences from "./hooks/useLocalPreferences.js";
42
+ import useLayoutMode from "./hooks/useLayoutMode.js";
43
+ import useRpcLogView from "./hooks/useRpcLogView.js";
44
+ import useToolbarExport from "./hooks/useToolbarExport.js";
45
+ import usePanelState from "./hooks/usePanelState.js";
46
+ import usePaneNavigation from "./hooks/usePaneNavigation.js";
47
+ import useSessionReset from "./hooks/useSessionReset.js";
48
+ import useTurnInterrupt from "./hooks/useTurnInterrupt.js";
49
+ import useDiffNavigation from "./hooks/useDiffNavigation.js";
50
+ import useChatCollapse from "./hooks/useChatCollapse.js";
51
+ import useSessionResync from "./hooks/useSessionResync.js";
52
+ import useMessageSync from "./hooks/useMessageSync.js";
53
+ import useChatMessagesState from "./hooks/useChatMessagesState.js";
54
+ import useWorktreeCloseConfirm from "./hooks/useWorktreeCloseConfirm.js";
55
+ import useRpcLogActions from "./hooks/useRpcLogActions.js";
56
+ import useExplorerActions from "./hooks/useExplorerActions.js";
57
+ import useProviderSelection from "./hooks/useProviderSelection.js";
58
+ import useChatExport from "./hooks/useChatExport.js";
59
+ import useChatSend from "./hooks/useChatSend.js";
60
+ import useChatClear from "./hooks/useChatClear.js";
61
+ const ExplorerPanel = lazy(() => import("./components/Explorer/ExplorerPanel.jsx"));
62
+ const DiffPanel = lazy(() => import("./components/Diff/DiffPanel.jsx"));
63
+ import Topbar from "./components/Topbar/Topbar.jsx";
64
+ const TerminalPanel = lazy(() => import("./components/Terminal/TerminalPanel.jsx"));
65
+ import SessionGate from "./components/SessionGate/SessionGate.jsx";
66
+ const SettingsPanel = lazy(() => import("./components/Settings/SettingsPanel.jsx"));
67
+ const LogsPanel = lazy(() => import("./components/Logs/LogsPanel.jsx"));
68
+ import vibe80LogoDark from "./assets/vibe80_dark.svg";
69
+ import vibe80LogoLight from "./assets/vibe80_light.svg";
70
+ import { useI18n } from "./i18n.jsx";
71
+
72
+ const getSessionIdFromUrl = () =>
73
+ new URLSearchParams(window.location.search).get("session");
74
+
75
+ const getRepositoryFromUrl = () =>
76
+ new URLSearchParams(window.location.search).get("repository");
77
+
78
+ const getInitialRepoUrl = () => {
79
+ const sessionId = getSessionIdFromUrl();
80
+ if (sessionId) {
81
+ return "";
82
+ }
83
+ const repoFromQuery = getRepositoryFromUrl();
84
+ const trimmed = repoFromQuery ? repoFromQuery.trim() : "";
85
+ return trimmed || "";
86
+ };
87
+
88
+ const encodeBase64 = (value) => {
89
+ if (!value) {
90
+ return "";
91
+ }
92
+ try {
93
+ const bytes = new TextEncoder().encode(value);
94
+ let binary = "";
95
+ bytes.forEach((byte) => {
96
+ binary += String.fromCharCode(byte);
97
+ });
98
+ return btoa(binary);
99
+ } catch {
100
+ return btoa(value);
101
+ }
102
+ };
103
+
104
+ const providerAuthOptions = {
105
+ codex: ["api_key", "auth_json_b64"],
106
+ claude: ["api_key", "setup_token"],
107
+ };
108
+
109
+ const getProviderAuthType = (provider, config) => {
110
+ const allowed = providerAuthOptions[provider] || [];
111
+ if (!allowed.length) {
112
+ return config?.authType || "api_key";
113
+ }
114
+ return allowed.includes(config?.authType) ? config.authType : allowed[0];
115
+ };
116
+
117
+ const normalizeVibe80Question = (rawQuestion) => {
118
+ const trimmed = rawQuestion?.trim();
119
+ if (!trimmed) {
120
+ return "";
121
+ }
122
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
123
+ return trimmed.slice(1, -1).trim();
124
+ }
125
+ return trimmed;
126
+ };
127
+
128
+ const parseFormFields = (blockBody) => {
129
+ if (!blockBody) {
130
+ return [];
131
+ }
132
+ return blockBody
133
+ .split(/\r?\n/)
134
+ .map((line) => line.trim())
135
+ .filter(Boolean)
136
+ .map((line) => {
137
+ const parts = line.split("::");
138
+ const [rawType, rawId, rawLabel, ...rest] = parts;
139
+ const type = (rawType || "").trim().toLowerCase();
140
+ const id = (rawId || "").trim();
141
+ const label = (rawLabel || "").trim();
142
+ if (!type || !id || !label) {
143
+ return null;
144
+ }
145
+ if (type === "radio" || type === "select") {
146
+ const choices = rest.map((item) => item.trim()).filter(Boolean);
147
+ return { type, id, label, choices };
148
+ }
149
+ if (type === "checkbox") {
150
+ const rawValue = rest.join("::").trim();
151
+ const defaultChecked = rawValue === "1";
152
+ return { type, id, label, defaultChecked };
153
+ }
154
+ if (type === "input" || type === "textarea") {
155
+ const defaultValue = rest.join("::").trim();
156
+ return { type, id, label, defaultValue };
157
+ }
158
+ return null;
159
+ })
160
+ .filter(Boolean);
161
+ };
162
+
163
+ const extractVibe80Blocks = (text, t = (value) => value) => {
164
+ const pattern =
165
+ /<!--\s*vibe80:(choices|form)\s*([^>]*)-->([\s\S]*?)<!--\s*\/vibe80:\1\s*-->|<!--\s*vibe80:yesno\s*([^>]*)-->/g;
166
+ const filerefPattern = /<!--\s*vibe80:fileref\s+([^>]+?)\s*-->/g;
167
+ const taskPattern = /<!--\s*vibe80:task\s*[^>]*-->/g;
168
+ const blocks = [];
169
+ const filerefs = [];
170
+ const normalizedText = String(text || "")
171
+ .replace(filerefPattern, (_, filePath) => {
172
+ const trimmed = String(filePath || "").trim();
173
+ if (trimmed) {
174
+ filerefs.push(trimmed);
175
+ }
176
+ return "";
177
+ })
178
+ .replace(taskPattern, "");
179
+ let cleaned = "";
180
+ let lastIndex = 0;
181
+ let match;
182
+
183
+ while ((match = pattern.exec(normalizedText)) !== null) {
184
+ cleaned += normalizedText.slice(lastIndex, match.index);
185
+ lastIndex = match.index + match[0].length;
186
+ const blockType = match[1];
187
+ const question = normalizeVibe80Question(match[2] || match[4]);
188
+ const body = match[3] || "";
189
+
190
+ if (!blockType) {
191
+ blocks.push({
192
+ type: "yesno",
193
+ question,
194
+ choices: [t("Yes"), t("No")],
195
+ });
196
+ continue;
197
+ }
198
+
199
+ if (blockType === "choices") {
200
+ const choices = body
201
+ .split(/\r?\n/)
202
+ .map((line) => line.trim())
203
+ .filter(Boolean);
204
+ if (choices.length) {
205
+ blocks.push({ type: "choices", question, choices });
206
+ }
207
+ continue;
208
+ }
209
+
210
+ const fields = parseFormFields(body);
211
+ if (fields.length) {
212
+ blocks.push({ type: "form", question, fields });
213
+ }
214
+ }
215
+
216
+ if (!blocks.length) {
217
+ return { cleanedText: normalizedText, blocks: [], filerefs };
218
+ }
219
+
220
+ cleaned += normalizedText.slice(lastIndex);
221
+ return { cleanedText: cleaned.trim(), blocks, filerefs };
222
+ };
223
+
224
+ const extractVibe80Task = (text) => {
225
+ const pattern = /<!--\s*vibe80:task\s*([^>]*)-->/g;
226
+ const raw = String(text || "");
227
+ let label = "";
228
+ let match;
229
+ while ((match = pattern.exec(raw)) !== null) {
230
+ const normalized = normalizeVibe80Question(match[1]);
231
+ if (normalized) {
232
+ label = normalized;
233
+ }
234
+ }
235
+ return label;
236
+ };
237
+
238
+ const extractFirstLine = (text) => {
239
+ const raw = String(text || "");
240
+ if (!raw) {
241
+ return "";
242
+ }
243
+ return raw.split(/\r?\n/)[0].trim();
244
+ };
245
+
246
+ const copyTextToClipboard = async (text) => {
247
+ if (!text) {
248
+ return;
249
+ }
250
+ if (navigator?.clipboard?.writeText) {
251
+ try {
252
+ await navigator.clipboard.writeText(text);
253
+ return;
254
+ } catch {
255
+ // Fall back to legacy copy behavior.
256
+ }
257
+ }
258
+ const textArea = document.createElement("textarea");
259
+ textArea.value = text;
260
+ textArea.setAttribute("readonly", "");
261
+ textArea.style.position = "absolute";
262
+ textArea.style.left = "-9999px";
263
+ document.body.appendChild(textArea);
264
+ textArea.select();
265
+ document.execCommand("copy");
266
+ document.body.removeChild(textArea);
267
+ };
268
+
269
+ const MAX_USER_DISPLAY_LENGTH = 1024;
270
+ const BACKLOG_PAGE_SIZE = 5;
271
+ const CHAT_COLLAPSE_THRESHOLD = 140;
272
+ const CHAT_COLLAPSE_VISIBLE = 60;
273
+ const REPO_HISTORY_KEY = "repoHistory";
274
+ const AUTH_MODE_KEY = "authMode";
275
+ const OPENAI_AUTH_MODE_KEY = "openAiAuthMode";
276
+ const LLM_PROVIDER_KEY = "llmProvider";
277
+ const LLM_PROVIDERS_KEY = "llmProviders";
278
+ const CHAT_COMMANDS_VISIBLE_KEY = "chatCommandsVisible";
279
+ const TOOL_RESULTS_VISIBLE_KEY = "toolResultsVisible";
280
+ const NOTIFICATIONS_ENABLED_KEY = "notificationsEnabled";
281
+ const THEME_MODE_KEY = "themeMode";
282
+ const DEBUG_MODE_KEY = "debugMode";
283
+ const MAX_REPO_HISTORY = 10;
284
+ const SOCKET_PING_INTERVAL_MS = 25000;
285
+ const SOCKET_PONG_GRACE_MS = 8000;
286
+ const IMAGE_EXTENSIONS = new Set([
287
+ "png",
288
+ "jpg",
289
+ "jpeg",
290
+ "gif",
291
+ "webp",
292
+ "bmp",
293
+ "svg",
294
+ "avif",
295
+ ]);
296
+
297
+ const getTruncatedText = (text, limit) => {
298
+ if (!text) {
299
+ return "";
300
+ }
301
+ if (text.length <= limit) {
302
+ return text;
303
+ }
304
+ return `${text.slice(0, limit)}…`;
305
+ };
306
+
307
+ const getAttachmentName = (attachment) => {
308
+ if (!attachment) {
309
+ return "";
310
+ }
311
+ if (typeof attachment === "string") {
312
+ const parts = attachment.split("/");
313
+ return parts[parts.length - 1] || attachment;
314
+ }
315
+ if (attachment.name) {
316
+ return attachment.name;
317
+ }
318
+ if (attachment.path) {
319
+ const parts = attachment.path.split("/");
320
+ return parts[parts.length - 1] || attachment.path;
321
+ }
322
+ return "";
323
+ };
324
+
325
+ const getAttachmentExtension = (attachment, t = (value) => value) => {
326
+ const name = getAttachmentName(attachment);
327
+ if (!name || !name.includes(".")) {
328
+ return t("FILE");
329
+ }
330
+ const ext = name.split(".").pop();
331
+ return ext ? ext.toUpperCase() : t("FILE");
332
+ };
333
+
334
+ const formatAttachmentSize = (bytes, t = (value) => value) => {
335
+ if (!Number.isFinite(bytes)) {
336
+ return "";
337
+ }
338
+ if (bytes < 1024) {
339
+ return t("{{count}} B", { count: bytes });
340
+ }
341
+ const kb = bytes / 1024;
342
+ if (kb < 1024) {
343
+ return t("{{count}} KB", { count: Math.round(kb) });
344
+ }
345
+ const mb = kb / 1024;
346
+ return t("{{count}} MB", { count: mb.toFixed(1) });
347
+ };
348
+
349
+ const normalizeAttachments = (attachments) => {
350
+ if (!Array.isArray(attachments)) {
351
+ return [];
352
+ }
353
+ return attachments
354
+ .map((item) => {
355
+ if (!item) {
356
+ return null;
357
+ }
358
+ if (typeof item === "string") {
359
+ const name = getAttachmentName(item);
360
+ return { name, path: item };
361
+ }
362
+ if (typeof item === "object") {
363
+ const name = item.name || getAttachmentName(item.path);
364
+ return { ...item, name };
365
+ }
366
+ return null;
367
+ })
368
+ .filter(Boolean);
369
+ };
370
+
371
+ const isImageAttachment = (attachment) => {
372
+ const name = getAttachmentName(attachment);
373
+ if (!name || !name.includes(".")) {
374
+ return false;
375
+ }
376
+ const ext = name.split(".").pop()?.toLowerCase();
377
+ return IMAGE_EXTENSIONS.has(ext);
378
+ };
379
+
380
+ const readRepoHistory = () => {
381
+ try {
382
+ const raw = localStorage.getItem(REPO_HISTORY_KEY);
383
+ if (!raw) {
384
+ return [];
385
+ }
386
+ const parsed = JSON.parse(raw);
387
+ if (!Array.isArray(parsed)) {
388
+ return [];
389
+ }
390
+ return parsed
391
+ .filter((entry) => typeof entry === "string")
392
+ .map((entry) => entry.trim())
393
+ .filter(Boolean);
394
+ } catch (error) {
395
+ return [];
396
+ }
397
+ };
398
+
399
+ const readAuthMode = () => {
400
+ try {
401
+ const stored = localStorage.getItem(AUTH_MODE_KEY);
402
+ if (stored === "ssh" || stored === "http" || stored === "none") {
403
+ return stored;
404
+ }
405
+ } catch (error) {
406
+ // Ignore storage errors (private mode, quota).
407
+ }
408
+ return "none";
409
+ };
410
+
411
+ const readOpenAiAuthMode = () => {
412
+ try {
413
+ const stored = localStorage.getItem(OPENAI_AUTH_MODE_KEY);
414
+ if (stored === "apiKey" || stored === "authFile") {
415
+ return stored;
416
+ }
417
+ } catch (error) {
418
+ // Ignore storage errors (private mode, quota).
419
+ }
420
+ return "apiKey";
421
+ };
422
+
423
+ const readChatCommandsVisible = () => {
424
+ try {
425
+ const stored = localStorage.getItem(CHAT_COMMANDS_VISIBLE_KEY);
426
+ if (stored === "true" || stored === "false") {
427
+ return stored === "true";
428
+ }
429
+ } catch (error) {
430
+ // Ignore storage errors (private mode, quota).
431
+ }
432
+ return true;
433
+ };
434
+
435
+ const readToolResultsVisible = () => {
436
+ try {
437
+ const stored = localStorage.getItem(TOOL_RESULTS_VISIBLE_KEY);
438
+ if (stored === "true" || stored === "false") {
439
+ return stored === "true";
440
+ }
441
+ } catch (error) {
442
+ // Ignore storage errors (private mode, quota).
443
+ }
444
+ return false;
445
+ };
446
+
447
+ const readNotificationsEnabled = () => {
448
+ try {
449
+ const stored = localStorage.getItem(NOTIFICATIONS_ENABLED_KEY);
450
+ if (stored === "true" || stored === "false") {
451
+ return stored === "true";
452
+ }
453
+ } catch (error) {
454
+ // Ignore storage errors (private mode, quota).
455
+ }
456
+ return true;
457
+ };
458
+
459
+ const readThemeMode = () => {
460
+ try {
461
+ const stored = localStorage.getItem(THEME_MODE_KEY);
462
+ if (stored === "light" || stored === "dark") {
463
+ return stored;
464
+ }
465
+ } catch (error) {
466
+ // Ignore storage errors (private mode, quota).
467
+ }
468
+ return "light";
469
+ };
470
+
471
+
472
+ const readDebugMode = () => {
473
+ try {
474
+ const stored = localStorage.getItem(DEBUG_MODE_KEY);
475
+ if (stored === "true" || stored === "false") {
476
+ return stored === "true";
477
+ }
478
+ } catch (error) {
479
+ // Ignore storage errors (private mode, quota).
480
+ }
481
+ return false;
482
+ };
483
+
484
+ const readLlmProvider = () => {
485
+ try {
486
+ const stored = localStorage.getItem(LLM_PROVIDER_KEY);
487
+ if (stored === "codex" || stored === "claude") {
488
+ return stored;
489
+ }
490
+ } catch (error) {
491
+ // Ignore storage errors (private mode, quota).
492
+ }
493
+ return "codex";
494
+ };
495
+
496
+ const readLlmProviders = () => {
497
+ try {
498
+ const stored = localStorage.getItem(LLM_PROVIDERS_KEY);
499
+ if (!stored) {
500
+ return [readLlmProvider()];
501
+ }
502
+ const parsed = JSON.parse(stored);
503
+ if (!Array.isArray(parsed)) {
504
+ return [readLlmProvider()];
505
+ }
506
+ const filtered = parsed.filter(
507
+ (entry) => entry === "codex" || entry === "claude"
508
+ );
509
+ return filtered.length ? filtered : [readLlmProvider()];
510
+ } catch (error) {
511
+ // Ignore storage errors (private mode, quota).
512
+ }
513
+ return [readLlmProvider()];
514
+ };
515
+
516
+ const mergeRepoHistory = (history, url) => {
517
+ const trimmed = url.trim();
518
+ if (!trimmed) {
519
+ return history;
520
+ }
521
+ const next = [trimmed, ...history.filter((entry) => entry !== trimmed)].slice(
522
+ 0,
523
+ MAX_REPO_HISTORY
524
+ );
525
+ if (
526
+ next.length === history.length &&
527
+ next.every((entry, index) => entry === history[index])
528
+ ) {
529
+ return history;
530
+ }
531
+ return next;
532
+ };
533
+
534
+ const downloadTextFile = (filename, content, type) => {
535
+ const blob = new Blob([content], { type });
536
+ const url = URL.createObjectURL(blob);
537
+ const link = document.createElement("a");
538
+ link.href = url;
539
+ link.download = filename;
540
+ document.body.appendChild(link);
541
+ link.click();
542
+ link.remove();
543
+ URL.revokeObjectURL(url);
544
+ };
545
+
546
+ const formatExportName = (base, extension) => {
547
+ const safeBase = base || "chat";
548
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
549
+ return `${safeBase}-${stamp}.${extension}`;
550
+ };
551
+
552
+ const extractRepoName = (url) => {
553
+ if (!url) {
554
+ return "";
555
+ }
556
+ const trimmed = url.trim().replace(/\/+$/, "");
557
+ const withoutQuery = trimmed.split(/[?#]/)[0];
558
+ const match = withoutQuery.match(/([^/:]+)$/);
559
+ return match ? match[1] : "";
560
+ };
561
+
562
+ const getLanguageForPath = (filePath) => {
563
+ if (!filePath) {
564
+ return "plaintext";
565
+ }
566
+ const baseName = filePath.split("/").pop() || "";
567
+ if (baseName.toLowerCase() === "dockerfile") {
568
+ return "dockerfile";
569
+ }
570
+ const match = filePath.toLowerCase().match(/\.([a-z0-9]+)$/);
571
+ const ext = match ? match[1] : "";
572
+ switch (ext) {
573
+ case "js":
574
+ case "cjs":
575
+ case "mjs":
576
+ return "javascript";
577
+ case "jsx":
578
+ return "javascript";
579
+ case "ts":
580
+ return "typescript";
581
+ case "tsx":
582
+ return "typescript";
583
+ case "json":
584
+ return "json";
585
+ case "md":
586
+ case "markdown":
587
+ return "markdown";
588
+ case "css":
589
+ return "css";
590
+ case "scss":
591
+ return "scss";
592
+ case "less":
593
+ return "less";
594
+ case "html":
595
+ case "htm":
596
+ return "html";
597
+ case "yml":
598
+ case "yaml":
599
+ return "yaml";
600
+ case "sh":
601
+ case "bash":
602
+ case "zsh":
603
+ return "shell";
604
+ case "py":
605
+ return "python";
606
+ case "go":
607
+ return "go";
608
+ case "java":
609
+ return "java";
610
+ case "c":
611
+ return "c";
612
+ case "cc":
613
+ case "cpp":
614
+ case "cxx":
615
+ case "hpp":
616
+ case "h":
617
+ return "cpp";
618
+ case "rs":
619
+ return "rust";
620
+ case "rb":
621
+ return "ruby";
622
+ case "php":
623
+ return "php";
624
+ case "sql":
625
+ return "sql";
626
+ case "toml":
627
+ return "toml";
628
+ case "xml":
629
+ return "xml";
630
+ case "dockerfile":
631
+ return "dockerfile";
632
+ default:
633
+ return "plaintext";
634
+ }
635
+ };
636
+
637
+ const formatProviderLabel = (provider, t = (value) => value) => {
638
+ if (provider === "codex") {
639
+ return t("Codex");
640
+ }
641
+ if (provider === "claude") {
642
+ return t("Claude");
643
+ }
644
+ return provider || "";
645
+ };
646
+
647
+ function App() {
648
+ const { t, language, setLanguage, locale } = useI18n();
649
+ const [messages, setMessages] = useState([]);
650
+ const [input, setInput] = useState("");
651
+ const [status, setStatus] = useState(() => t("Connecting..."));
652
+ const [processing, setProcessing] = useState(false);
653
+ const [hasMainWorktreeStatus, setHasMainWorktreeStatus] = useState(false);
654
+ const [activity, setActivity] = useState("");
655
+ const [connected, setConnected] = useState(false);
656
+ const [attachmentSession, setAttachmentSession] = useState(null);
657
+ const [repoUrl, setRepoUrl] = useState(getInitialRepoUrl);
658
+ const [repoInput, setRepoInput] = useState(getInitialRepoUrl);
659
+ const [sessionNameInput, setSessionNameInput] = useState("");
660
+ const [repoAuth, setRepoAuth] = useState(null);
661
+ const [authMode, setAuthMode] = useState(readAuthMode);
662
+ const [sshKeyInput, setSshKeyInput] = useState("");
663
+ const [httpUsername, setHttpUsername] = useState("");
664
+ const [httpPassword, setHttpPassword] = useState("");
665
+ const [sessionMode, setSessionMode] = useState("new");
666
+ const [annotationMode, setAnnotationMode] = useState(false);
667
+ const [annotationsByScope, setAnnotationsByScope] = useState({});
668
+ const [defaultInternetAccess, setDefaultInternetAccess] = useState(true);
669
+ const [defaultDenyGitCredentialsAccess, setDefaultDenyGitCredentialsAccess] = useState(false);
670
+ const [toast, setToast] = useState(null);
671
+ const [llmProvider, setLlmProvider] = useState(readLlmProvider);
672
+ const [selectedProviders, setSelectedProviders] = useState(readLlmProviders);
673
+ const [providerSwitching, setProviderSwitching] = useState(false);
674
+ const [openAiAuthMode, setOpenAiAuthMode] = useState(readOpenAiAuthMode);
675
+ const [openAiAuthFile, setOpenAiAuthFile] = useState(null);
676
+ const [openAiApiKey, setOpenAiApiKey] = useState("");
677
+ const [openAiLoginError, setOpenAiLoginError] = useState("");
678
+ const [openAiLoginPending, setOpenAiLoginPending] = useState(false);
679
+ const [openAiLoginRequest, setOpenAiLoginRequest] = useState(null);
680
+ const [openAiReady, setOpenAiReady] = useState(false);
681
+ const [claudeAuthFile, setClaudeAuthFile] = useState(null);
682
+ const [claudeLoginError, setClaudeLoginError] = useState("");
683
+ const [claudeLoginPending, setClaudeLoginPending] = useState(false);
684
+ const [claudeReady, setClaudeReady] = useState(false);
685
+ const [appServerReady, setAppServerReady] = useState(false);
686
+ const [sessionRequested, setSessionRequested] = useState(() =>
687
+ Boolean(getInitialRepoUrl())
688
+ );
689
+ const [showChatCommands, setShowChatCommands] = useState(
690
+ readChatCommandsVisible
691
+ );
692
+ const [showToolResults, setShowToolResults] = useState(
693
+ readToolResultsVisible
694
+ );
695
+ const [notificationsEnabled, setNotificationsEnabled] = useState(
696
+ readNotificationsEnabled
697
+ );
698
+ const [themeMode, setThemeMode] = useState(readThemeMode);
699
+ const toastTimeoutRef = useRef(null);
700
+ const previousAttachmentSessionIdRef = useRef(null);
701
+ const showToast = useCallback((message, type = "success") => {
702
+ setToast({ message, type });
703
+ if (toastTimeoutRef.current) {
704
+ clearTimeout(toastTimeoutRef.current);
705
+ }
706
+ toastTimeoutRef.current = setTimeout(() => {
707
+ setToast(null);
708
+ toastTimeoutRef.current = null;
709
+ }, 3000);
710
+ }, []);
711
+ useEffect(() => {
712
+ return () => {
713
+ if (toastTimeoutRef.current) {
714
+ clearTimeout(toastTimeoutRef.current);
715
+ toastTimeoutRef.current = null;
716
+ }
717
+ };
718
+ }, []);
719
+ const [paneByTab, setPaneByTab] = useState({ main: "chat" });
720
+ const handleSendMessageRef = useRef(null);
721
+ const loadExplorerFileRef = useRef(null);
722
+ const requestExplorerTreeRef = useRef(null);
723
+ const requestExplorerStatusRef = useRef(null);
724
+ const explorerDefaultState = useMemo(
725
+ () => ({
726
+ tree: null,
727
+ loading: false,
728
+ error: "",
729
+ treeTruncated: false,
730
+ treeTotal: 0,
731
+ openTabPaths: [],
732
+ activeFilePath: null,
733
+ filesByPath: {},
734
+ selectedPath: null,
735
+ selectedType: null,
736
+ renamingPath: null,
737
+ renameDraft: "",
738
+ deletingPath: null,
739
+ fileContent: "",
740
+ draftContent: "",
741
+ fileLoading: false,
742
+ fileSaving: false,
743
+ fileError: "",
744
+ fileSaveError: "",
745
+ fileTruncated: false,
746
+ fileBinary: false,
747
+ editMode: false,
748
+ isDirty: false,
749
+ statusByPath: {},
750
+ statusLoading: false,
751
+ statusError: "",
752
+ statusLoaded: false,
753
+ expandedPaths: [],
754
+ }),
755
+ []
756
+ );
757
+ const [explorerByTab, setExplorerByTab] = useState({});
758
+ const [currentTurnId, setCurrentTurnId] = useState(null);
759
+ const [rpcLogs, setRpcLogs] = useState([]);
760
+ const [rpcLogsEnabled, setRpcLogsEnabled] = useState(true);
761
+ const [logFilterByTab, setLogFilterByTab] = useState({ main: "all" });
762
+ const [sideOpen, setSideOpen] = useState(false);
763
+ const [closeConfirm, setCloseConfirm] = useState(null);
764
+ const [terminalEnabled, setTerminalEnabled] = useState(true);
765
+ const explorerRef = useRef({});
766
+ // Worktree states for parallel LLM requests
767
+ const [mainTaskLabel, setMainTaskLabel] = useState("");
768
+ const lastPaneByTabRef = useRef(new Map());
769
+ const getItemActivityLabel = (item) => {
770
+ if (!item?.type) {
771
+ return "";
772
+ }
773
+ if (item.type === "commandExecution") {
774
+ const command =
775
+ item.commandActions?.command || item.command || t("Command");
776
+ return t("Command: {{command}}", { command });
777
+ }
778
+ if (item.type === "fileChange") {
779
+ return t("Applying changes...");
780
+ }
781
+ if (item.type === "mcpToolCall") {
782
+ return t("Tool: {{tool}}", { tool: item.tool });
783
+ }
784
+ if (item.type === "reasoning") {
785
+ return t("Reasoning...");
786
+ }
787
+ if (item.type === "agentMessage") {
788
+ return t("Generating response...");
789
+ }
790
+ return "";
791
+ };
792
+ const {
793
+ commandPanelOpen,
794
+ setCommandPanelOpen,
795
+ toolResultPanelOpen,
796
+ setToolResultPanelOpen,
797
+ } = usePanelState();
798
+ const [repoHistory, setRepoHistory] = useState(() => readRepoHistory());
799
+ const [debugMode, setDebugMode] = useState(() => readDebugMode());
800
+ const socketRef = useRef(null);
801
+ const rpcLogsEnabledRef = useRef(true);
802
+ const listRef = useRef(null);
803
+ const inputRef = useRef(null);
804
+ const uploadInputRef = useRef(null);
805
+ const { toolbarExportOpen, setToolbarExportOpen, toolbarExportRef } =
806
+ useToolbarExport();
807
+ const conversationRef = useRef(null);
808
+ const composerRef = useRef(null);
809
+ const terminalContainerRef = useRef(null);
810
+ const terminalRef = useRef(null);
811
+ const terminalFitRef = useRef(null);
812
+ const terminalDisposableRef = useRef(null);
813
+ const terminalSocketRef = useRef(null);
814
+ const terminalSessionRef = useRef(null);
815
+ const terminalWorktreeRef = useRef(null);
816
+ const reconnectTimerRef = useRef(null);
817
+ const reconnectAttemptRef = useRef(0);
818
+ const closingRef = useRef(false);
819
+ const pingIntervalRef = useRef(null);
820
+ const lastPongRef = useRef(0);
821
+ const messagesRef = useRef([]);
822
+ const {
823
+ apiFetch,
824
+ deploymentMode,
825
+ handleDeleteSession,
826
+ handleLeaveWorkspace,
827
+ handleWorkspaceCopy,
828
+ handleWorkspaceProvidersSubmit,
829
+ handleWorkspaceSubmit,
830
+ loadWorkspaceProviders,
831
+ loadWorkspaceSessions,
832
+ providersBackStep,
833
+ setProvidersBackStep,
834
+ setWorkspaceAuthExpanded,
835
+ setWorkspaceAuthFiles,
836
+ setWorkspaceError,
837
+ setWorkspaceId,
838
+ setWorkspaceIdInput,
839
+ setWorkspaceMode,
840
+ setWorkspaceProviders,
841
+ setWorkspaceProvidersEditing,
842
+ setWorkspaceRefreshToken,
843
+ setWorkspaceSecretInput,
844
+ setWorkspaceStep,
845
+ setWorkspaceToken,
846
+ workspaceAuthExpanded,
847
+ workspaceAuthFiles,
848
+ workspaceBusy,
849
+ workspaceCopied,
850
+ workspaceCreated,
851
+ workspaceError,
852
+ workspaceId,
853
+ workspaceIdInput,
854
+ workspaceMode,
855
+ workspaceProviders,
856
+ workspaceProvidersEditing,
857
+ workspaceSecretInput,
858
+ workspaceSessionDeletingId,
859
+ workspaceSessions,
860
+ workspaceSessionsError,
861
+ workspaceSessionsLoading,
862
+ workspaceStep,
863
+ workspaceToken,
864
+ } = useWorkspaceAuth({
865
+ t,
866
+ encodeBase64,
867
+ copyTextToClipboard,
868
+ extractRepoName,
869
+ setSessionMode,
870
+ showToast,
871
+ getProviderAuthType,
872
+ });
873
+ const {
874
+ handoffOpen,
875
+ handoffQrDataUrl,
876
+ handoffExpiresAt,
877
+ handoffLoading,
878
+ handoffError,
879
+ handoffRemaining,
880
+ requestHandoffQr,
881
+ closeHandoffQr,
882
+ } = useSessionHandoff({
883
+ t,
884
+ apiFetch,
885
+ attachmentSessionId: attachmentSession?.sessionId,
886
+ });
887
+ const {
888
+ gitIdentityName,
889
+ gitIdentityEmail,
890
+ gitIdentityGlobal,
891
+ gitIdentityRepo,
892
+ gitIdentityLoading,
893
+ gitIdentitySaving,
894
+ gitIdentityError,
895
+ gitIdentityMessage,
896
+ setGitIdentityName,
897
+ setGitIdentityEmail,
898
+ handleSaveGitIdentity,
899
+ } = useGitIdentity({
900
+ t,
901
+ apiFetch,
902
+ attachmentSessionId: attachmentSession?.sessionId,
903
+ });
904
+
905
+
906
+ const messageIndex = useMemo(() => new Map(), []);
907
+ const commandIndex = useMemo(() => new Map(), []);
908
+ const repoName = useMemo(
909
+ () => extractRepoName(attachmentSession?.repoUrl),
910
+ [attachmentSession?.repoUrl]
911
+ );
912
+ const brandLogo = themeMode === "dark" ? vibe80LogoDark : vibe80LogoLight;
913
+ const authenticatedProviders = useMemo(() => {
914
+ const list = [];
915
+ if (openAiReady) {
916
+ list.push("codex");
917
+ }
918
+ if (claudeReady) {
919
+ list.push("claude");
920
+ }
921
+ return list;
922
+ }, [openAiReady, claudeReady]);
923
+ const availableProviders = useMemo(
924
+ () => selectedProviders.filter((provider) => authenticatedProviders.includes(provider)),
925
+ [selectedProviders, authenticatedProviders]
926
+ );
927
+
928
+ useEffect(() => {
929
+ messagesRef.current = messages;
930
+ }, [messages]);
931
+
932
+ useEffect(() => {
933
+ explorerRef.current = explorerByTab;
934
+ }, [explorerByTab]);
935
+
936
+ useEffect(() => {
937
+ rpcLogsEnabledRef.current = rpcLogsEnabled;
938
+ }, [rpcLogsEnabled]);
939
+
940
+ const {
941
+ attachmentPreview,
942
+ attachmentsError,
943
+ attachmentsLoading,
944
+ draftAttachments,
945
+ renderMessageAttachments,
946
+ setAttachmentPreview,
947
+ setAttachmentsError,
948
+ setAttachmentsLoading,
949
+ setDraftAttachments,
950
+ } = useAttachments({
951
+ attachmentSessionId: attachmentSession?.sessionId,
952
+ workspaceToken,
953
+ normalizeAttachments,
954
+ isImageAttachment,
955
+ getAttachmentName,
956
+ attachmentIcon: <FontAwesomeIcon icon={faPaperclip} />,
957
+ t,
958
+ });
959
+
960
+ const choicesKey = useMemo(
961
+ () =>
962
+ attachmentSession?.sessionId
963
+ ? `choices:${attachmentSession.sessionId}`
964
+ : null,
965
+ [attachmentSession?.sessionId, apiFetch]
966
+ );
967
+ const {
968
+ choiceSelections,
969
+ setChoiceSelections,
970
+ activeForm,
971
+ activeFormValues,
972
+ openVibe80Form,
973
+ closeVibe80Form,
974
+ updateActiveFormValue,
975
+ submitActiveForm,
976
+ handleChoiceClick,
977
+ } = useVibe80Forms({
978
+ choicesKey,
979
+ input,
980
+ setInput,
981
+ handleSendMessageRef,
982
+ draftAttachments,
983
+ setDraftAttachments,
984
+ });
985
+ useLocalPreferences({
986
+ authMode,
987
+ llmProvider,
988
+ selectedProviders,
989
+ openAiAuthMode,
990
+ showChatCommands,
991
+ showToolResults,
992
+ notificationsEnabled,
993
+ themeMode,
994
+ repoHistory,
995
+ debugMode,
996
+ setLlmProvider,
997
+ setOpenAiLoginError,
998
+ setClaudeLoginError,
999
+ AUTH_MODE_KEY,
1000
+ LLM_PROVIDER_KEY,
1001
+ LLM_PROVIDERS_KEY,
1002
+ OPENAI_AUTH_MODE_KEY,
1003
+ CHAT_COMMANDS_VISIBLE_KEY,
1004
+ TOOL_RESULTS_VISIBLE_KEY,
1005
+ NOTIFICATIONS_ENABLED_KEY,
1006
+ THEME_MODE_KEY,
1007
+ REPO_HISTORY_KEY,
1008
+ DEBUG_MODE_KEY,
1009
+ });
1010
+
1011
+ const groupedMessages = useMemo(() => {
1012
+ const grouped = [];
1013
+ (messages || []).forEach((message) => {
1014
+ if (message?.role === "commandExecution") {
1015
+ const last = grouped[grouped.length - 1];
1016
+ if (last?.groupType === "commandExecution") {
1017
+ last.items.push(message);
1018
+ } else {
1019
+ grouped.push({
1020
+ groupType: "commandExecution",
1021
+ id: `command-group-${message.id}`,
1022
+ items: [message],
1023
+ });
1024
+ }
1025
+ return;
1026
+ }
1027
+ grouped.push(message);
1028
+ });
1029
+ return grouped;
1030
+ }, [messages]);
1031
+ const { isMobileLayout } = useLayoutMode({ themeMode, setSideOpen });
1032
+
1033
+ const { applyMessages, mergeAndApplyMessages } = useChatMessagesState({
1034
+ normalizeAttachments,
1035
+ messageIndex,
1036
+ commandIndex,
1037
+ messagesRef,
1038
+ setMessages,
1039
+ setCommandPanelOpen,
1040
+ setToolResultPanelOpen,
1041
+ });
1042
+
1043
+ const {
1044
+ activeWorktreeId,
1045
+ activeWorktreeIdRef,
1046
+ applyWorktreesList,
1047
+ closeWorktree,
1048
+ createWorktree,
1049
+ handleSelectWorktree,
1050
+ loadMainWorktreeSnapshot,
1051
+ loadWorktreeSnapshot,
1052
+ requestWorktreeMessages,
1053
+ requestWorktreesList,
1054
+ renameWorktreeHandler,
1055
+ setActiveWorktreeId,
1056
+ setWorktrees,
1057
+ worktrees,
1058
+ } = useWorktrees({
1059
+ apiFetch,
1060
+ attachmentSessionId: attachmentSession?.sessionId,
1061
+ availableProviders,
1062
+ llmProvider,
1063
+ messagesRef,
1064
+ normalizeAttachments,
1065
+ applyMessages,
1066
+ socketRef,
1067
+ setPaneByTab,
1068
+ setLogFilterByTab,
1069
+ showToast,
1070
+ t,
1071
+ });
1072
+ const {
1073
+ logFilter,
1074
+ setLogFilter,
1075
+ scopedRpcLogs,
1076
+ formattedRpcLogs,
1077
+ filteredRpcLogs,
1078
+ } =
1079
+ useRpcLogView({
1080
+ rpcLogs,
1081
+ activeWorktreeId,
1082
+ locale,
1083
+ logFilterByTab,
1084
+ setLogFilterByTab,
1085
+ });
1086
+ const { ensureNotificationPermission, maybeNotify, lastNotifiedIdRef } =
1087
+ useNotifications({
1088
+ notificationsEnabled,
1089
+ t,
1090
+ });
1091
+ const loadRepoLastCommitRef = useRef(() => {});
1092
+ const loadRepoLastCommitProxy = useCallback(
1093
+ (...args) => loadRepoLastCommitRef.current?.(...args),
1094
+ []
1095
+ );
1096
+ const {
1097
+ branches,
1098
+ branchError,
1099
+ branchLoading,
1100
+ currentBranch,
1101
+ defaultBranch,
1102
+ loadBranches,
1103
+ loadProviderModels,
1104
+ modelError,
1105
+ modelLoading,
1106
+ models,
1107
+ providerModelState,
1108
+ selectedModel,
1109
+ selectedReasoningEffort,
1110
+ setModelError,
1111
+ setModelLoading,
1112
+ setModels,
1113
+ setProviderModelState,
1114
+ setSelectedModel,
1115
+ setSelectedReasoningEffort,
1116
+ } = useRepoBranchesModels({
1117
+ apiFetch,
1118
+ attachmentSessionId: attachmentSession?.sessionId,
1119
+ llmProvider,
1120
+ loadRepoLastCommit: loadRepoLastCommitProxy,
1121
+ processing,
1122
+ socketRef,
1123
+ t,
1124
+ });
1125
+ const {
1126
+ currentDiff,
1127
+ diffFiles,
1128
+ diffStatusLines,
1129
+ hasCurrentChanges,
1130
+ untrackedFilePanels,
1131
+ untrackedLoading,
1132
+ loadRepoLastCommit,
1133
+ loadWorktreeLastCommit,
1134
+ repoDiff,
1135
+ repoLastCommit,
1136
+ requestRepoDiff,
1137
+ requestWorktreeDiff,
1138
+ setRepoDiff,
1139
+ setRepoLastCommit,
1140
+ setWorktreeLastCommitById,
1141
+ worktreeLastCommitById,
1142
+ } = useRepoStatus({
1143
+ apiFetch,
1144
+ attachmentSessionId: attachmentSession?.sessionId,
1145
+ currentBranch,
1146
+ activeWorktreeId,
1147
+ parseDiff,
1148
+ setWorktrees,
1149
+ worktrees,
1150
+ t,
1151
+ });
1152
+ useEffect(() => {
1153
+ loadRepoLastCommitRef.current = loadRepoLastCommit;
1154
+ }, [loadRepoLastCommit]);
1155
+ const isInWorktree = activeWorktreeId && activeWorktreeId !== "main";
1156
+ const activeWorktree = isInWorktree ? worktrees.get(activeWorktreeId) : null;
1157
+ const activeProvider = isInWorktree ? activeWorktree?.provider : llmProvider;
1158
+ const activeModel = isInWorktree ? activeWorktree?.model : selectedModel;
1159
+ const activeChatKey = activeWorktreeId || "main";
1160
+ const annotationScopeKey = useMemo(() => {
1161
+ const sessionId = attachmentSession?.sessionId;
1162
+ if (!sessionId) {
1163
+ return null;
1164
+ }
1165
+ return `${sessionId}::${activeChatKey}`;
1166
+ }, [attachmentSession?.sessionId, activeChatKey]);
1167
+ const scopedAnnotations = useMemo(() => {
1168
+ if (!annotationScopeKey) {
1169
+ return [];
1170
+ }
1171
+ const scoped = annotationsByScope[annotationScopeKey] || {};
1172
+ return Object.values(scoped).sort((a, b) => {
1173
+ if (a.messageId !== b.messageId) {
1174
+ return String(a.messageId || "").localeCompare(String(b.messageId || ""));
1175
+ }
1176
+ return (a.lineIndex || 0) - (b.lineIndex || 0);
1177
+ });
1178
+ }, [annotationScopeKey, annotationsByScope]);
1179
+ const setAnnotationDraft = useCallback(
1180
+ (annotationKey, annotationText) => {
1181
+ if (!annotationScopeKey || !annotationKey) {
1182
+ return;
1183
+ }
1184
+ setAnnotationsByScope((current) => {
1185
+ const scoped = current[annotationScopeKey];
1186
+ if (!scoped || !scoped[annotationKey]) {
1187
+ return current;
1188
+ }
1189
+ return {
1190
+ ...current,
1191
+ [annotationScopeKey]: {
1192
+ ...scoped,
1193
+ [annotationKey]: {
1194
+ ...scoped[annotationKey],
1195
+ annotationText,
1196
+ },
1197
+ },
1198
+ };
1199
+ });
1200
+ },
1201
+ [annotationScopeKey]
1202
+ );
1203
+ const removeAnnotation = useCallback(
1204
+ (annotationKey) => {
1205
+ if (!annotationScopeKey || !annotationKey) {
1206
+ return;
1207
+ }
1208
+ setAnnotationsByScope((current) => {
1209
+ const scoped = current[annotationScopeKey];
1210
+ if (!scoped || !scoped[annotationKey]) {
1211
+ return current;
1212
+ }
1213
+ const nextScoped = { ...scoped };
1214
+ delete nextScoped[annotationKey];
1215
+ if (Object.keys(nextScoped).length === 0) {
1216
+ const next = { ...current };
1217
+ delete next[annotationScopeKey];
1218
+ return next;
1219
+ }
1220
+ return {
1221
+ ...current,
1222
+ [annotationScopeKey]: nextScoped,
1223
+ };
1224
+ });
1225
+ },
1226
+ [annotationScopeKey]
1227
+ );
1228
+ const addOrFocusAnnotation = useCallback(
1229
+ ({ messageId, lineIndex, lineText }) => {
1230
+ if (!annotationScopeKey || !messageId) {
1231
+ return;
1232
+ }
1233
+ const annotationKey = `${messageId}:${lineIndex}`;
1234
+ setAnnotationsByScope((current) => {
1235
+ const scoped = current[annotationScopeKey] || {};
1236
+ const existing = scoped[annotationKey];
1237
+ return {
1238
+ ...current,
1239
+ [annotationScopeKey]: {
1240
+ ...scoped,
1241
+ [annotationKey]: {
1242
+ annotationKey,
1243
+ messageId,
1244
+ lineIndex,
1245
+ lineText: lineText || "",
1246
+ annotationText: existing?.annotationText || "",
1247
+ },
1248
+ },
1249
+ };
1250
+ });
1251
+ },
1252
+ [annotationScopeKey]
1253
+ );
1254
+ const clearScopedAnnotations = useCallback(() => {
1255
+ if (!annotationScopeKey) {
1256
+ return;
1257
+ }
1258
+ setAnnotationsByScope((current) => {
1259
+ if (!current[annotationScopeKey]) {
1260
+ return current;
1261
+ }
1262
+ const next = { ...current };
1263
+ delete next[annotationScopeKey];
1264
+ return next;
1265
+ });
1266
+ }, [annotationScopeKey]);
1267
+ const buildAnnotatedMessage = useCallback(
1268
+ (composerText) => {
1269
+ const text = String(composerText || "").trim();
1270
+ if (!text) {
1271
+ return "";
1272
+ }
1273
+ const validAnnotations = scopedAnnotations
1274
+ .filter((entry) => entry?.annotationText?.trim())
1275
+ .map((entry) => ({
1276
+ lineText: entry.lineText || "",
1277
+ annotationText: entry.annotationText.trim(),
1278
+ }));
1279
+ if (validAnnotations.length === 0) {
1280
+ return text;
1281
+ }
1282
+ const annotationPrefix = validAnnotations
1283
+ .map((entry) => `> ${entry.lineText}\n${entry.annotationText}`)
1284
+ .join("\n\n");
1285
+ return `${annotationPrefix}\n\n${text}`;
1286
+ },
1287
+ [scopedAnnotations]
1288
+ );
1289
+ const activeModelOptions = isInWorktree
1290
+ ? Array.isArray(activeWorktree?.models)
1291
+ ? activeWorktree.models
1292
+ : []
1293
+ : Array.isArray(models)
1294
+ ? models
1295
+ : [];
1296
+ const activeModelLoading = isInWorktree
1297
+ ? Boolean(activeWorktree?.modelLoading)
1298
+ : Boolean(modelLoading);
1299
+ const activeModelError = isInWorktree
1300
+ ? activeWorktree?.modelError || ""
1301
+ : modelError;
1302
+ const currentMessages =
1303
+ isInWorktree && !activeWorktree
1304
+ ? []
1305
+ : activeWorktree
1306
+ ? activeWorktree.messages
1307
+ : messages;
1308
+ const hasMessages =
1309
+ Array.isArray(currentMessages) && currentMessages.length > 0;
1310
+ const activePane = paneByTab[activeWorktreeId] || "chat";
1311
+ const activeExplorer = explorerByTab[activeWorktreeId] || explorerDefaultState;
1312
+ const { handleResumeSession, onRepoSubmit } = useSessionLifecycle({
1313
+ t,
1314
+ apiFetch,
1315
+ workspaceToken,
1316
+ handleLeaveWorkspace,
1317
+ repoUrl,
1318
+ setRepoUrl,
1319
+ repoInput,
1320
+ sessionNameInput,
1321
+ repoAuth,
1322
+ setRepoAuth,
1323
+ authMode,
1324
+ sshKeyInput,
1325
+ httpUsername,
1326
+ httpPassword,
1327
+ sessionMode,
1328
+ sessionRequested,
1329
+ setSessionRequested,
1330
+ defaultInternetAccess,
1331
+ defaultDenyGitCredentialsAccess,
1332
+ attachmentSession,
1333
+ setAttachmentSession,
1334
+ setAttachmentsLoading,
1335
+ setAttachmentsError,
1336
+ setWorkspaceToken,
1337
+ setWorkspaceMode,
1338
+ setWorkspaceError,
1339
+ setOpenAiLoginPending,
1340
+ setOpenAiLoginRequest,
1341
+ });
1342
+ const explorerStatusByPath = activeExplorer.statusByPath || {};
1343
+ const explorerDirStatus = useMemo(() => {
1344
+ const dirStatus = {};
1345
+ const setStatus = (dirPath, type) => {
1346
+ if (!dirPath) {
1347
+ return;
1348
+ }
1349
+ const existing = dirStatus[dirPath];
1350
+ if (existing === "untracked") {
1351
+ return;
1352
+ }
1353
+ if (type === "untracked") {
1354
+ dirStatus[dirPath] = "untracked";
1355
+ return;
1356
+ }
1357
+ if (!existing) {
1358
+ dirStatus[dirPath] = type;
1359
+ }
1360
+ };
1361
+ Object.entries(explorerStatusByPath).forEach(([path, type]) => {
1362
+ if (!path) {
1363
+ return;
1364
+ }
1365
+ const parts = path.split("/").filter(Boolean);
1366
+ if (parts.length <= 1) {
1367
+ return;
1368
+ }
1369
+ for (let i = 0; i < parts.length - 1; i += 1) {
1370
+ const dirPath = parts.slice(0, i + 1).join("/");
1371
+ setStatus(dirPath, type);
1372
+ }
1373
+ });
1374
+ return dirStatus;
1375
+ }, [explorerStatusByPath]);
1376
+
1377
+ const { resyncSession } = useSessionResync({
1378
+ attachmentSessionId: attachmentSession?.sessionId,
1379
+ apiFetch,
1380
+ llmProvider,
1381
+ setLlmProvider,
1382
+ setSelectedProviders,
1383
+ setOpenAiReady,
1384
+ setClaudeReady,
1385
+ setRepoDiff,
1386
+ setRpcLogsEnabled,
1387
+ setRpcLogs,
1388
+ setTerminalEnabled,
1389
+ loadMainWorktreeSnapshot,
1390
+ });
1391
+
1392
+ const { requestMessageSync } = useMessageSync({
1393
+ socketRef,
1394
+ messagesRef,
1395
+ });
1396
+
1397
+ useChatSocket({
1398
+ attachmentSessionId: attachmentSession?.sessionId,
1399
+ workspaceToken,
1400
+ socketRef,
1401
+ reconnectTimerRef,
1402
+ reconnectAttemptRef,
1403
+ closingRef,
1404
+ pingIntervalRef,
1405
+ lastPongRef,
1406
+ messageIndex,
1407
+ commandIndex,
1408
+ rpcLogsEnabledRef,
1409
+ mergeAndApplyMessages,
1410
+ requestMessageSync,
1411
+ requestWorktreesList,
1412
+ requestWorktreeMessages,
1413
+ applyWorktreesList,
1414
+ resyncSession,
1415
+ t,
1416
+ setStatus,
1417
+ setConnected,
1418
+ setAppServerReady,
1419
+ setHasMainWorktreeStatus,
1420
+ setMessages,
1421
+ setProcessing,
1422
+ setActivity,
1423
+ setCurrentTurnId,
1424
+ setMainTaskLabel,
1425
+ setModelLoading,
1426
+ setModelError,
1427
+ setModels,
1428
+ setProviderModelState,
1429
+ setSelectedModel,
1430
+ setSelectedReasoningEffort,
1431
+ setRepoDiff,
1432
+ setRpcLogs,
1433
+ setWorktrees,
1434
+ setPaneByTab,
1435
+ setLogFilterByTab,
1436
+ setActiveWorktreeId,
1437
+ activeWorktreeIdRef,
1438
+ extractVibe80Task,
1439
+ extractFirstLine,
1440
+ getItemActivityLabel,
1441
+ maybeNotify,
1442
+ normalizeAttachments,
1443
+ loadRepoLastCommit,
1444
+ loadBranches,
1445
+ loadWorktreeLastCommit,
1446
+ openAiLoginRequest,
1447
+ setOpenAiLoginRequest,
1448
+ connected,
1449
+ });
1450
+
1451
+ const requestScopedModelList = useCallback(
1452
+ (worktreeId = "main") => {
1453
+ const socket = socketRef.current;
1454
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
1455
+ return;
1456
+ }
1457
+ if (worktreeId === "main") {
1458
+ setModelLoading(true);
1459
+ setModelError("");
1460
+ } else {
1461
+ setWorktrees((current) => {
1462
+ const next = new Map(current);
1463
+ const wt = next.get(worktreeId);
1464
+ if (!wt) {
1465
+ return current;
1466
+ }
1467
+ next.set(worktreeId, {
1468
+ ...wt,
1469
+ modelLoading: true,
1470
+ modelError: "",
1471
+ });
1472
+ return next;
1473
+ });
1474
+ }
1475
+ socket.send(JSON.stringify({ type: "model_list", worktreeId }));
1476
+ },
1477
+ [setModelError, setModelLoading, setWorktrees, socketRef]
1478
+ );
1479
+
1480
+ const handleComposerModelChange = useCallback(
1481
+ (nextModelValue) => {
1482
+ const socket = socketRef.current;
1483
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
1484
+ return;
1485
+ }
1486
+ const nextModel = nextModelValue || null;
1487
+ if (activeChatKey === "main") {
1488
+ setSelectedModel(nextModel || "");
1489
+ setModelLoading(true);
1490
+ setModelError("");
1491
+ } else {
1492
+ setWorktrees((current) => {
1493
+ const next = new Map(current);
1494
+ const wt = next.get(activeChatKey);
1495
+ if (!wt) {
1496
+ return current;
1497
+ }
1498
+ next.set(activeChatKey, {
1499
+ ...wt,
1500
+ model: nextModel,
1501
+ modelLoading: true,
1502
+ modelError: "",
1503
+ });
1504
+ return next;
1505
+ });
1506
+ }
1507
+ socket.send(
1508
+ JSON.stringify({
1509
+ type: "model_set",
1510
+ worktreeId: activeChatKey,
1511
+ model: nextModel,
1512
+ reasoningEffort:
1513
+ activeChatKey === "main"
1514
+ ? selectedReasoningEffort || null
1515
+ : activeWorktree?.reasoningEffort ?? null,
1516
+ })
1517
+ );
1518
+ },
1519
+ [
1520
+ activeChatKey,
1521
+ activeWorktree?.reasoningEffort,
1522
+ selectedReasoningEffort,
1523
+ setModelError,
1524
+ setModelLoading,
1525
+ setSelectedModel,
1526
+ setWorktrees,
1527
+ socketRef,
1528
+ ]
1529
+ );
1530
+
1531
+ useTerminalSession({
1532
+ activePane,
1533
+ activeWorktreeId,
1534
+ attachmentSessionId: attachmentSession?.sessionId,
1535
+ terminalEnabled,
1536
+ terminalContainerRef,
1537
+ terminalDisposableRef,
1538
+ terminalFitRef,
1539
+ terminalRef,
1540
+ terminalSessionRef,
1541
+ terminalSocketRef,
1542
+ terminalWorktreeRef,
1543
+ themeMode,
1544
+ workspaceToken,
1545
+ });
1546
+
1547
+
1548
+ useEffect(() => {
1549
+ if (typeof attachmentSession?.terminalEnabled === "boolean") {
1550
+ setTerminalEnabled(attachmentSession.terminalEnabled);
1551
+ }
1552
+ }, [attachmentSession?.terminalEnabled]);
1553
+
1554
+ useEffect(() => {
1555
+ setAppServerReady(false);
1556
+ }, [attachmentSession?.sessionId]);
1557
+
1558
+ useEffect(() => {
1559
+ if (!connected || !attachmentSession?.sessionId) {
1560
+ return;
1561
+ }
1562
+ if (activeProvider !== "codex" && activeProvider !== "claude") {
1563
+ return;
1564
+ }
1565
+ if (isInWorktree && activeWorktree?.status !== "ready") {
1566
+ return;
1567
+ }
1568
+ if (!isInWorktree && activeProvider === "codex" && !appServerReady) {
1569
+ return;
1570
+ }
1571
+ requestScopedModelList(activeChatKey);
1572
+ }, [
1573
+ activeChatKey,
1574
+ activeWorktree?.status,
1575
+ activeProvider,
1576
+ appServerReady,
1577
+ attachmentSession?.sessionId,
1578
+ connected,
1579
+ isInWorktree,
1580
+ requestScopedModelList,
1581
+ ]);
1582
+
1583
+
1584
+ useEffect(() => {
1585
+ if (!attachmentSession?.sessionId) {
1586
+ return;
1587
+ }
1588
+ void loadMainWorktreeSnapshot();
1589
+ setRepoDiff(attachmentSession.repoDiff || { status: "", diff: "" });
1590
+ const logsEnabled =
1591
+ typeof attachmentSession.rpcLogsEnabled === "boolean"
1592
+ ? attachmentSession.rpcLogsEnabled
1593
+ : true;
1594
+ setRpcLogsEnabled(logsEnabled);
1595
+ setRpcLogs(logsEnabled ? attachmentSession.rpcLogs || [] : []);
1596
+ setStatus(t("Connecting..."));
1597
+ setConnected(false);
1598
+ setHasMainWorktreeStatus(false);
1599
+ }, [attachmentSession?.sessionId, loadMainWorktreeSnapshot, messageIndex, t]);
1600
+
1601
+ useEffect(() => {
1602
+ if (attachmentSession?.sessionId) {
1603
+ const label = attachmentSession?.name || repoName || t("Session");
1604
+ document.title = `vibe80 - ${label}`;
1605
+ } else {
1606
+ document.title = "vibe80";
1607
+ }
1608
+ }, [attachmentSession?.sessionId, attachmentSession?.name, repoName, t]);
1609
+
1610
+ useEffect(() => {
1611
+ if (typeof attachmentSession?.defaultInternetAccess === "boolean") {
1612
+ setDefaultInternetAccess(attachmentSession.defaultInternetAccess);
1613
+ }
1614
+ }, [attachmentSession?.defaultInternetAccess]);
1615
+
1616
+ useEffect(() => {
1617
+ if (typeof attachmentSession?.defaultDenyGitCredentialsAccess === "boolean") {
1618
+ setDefaultDenyGitCredentialsAccess(
1619
+ attachmentSession.defaultDenyGitCredentialsAccess
1620
+ );
1621
+ }
1622
+ }, [attachmentSession?.defaultDenyGitCredentialsAccess]);
1623
+
1624
+ useEffect(() => {
1625
+ if (!attachmentSession?.defaultProvider && !attachmentSession?.providers) {
1626
+ return;
1627
+ }
1628
+ const sessionProviders = Array.isArray(attachmentSession.providers)
1629
+ ? attachmentSession.providers.filter(
1630
+ (entry) => entry === "codex" || entry === "claude"
1631
+ )
1632
+ : [];
1633
+ if (sessionProviders.length) {
1634
+ setSelectedProviders(sessionProviders);
1635
+ setOpenAiReady(sessionProviders.includes("codex"));
1636
+ setClaudeReady(sessionProviders.includes("claude"));
1637
+ } else if (attachmentSession.defaultProvider) {
1638
+ setSelectedProviders([attachmentSession.defaultProvider]);
1639
+ setOpenAiReady(attachmentSession.defaultProvider === "codex");
1640
+ setClaudeReady(attachmentSession.defaultProvider === "claude");
1641
+ }
1642
+ // Sync local state with session provider on initial load
1643
+ if (
1644
+ attachmentSession.defaultProvider &&
1645
+ attachmentSession.defaultProvider !== llmProvider
1646
+ ) {
1647
+ setLlmProvider(attachmentSession.defaultProvider);
1648
+ }
1649
+ }, [attachmentSession?.defaultProvider, attachmentSession?.providers]);
1650
+
1651
+ useEffect(() => {
1652
+ if (!attachmentSession?.repoUrl) {
1653
+ return;
1654
+ }
1655
+ setRepoHistory((current) =>
1656
+ mergeRepoHistory(current, attachmentSession.repoUrl)
1657
+ );
1658
+ }, [attachmentSession?.repoUrl]);
1659
+
1660
+ const { handleProviderSwitch, toggleProviderSelection } =
1661
+ useProviderSelection({
1662
+ attachmentSessionId: attachmentSession?.sessionId,
1663
+ socketRef,
1664
+ availableProviders,
1665
+ llmProvider,
1666
+ providerSwitching,
1667
+ processing,
1668
+ setProviderSwitching,
1669
+ setStatus,
1670
+ setSelectedProviders,
1671
+ setLlmProvider,
1672
+ t,
1673
+ });
1674
+
1675
+ const {
1676
+ commandMenuOpen,
1677
+ setCommandMenuOpen,
1678
+ setCommandQuery,
1679
+ commandSelection,
1680
+ filteredCommands,
1681
+ isDraggingAttachments,
1682
+ handleInputChange,
1683
+ handleComposerKeyDown,
1684
+ onSubmit,
1685
+ onUploadAttachments,
1686
+ onPasteAttachments,
1687
+ onDragOverComposer,
1688
+ onDragEnterComposer,
1689
+ onDragLeaveComposer,
1690
+ onDropAttachments,
1691
+ removeDraftAttachment,
1692
+ triggerAttachmentPicker,
1693
+ captureScreenshot,
1694
+ } = useChatComposer({
1695
+ t,
1696
+ input,
1697
+ setInput,
1698
+ inputRef,
1699
+ handleSendMessageRef,
1700
+ attachmentSession,
1701
+ apiFetch,
1702
+ normalizeAttachments,
1703
+ setDraftAttachments,
1704
+ draftAttachments,
1705
+ setAttachmentsLoading,
1706
+ setAttachmentsError,
1707
+ showToast,
1708
+ uploadInputRef,
1709
+ attachmentsLoading,
1710
+ conversationRef,
1711
+ composerRef,
1712
+ isMobileLayout,
1713
+ });
1714
+
1715
+ const { sendMessage, sendCommitMessage, sendWorktreeMessage } = useChatSend({
1716
+ input,
1717
+ setInput,
1718
+ setMessages,
1719
+ setDraftAttachments,
1720
+ socketRef,
1721
+ connected,
1722
+ normalizeAttachments,
1723
+ draftAttachments,
1724
+ setWorktrees,
1725
+ setProcessing,
1726
+ setActivity,
1727
+ processingLabel: t("Processing..."),
1728
+ handleSendMessageRef,
1729
+ ensureNotificationPermission,
1730
+ });
1731
+
1732
+ const {
1733
+ openCloseConfirm,
1734
+ closeCloseConfirm,
1735
+ handleConfirmDelete,
1736
+ } = useWorktreeCloseConfirm({
1737
+ closeConfirm,
1738
+ setCloseConfirm,
1739
+ setActiveWorktreeId,
1740
+ activeWorktreeIdRef,
1741
+ closeWorktree,
1742
+ });
1743
+
1744
+
1745
+ useEffect(() => {
1746
+ if (listRef.current) {
1747
+ listRef.current.scrollTop = listRef.current.scrollHeight;
1748
+ }
1749
+ }, [currentMessages, processing, activeWorktreeId]);
1750
+
1751
+ // Combined list for tabs: "main" + all worktrees
1752
+ const allTabs = useMemo(() => {
1753
+ const mainTab = {
1754
+ id: "main",
1755
+ name: currentBranch || "Main",
1756
+ branchName: currentBranch || "main",
1757
+ provider: llmProvider,
1758
+ status: processing
1759
+ ? "processing"
1760
+ : connected
1761
+ ? (hasMainWorktreeStatus ? "ready" : "processing")
1762
+ : "creating",
1763
+ color: "#6b7280",
1764
+ messages: messages,
1765
+ };
1766
+ const wtList = Array.from(worktrees.values());
1767
+ return [mainTab, ...wtList];
1768
+ }, [
1769
+ currentBranch,
1770
+ llmProvider,
1771
+ processing,
1772
+ connected,
1773
+ hasMainWorktreeStatus,
1774
+ messages,
1775
+ worktrees,
1776
+ ]);
1777
+
1778
+ // Group messages for display (works with both legacy and worktree modes)
1779
+ const displayedGroupedMessages = useMemo(() => {
1780
+ const grouped = [];
1781
+ (currentMessages || []).forEach((message) => {
1782
+ if (message?.role === "commandExecution") {
1783
+ if (!showChatCommands) {
1784
+ return;
1785
+ }
1786
+ const last = grouped[grouped.length - 1];
1787
+ if (last?.groupType === "commandExecution") {
1788
+ last.items.push(message);
1789
+ } else {
1790
+ grouped.push({
1791
+ groupType: "commandExecution",
1792
+ id: `command-group-${message.id}`,
1793
+ items: [message],
1794
+ });
1795
+ }
1796
+ return;
1797
+ }
1798
+ if (message?.role === "tool_result") {
1799
+ if (!showToolResults) {
1800
+ return;
1801
+ }
1802
+ const last = grouped[grouped.length - 1];
1803
+ if (last?.groupType === "toolResult") {
1804
+ last.items.push(message);
1805
+ } else {
1806
+ grouped.push({
1807
+ groupType: "toolResult",
1808
+ id: `tool-result-group-${message.id}`,
1809
+ items: [message],
1810
+ });
1811
+ }
1812
+ return;
1813
+ }
1814
+ if (message?.type === "action_result") {
1815
+ const last = grouped[grouped.length - 1];
1816
+ if (last?.groupType === "toolResult") {
1817
+ last.items.push(message);
1818
+ } else {
1819
+ grouped.push({
1820
+ groupType: "toolResult",
1821
+ id: `tool-result-group-${message.id}`,
1822
+ items: [message],
1823
+ });
1824
+ }
1825
+ return;
1826
+ }
1827
+ grouped.push(message);
1828
+ });
1829
+ return grouped;
1830
+ }, [currentMessages, showChatCommands, showToolResults]);
1831
+ const {
1832
+ showOlderMessagesByTab,
1833
+ setShowOlderMessagesByTab,
1834
+ showOlderMessages,
1835
+ collapsedMessages: chatHistoryWindow,
1836
+ } = useChatCollapse({
1837
+ activeChatKey,
1838
+ displayedGroupedMessages,
1839
+ CHAT_COLLAPSE_THRESHOLD,
1840
+ CHAT_COLLAPSE_VISIBLE,
1841
+ });
1842
+
1843
+ useEffect(() => {
1844
+ const nextSessionId = attachmentSession?.sessionId || null;
1845
+ const previousSessionId = previousAttachmentSessionIdRef.current;
1846
+ if (previousSessionId === nextSessionId) {
1847
+ return;
1848
+ }
1849
+ previousAttachmentSessionIdRef.current = nextSessionId;
1850
+
1851
+ // Full reset of session-scoped UI/worktree state to avoid cross-session clashes.
1852
+ setMessages([]);
1853
+ messageIndex.clear();
1854
+ commandIndex.clear();
1855
+ setWorktrees(new Map());
1856
+ setActiveWorktreeId("main");
1857
+ setPaneByTab({ main: "chat" });
1858
+ setLogFilterByTab({ main: "all" });
1859
+ setExplorerByTab({});
1860
+ setShowOlderMessagesByTab({});
1861
+ setCommandPanelOpen({});
1862
+ setToolResultPanelOpen({});
1863
+ setRepoLastCommit(null);
1864
+ setWorktreeLastCommitById(new Map());
1865
+ setCurrentTurnId(null);
1866
+ setMainTaskLabel("");
1867
+ setActivity("");
1868
+ setCloseConfirm(null);
1869
+ setAnnotationsByScope({});
1870
+ }, [
1871
+ attachmentSession?.sessionId,
1872
+ commandIndex,
1873
+ messageIndex,
1874
+ setActiveWorktreeId,
1875
+ setActivity,
1876
+ setCloseConfirm,
1877
+ setCommandPanelOpen,
1878
+ setCurrentTurnId,
1879
+ setExplorerByTab,
1880
+ setLogFilterByTab,
1881
+ setMainTaskLabel,
1882
+ setMessages,
1883
+ setAnnotationsByScope,
1884
+ setPaneByTab,
1885
+ setRepoLastCommit,
1886
+ setShowOlderMessagesByTab,
1887
+ setToolResultPanelOpen,
1888
+ setWorktreeLastCommitById,
1889
+ setWorktrees,
1890
+ ]);
1891
+
1892
+ // Check if we're in a real worktree (not "main")
1893
+ const activeCommit = isInWorktree
1894
+ ? worktreeLastCommitById.get(activeWorktreeId)
1895
+ : repoLastCommit;
1896
+ const activeBranchLabel = isInWorktree
1897
+ ? activeWorktree?.branchName || activeWorktree?.name || ""
1898
+ : currentBranch || repoLastCommit?.branch || "";
1899
+ const shortSha =
1900
+ typeof activeCommit?.sha === "string" ? activeCommit.sha.slice(0, 7) : "";
1901
+ const showInternetAccess = isInWorktree
1902
+ ? Boolean(activeWorktree?.internetAccess)
1903
+ : Boolean(defaultInternetAccess);
1904
+ const showGitCredentialsShared = isInWorktree
1905
+ ? activeWorktree?.denyGitCredentialsAccess === false
1906
+ : defaultDenyGitCredentialsAccess === false;
1907
+ const activeProviderLabel = formatProviderLabel(activeProvider, t);
1908
+ const activeModelLabel = activeModel || t("Default model");
1909
+ const showProviderMeta = Boolean(activeProviderLabel && activeModelLabel);
1910
+ const repoTitle = repoName || t("Repository");
1911
+ const showChatInfoPanel =
1912
+ !isMobileLayout &&
1913
+ activePane === "chat" &&
1914
+ Boolean(activeBranchLabel && shortSha && activeCommit?.message);
1915
+
1916
+ const isWorktreeProcessing = activeWorktree?.status === "processing";
1917
+ const isWorktreeStopped = activeWorktree?.status === "stopped";
1918
+ const currentProcessing = isInWorktree ? isWorktreeProcessing : processing;
1919
+ const currentInteractionBlocked =
1920
+ currentProcessing || (isInWorktree && isWorktreeStopped);
1921
+ const currentActivity = isInWorktree ? activeWorktree?.activity || "" : activity;
1922
+ const activeTaskLabel = currentProcessing
1923
+ ? isInWorktree
1924
+ ? activeWorktree?.taskLabel
1925
+ : mainTaskLabel
1926
+ : "";
1927
+ const currentTurnIdForActive = isInWorktree
1928
+ ? activeWorktree?.currentTurnId
1929
+ : currentTurnId;
1930
+ const canInterrupt = currentProcessing && Boolean(currentTurnIdForActive);
1931
+ const isCodexReady =
1932
+ activeProvider !== "codex"
1933
+ ? true
1934
+ : isInWorktree
1935
+ ? activeWorktree?.status === "ready"
1936
+ : appServerReady;
1937
+ const composerModelVisible =
1938
+ activeProvider === "codex" || activeProvider === "claude";
1939
+ const composerModelDisabled =
1940
+ !connected ||
1941
+ currentInteractionBlocked ||
1942
+ activeModelLoading ||
1943
+ !activeModelOptions.length;
1944
+ const composerSelectedModel = activeModel || "";
1945
+
1946
+ const { handleViewSelect, handleOpenSettings, handleSettingsBack } =
1947
+ usePaneNavigation({
1948
+ activePane,
1949
+ activeWorktreeId,
1950
+ debugMode,
1951
+ rpcLogsEnabled,
1952
+ terminalEnabled,
1953
+ setPaneByTab,
1954
+ setToolbarExportOpen,
1955
+ lastPaneByTabRef,
1956
+ });
1957
+
1958
+ const {
1959
+ addToBacklog,
1960
+ backlog,
1961
+ editBacklogItem,
1962
+ launchBacklogItem,
1963
+ markBacklogItemDone,
1964
+ removeFromBacklog,
1965
+ setBacklog,
1966
+ setBacklogMessagePage,
1967
+ updateBacklogMessages,
1968
+ } = useBacklog({
1969
+ attachmentSessionId: attachmentSession?.sessionId,
1970
+ apiFetch,
1971
+ normalizeAttachments,
1972
+ sendMessage,
1973
+ setInput,
1974
+ setMessages,
1975
+ setWorktrees,
1976
+ setDraftAttachments,
1977
+ input,
1978
+ draftAttachments,
1979
+ inputRef,
1980
+ showToast,
1981
+ t,
1982
+ });
1983
+
1984
+ const { interruptTurn } = useTurnInterrupt({
1985
+ activeWorktreeId,
1986
+ isInWorktree,
1987
+ currentTurnIdForActive,
1988
+ socketRef,
1989
+ setWorktrees,
1990
+ setActivity,
1991
+ });
1992
+
1993
+ const { handleLeaveSession } = useSessionReset({
1994
+ setAttachmentSession,
1995
+ setRepoUrl,
1996
+ setRepoInput,
1997
+ setRepoAuth,
1998
+ setSessionRequested,
1999
+ setAttachmentsError,
2000
+ setAttachmentsLoading,
2001
+ setMessages,
2002
+ setRepoDiff,
2003
+ setRpcLogs,
2004
+ setRpcLogsEnabled,
2005
+ setRepoLastCommit,
2006
+ setWorktreeLastCommitById,
2007
+ setCurrentTurnId,
2008
+ setActivity,
2009
+ setDefaultDenyGitCredentialsAccess,
2010
+ });
2011
+
2012
+ useEffect(() => {
2013
+ if (!workspaceToken && attachmentSession?.sessionId) {
2014
+ handleLeaveSession();
2015
+ }
2016
+ }, [workspaceToken, attachmentSession?.sessionId, handleLeaveSession]);
2017
+
2018
+ const { handleDiffSelect } = useDiffNavigation({
2019
+ activeWorktreeId,
2020
+ handleViewSelect,
2021
+ requestWorktreeDiff,
2022
+ requestRepoDiff,
2023
+ });
2024
+
2025
+
2026
+ const {
2027
+ updateExplorerState,
2028
+ openPathInExplorer,
2029
+ requestExplorerTree,
2030
+ requestExplorerStatus,
2031
+ loadExplorerFile,
2032
+ openFileInExplorer,
2033
+ setActiveExplorerFile,
2034
+ closeExplorerFile,
2035
+ selectExplorerNode,
2036
+ toggleExplorerDir,
2037
+ toggleExplorerEditMode,
2038
+ updateExplorerDraft,
2039
+ saveExplorerFile,
2040
+ startExplorerRename,
2041
+ cancelExplorerRename,
2042
+ updateExplorerRenameDraft,
2043
+ submitExplorerRename,
2044
+ createExplorerFile,
2045
+ createExplorerFolder,
2046
+ deleteExplorerSelection,
2047
+ } = useExplorerActions({
2048
+ attachmentSessionId: attachmentSession?.sessionId,
2049
+ apiFetch,
2050
+ t,
2051
+ setExplorerByTab,
2052
+ explorerDefaultState,
2053
+ explorerRef,
2054
+ activeWorktreeId,
2055
+ handleViewSelect,
2056
+ showToast,
2057
+ requestExplorerTreeRef,
2058
+ requestExplorerStatusRef,
2059
+ loadExplorerFileRef,
2060
+ });
2061
+ const { handleSendMessage } = useChatCommands({
2062
+ activeProvider,
2063
+ activeWorktreeId,
2064
+ addToBacklog,
2065
+ apiFetch,
2066
+ attachmentSessionId: attachmentSession?.sessionId,
2067
+ captureScreenshot,
2068
+ connected,
2069
+ handleSendMessageRef,
2070
+ handleViewSelect,
2071
+ input,
2072
+ isWorktreeStopped,
2073
+ isCodexReady,
2074
+ isInWorktree,
2075
+ openPathInExplorer,
2076
+ requestRepoDiff,
2077
+ requestWorktreeDiff,
2078
+ sendMessage,
2079
+ sendWorktreeMessage,
2080
+ buildAnnotatedMessage,
2081
+ clearScopedAnnotations,
2082
+ setAnnotationMode,
2083
+ setCommandMenuOpen,
2084
+ setDraftAttachments,
2085
+ setInput,
2086
+ setMessages,
2087
+ setWorktrees,
2088
+ socketRef,
2089
+ showToast,
2090
+ t,
2091
+ });
2092
+
2093
+ // ============== End Worktree Functions ==============
2094
+
2095
+ const { handleClearRpcLogs } = useRpcLogActions({
2096
+ activeWorktreeId,
2097
+ setRpcLogs,
2098
+ });
2099
+
2100
+ useEffect(() => {
2101
+ if (activePane !== "diff") {
2102
+ return;
2103
+ }
2104
+ if (activeWorktreeId && activeWorktreeId !== "main") {
2105
+ requestWorktreeDiff(activeWorktreeId);
2106
+ } else {
2107
+ requestRepoDiff();
2108
+ }
2109
+ }, [activePane, activeWorktreeId, requestRepoDiff, requestWorktreeDiff]);
2110
+
2111
+ useEffect(() => {
2112
+ if (activePane !== "explorer") {
2113
+ return;
2114
+ }
2115
+ const tabId = activeWorktreeId || "main";
2116
+ requestExplorerTree(tabId, true);
2117
+ requestExplorerStatus(tabId, true);
2118
+ }, [
2119
+ activePane,
2120
+ activeWorktreeId,
2121
+ requestExplorerTree,
2122
+ requestExplorerStatus,
2123
+ ]);
2124
+
2125
+ useEffect(() => {
2126
+ if (!attachmentSession?.sessionId || isMobileLayout || activePane !== "chat") {
2127
+ return;
2128
+ }
2129
+ if (isInWorktree && activeWorktreeId) {
2130
+ if (!worktreeLastCommitById.has(activeWorktreeId)) {
2131
+ void loadWorktreeLastCommit(activeWorktreeId);
2132
+ }
2133
+ return;
2134
+ }
2135
+ void loadRepoLastCommit();
2136
+ }, [
2137
+ attachmentSession?.sessionId,
2138
+ isMobileLayout,
2139
+ activePane,
2140
+ isInWorktree,
2141
+ activeWorktreeId,
2142
+ worktreeLastCommitById,
2143
+ loadWorktreeLastCommit,
2144
+ loadRepoLastCommit,
2145
+ ]);
2146
+
2147
+ const { handleExportChat } = useChatExport({
2148
+ currentMessages,
2149
+ attachmentRepoUrl: attachmentSession?.repoUrl,
2150
+ repoUrl,
2151
+ isInWorktree,
2152
+ activeWorktree,
2153
+ t,
2154
+ normalizeAttachments,
2155
+ downloadTextFile,
2156
+ formatExportName,
2157
+ extractRepoName,
2158
+ setToolbarExportOpen,
2159
+ });
2160
+
2161
+ const { handleClearChat } = useChatClear({
2162
+ activeWorktreeId,
2163
+ setToolbarExportOpen,
2164
+ setWorktrees,
2165
+ lastNotifiedIdRef,
2166
+ attachmentSessionId: attachmentSession?.sessionId,
2167
+ apiFetch,
2168
+ setMessages,
2169
+ messageIndex,
2170
+ commandIndex,
2171
+ setChoiceSelections,
2172
+ choicesKey,
2173
+ setCommandPanelOpen,
2174
+ setToolResultPanelOpen,
2175
+ llmProvider,
2176
+ });
2177
+
2178
+ const supportsModels = llmProvider === "codex";
2179
+ const hasSession = Boolean(attachmentSession?.sessionId);
2180
+ const canSwitchProvider = availableProviders.length > 1;
2181
+ const nextProvider = canSwitchProvider
2182
+ ? availableProviders.find((provider) => provider !== llmProvider) || llmProvider
2183
+ : llmProvider;
2184
+
2185
+ if (!attachmentSession?.sessionId) {
2186
+ const isRepoProvided = Boolean(repoUrl);
2187
+ const isCloning =
2188
+ sessionMode === "new" && sessionRequested && isRepoProvided;
2189
+ const repoDisplay = getTruncatedText(repoUrl, 72);
2190
+ const formDisabled = workspaceBusy || sessionRequested;
2191
+ const workspaceProvider = (providerKey) => workspaceProviders[providerKey] || {};
2192
+ const showMonoLoginRequired =
2193
+ deploymentMode === "mono_user" && !workspaceToken;
2194
+ const showStep1 = !showMonoLoginRequired && workspaceStep === 1;
2195
+ const showStep2 = !showMonoLoginRequired && workspaceStep === 2;
2196
+ const showStep3 = workspaceStep === 3 && workspaceToken;
2197
+ const showStep4 = workspaceStep === 4 && workspaceToken;
2198
+ const headerHint = showMonoLoginRequired
2199
+ ? t("Please use the console generated URL to login")
2200
+ : showStep2
2201
+ ? t("Configure AI providers for this workspace.")
2202
+ : showStep3
2203
+ ? t("Workspace created hint")
2204
+ : null;
2205
+ const infoContent = showMonoLoginRequired
2206
+ ? {
2207
+ title: t("Login required"),
2208
+ paragraphs: [t("Please use the console generated URL to login")],
2209
+ }
2210
+ : showStep2
2211
+ ? {
2212
+ title: t("Configure AI providers"),
2213
+ paragraphs: [
2214
+ t(
2215
+ "Vibe80 can run Codex or Claude Code. To continue, provide your Anthropic and/or OpenAI credentials. If you use pay-as-you-go billing, supply an API key."
2216
+ ),
2217
+ t(
2218
+ "For subscription plans, use auth.json from the Codex CLI login (ChatGPT) or a long-lived token from `claude setup-token` (Claude)."
2219
+ ),
2220
+ ],
2221
+ }
2222
+ : showStep3
2223
+ ? {
2224
+ title: t("Workspace credentials"),
2225
+ paragraphs: [
2226
+ t(
2227
+ "Please keep your workspace credentials (Workspace ID and Workspace Secret) for future access. We do not have a user identification mechanism; your Workspace ID is your only identifier."
2228
+ ),
2229
+ ],
2230
+ }
2231
+ : showStep4
2232
+ ? {
2233
+ title: t("Start a session"),
2234
+ paragraphs: [
2235
+ t(
2236
+ "Vibe80 opens Git-based work sessions. Even in a secure environment, we recommend short-lived and revocable PATs or keys."
2237
+ ),
2238
+ ],
2239
+ securityLink: true,
2240
+ }
2241
+ : {
2242
+ title: t("Configure the workspace"),
2243
+ paragraphs: [
2244
+ t(
2245
+ "A workspace is an isolated, secured environment accessible only with credentials. It lets you reuse AI credentials for all future sessions."
2246
+ ),
2247
+ t(
2248
+ "You can create multiple workspaces to separate teams, projects, or security boundaries."
2249
+ ),
2250
+ ],
2251
+ };
2252
+ return (
2253
+ <SessionGate
2254
+ t={t}
2255
+ brandLogo={brandLogo}
2256
+ showStep1={showStep1}
2257
+ showStep2={showStep2}
2258
+ showStep3={showStep3}
2259
+ showStep4={showStep4}
2260
+ showMonoLoginRequired={showMonoLoginRequired}
2261
+ headerHint={headerHint}
2262
+ workspaceMode={workspaceMode}
2263
+ setWorkspaceMode={setWorkspaceMode}
2264
+ formDisabled={formDisabled}
2265
+ handleWorkspaceSubmit={handleWorkspaceSubmit}
2266
+ workspaceIdInput={workspaceIdInput}
2267
+ setWorkspaceIdInput={setWorkspaceIdInput}
2268
+ workspaceSecretInput={workspaceSecretInput}
2269
+ setWorkspaceSecretInput={setWorkspaceSecretInput}
2270
+ workspaceError={workspaceError}
2271
+ handleWorkspaceProvidersSubmit={handleWorkspaceProvidersSubmit}
2272
+ workspaceProvider={workspaceProvider}
2273
+ workspaceAuthExpanded={workspaceAuthExpanded}
2274
+ setWorkspaceAuthExpanded={setWorkspaceAuthExpanded}
2275
+ setWorkspaceProviders={setWorkspaceProviders}
2276
+ providerAuthOptions={providerAuthOptions}
2277
+ getProviderAuthType={getProviderAuthType}
2278
+ workspaceAuthFiles={workspaceAuthFiles}
2279
+ setWorkspaceAuthFiles={setWorkspaceAuthFiles}
2280
+ sessionMode={sessionMode}
2281
+ setSessionMode={setSessionMode}
2282
+ setSessionRequested={setSessionRequested}
2283
+ setAttachmentsError={setAttachmentsError}
2284
+ loadWorkspaceSessions={loadWorkspaceSessions}
2285
+ deploymentMode={deploymentMode}
2286
+ handleLeaveWorkspace={handleLeaveWorkspace}
2287
+ workspaceSessionsLoading={workspaceSessionsLoading}
2288
+ workspaceSessions={workspaceSessions}
2289
+ workspaceSessionsError={workspaceSessionsError}
2290
+ workspaceSessionDeletingId={workspaceSessionDeletingId}
2291
+ handleResumeSession={handleResumeSession}
2292
+ handleDeleteSession={handleDeleteSession}
2293
+ locale={locale}
2294
+ extractRepoName={extractRepoName}
2295
+ getTruncatedText={getTruncatedText}
2296
+ isCloning={isCloning}
2297
+ repoDisplay={repoDisplay}
2298
+ onRepoSubmit={onRepoSubmit}
2299
+ sessionNameInput={sessionNameInput}
2300
+ setSessionNameInput={setSessionNameInput}
2301
+ repoInput={repoInput}
2302
+ setRepoInput={setRepoInput}
2303
+ repoHistory={repoHistory}
2304
+ authMode={authMode}
2305
+ setAuthMode={setAuthMode}
2306
+ sshKeyInput={sshKeyInput}
2307
+ setSshKeyInput={setSshKeyInput}
2308
+ httpUsername={httpUsername}
2309
+ setHttpUsername={setHttpUsername}
2310
+ httpPassword={httpPassword}
2311
+ setHttpPassword={setHttpPassword}
2312
+ defaultInternetAccess={defaultInternetAccess}
2313
+ setDefaultInternetAccess={setDefaultInternetAccess}
2314
+ defaultDenyGitCredentialsAccess={defaultDenyGitCredentialsAccess}
2315
+ setDefaultDenyGitCredentialsAccess={setDefaultDenyGitCredentialsAccess}
2316
+ attachmentsError={attachmentsError}
2317
+ sessionRequested={sessionRequested}
2318
+ workspaceBusy={workspaceBusy}
2319
+ workspaceProvidersEditing={workspaceProvidersEditing}
2320
+ providersBackStep={providersBackStep}
2321
+ setWorkspaceStep={setWorkspaceStep}
2322
+ setWorkspaceProvidersEditing={setWorkspaceProvidersEditing}
2323
+ setWorkspaceError={setWorkspaceError}
2324
+ setProvidersBackStep={setProvidersBackStep}
2325
+ loadWorkspaceProviders={loadWorkspaceProviders}
2326
+ workspaceCreated={workspaceCreated}
2327
+ workspaceId={workspaceId}
2328
+ workspaceCopied={workspaceCopied}
2329
+ handleWorkspaceCopy={handleWorkspaceCopy}
2330
+ infoContent={infoContent}
2331
+ toast={toast}
2332
+ />
2333
+ );
2334
+ }
2335
+ const renderExplorerNodes = (
2336
+ nodes,
2337
+ tabId,
2338
+ expandedSet,
2339
+ selectedPath,
2340
+ selectedType,
2341
+ renamingPath,
2342
+ renameDraft,
2343
+ statusByPath,
2344
+ dirStatus
2345
+ ) => {
2346
+ if (!Array.isArray(nodes) || nodes.length === 0) {
2347
+ return null;
2348
+ }
2349
+ return (
2350
+ <ul className="explorer-tree-list">
2351
+ {nodes.map((node) => {
2352
+ if (node.type === "dir") {
2353
+ const isExpanded = expandedSet.has(node.path);
2354
+ const isSelected = selectedPath === node.path && selectedType === "dir";
2355
+ const isRenaming = renamingPath === node.path;
2356
+ const statusType = dirStatus?.[node.path] || "";
2357
+ return (
2358
+ <li
2359
+ key={node.path}
2360
+ className={`explorer-tree-item is-dir ${
2361
+ isSelected ? "is-selected" : ""
2362
+ } ${
2363
+ statusType ? `is-${statusType}` : ""
2364
+ }`}
2365
+ >
2366
+ <div className="explorer-tree-entry">
2367
+ <button
2368
+ type="button"
2369
+ className="explorer-tree-caret-button"
2370
+ onClick={(event) => {
2371
+ event.stopPropagation();
2372
+ toggleExplorerDir(tabId, node.path);
2373
+ }}
2374
+ >
2375
+ <span className="explorer-tree-caret" aria-hidden="true">
2376
+ <FontAwesomeIcon
2377
+ icon={isExpanded ? faChevronDown : faChevronRight}
2378
+ />
2379
+ </span>
2380
+ </button>
2381
+ {!isRenaming ? (
2382
+ <button
2383
+ type="button"
2384
+ className="explorer-tree-toggle"
2385
+ onClick={() => selectExplorerNode(tabId, node.path, "dir")}
2386
+ >
2387
+ <span className="explorer-tree-name">{node.name}</span>
2388
+ </button>
2389
+ ) : (
2390
+ <div className="explorer-tree-toggle is-renaming">
2391
+ <input
2392
+ className="explorer-tree-rename-input"
2393
+ value={renameDraft || ""}
2394
+ autoFocus
2395
+ onClick={(event) => event.stopPropagation()}
2396
+ onChange={(event) =>
2397
+ updateExplorerRenameDraft(tabId, event.target.value)
2398
+ }
2399
+ onBlur={() => {
2400
+ void submitExplorerRename(tabId);
2401
+ }}
2402
+ onKeyDown={(event) => {
2403
+ if (event.key === "Enter") {
2404
+ event.preventDefault();
2405
+ void submitExplorerRename(tabId);
2406
+ } else if (event.key === "Escape") {
2407
+ event.preventDefault();
2408
+ cancelExplorerRename(tabId);
2409
+ }
2410
+ }}
2411
+ />
2412
+ </div>
2413
+ )}
2414
+ </div>
2415
+ {isExpanded
2416
+ ? renderExplorerNodes(
2417
+ node.children || [],
2418
+ tabId,
2419
+ expandedSet,
2420
+ selectedPath,
2421
+ selectedType,
2422
+ renamingPath,
2423
+ renameDraft,
2424
+ statusByPath,
2425
+ dirStatus
2426
+ )
2427
+ : null}
2428
+ </li>
2429
+ );
2430
+ }
2431
+ const isSelected = selectedPath === node.path && selectedType === "file";
2432
+ const isRenaming = renamingPath === node.path;
2433
+ const statusType = statusByPath?.[node.path] || "";
2434
+ return (
2435
+ <li
2436
+ key={node.path}
2437
+ className={`explorer-tree-item is-file ${
2438
+ isSelected ? "is-selected" : ""
2439
+ } ${statusType ? `is-${statusType}` : ""}`}
2440
+ >
2441
+ {!isRenaming ? (
2442
+ <button
2443
+ type="button"
2444
+ className="explorer-tree-file"
2445
+ onClick={() => {
2446
+ selectExplorerNode(tabId, node.path, "file");
2447
+ loadExplorerFileRef.current?.(tabId, node.path);
2448
+ }}
2449
+ >
2450
+ <span className="explorer-tree-icon" aria-hidden="true">
2451
+ <FontAwesomeIcon icon={faFileLines} />
2452
+ </span>
2453
+ <span className="explorer-tree-name">{node.name}</span>
2454
+ </button>
2455
+ ) : (
2456
+ <div className="explorer-tree-file is-renaming">
2457
+ <span className="explorer-tree-icon" aria-hidden="true">
2458
+ <FontAwesomeIcon icon={faFileLines} />
2459
+ </span>
2460
+ <input
2461
+ className="explorer-tree-rename-input"
2462
+ value={renameDraft || ""}
2463
+ autoFocus
2464
+ onClick={(event) => event.stopPropagation()}
2465
+ onChange={(event) =>
2466
+ updateExplorerRenameDraft(tabId, event.target.value)
2467
+ }
2468
+ onBlur={() => {
2469
+ void submitExplorerRename(tabId);
2470
+ }}
2471
+ onKeyDown={(event) => {
2472
+ if (event.key === "Enter") {
2473
+ event.preventDefault();
2474
+ void submitExplorerRename(tabId);
2475
+ } else if (event.key === "Escape") {
2476
+ event.preventDefault();
2477
+ cancelExplorerRename(tabId);
2478
+ }
2479
+ }}
2480
+ />
2481
+ </div>
2482
+ )}
2483
+ </li>
2484
+ );
2485
+ })}
2486
+ </ul>
2487
+ );
2488
+ };
2489
+
2490
+ return (
2491
+ <div className="app">
2492
+ <Topbar
2493
+ t={t}
2494
+ brandLogo={brandLogo}
2495
+ allTabs={allTabs}
2496
+ activeWorktreeId={activeWorktreeId}
2497
+ handleSelectWorktree={handleSelectWorktree}
2498
+ createWorktree={createWorktree}
2499
+ openCloseConfirm={openCloseConfirm}
2500
+ renameWorktreeHandler={renameWorktreeHandler}
2501
+ llmProvider={llmProvider}
2502
+ availableProviders={availableProviders}
2503
+ branches={branches}
2504
+ defaultBranch={defaultBranch}
2505
+ currentBranch={currentBranch}
2506
+ branchLoading={branchLoading}
2507
+ branchError={branchError}
2508
+ defaultInternetAccess={defaultInternetAccess}
2509
+ defaultDenyGitCredentialsAccess={defaultDenyGitCredentialsAccess}
2510
+ deploymentMode={deploymentMode}
2511
+ loadBranches={loadBranches}
2512
+ providerModelState={providerModelState}
2513
+ loadProviderModels={loadProviderModels}
2514
+ connected={connected}
2515
+ isMobileLayout={isMobileLayout}
2516
+ requestHandoffQr={requestHandoffQr}
2517
+ attachmentSession={attachmentSession}
2518
+ handoffLoading={handoffLoading}
2519
+ handleOpenSettings={handleOpenSettings}
2520
+ handleLeaveSession={handleLeaveSession}
2521
+ />
2522
+
2523
+ <div
2524
+ className={`layout ${sideOpen ? "is-side-open" : "is-side-collapsed"} ${
2525
+ isMobileLayout ? "is-mobile" : ""
2526
+ }`}
2527
+ >
2528
+ {isMobileLayout && sideOpen ? (
2529
+ <button
2530
+ type="button"
2531
+ className="side-backdrop"
2532
+ aria-label={t("Close panel")}
2533
+ onClick={() => setSideOpen(false)}
2534
+ />
2535
+ ) : null}
2536
+
2537
+ <aside className="side">
2538
+ <div className="side-body">
2539
+ <section className="backlog">
2540
+ <div className="panel-header">
2541
+ <div className="panel-title">{t("Backlog")}</div>
2542
+ <div className="panel-subtitle">
2543
+ {backlog.length === 0
2544
+ ? t("No tasks")
2545
+ : t("{{count}} item(s)", { count: backlog.length })}
2546
+ </div>
2547
+ </div>
2548
+ {backlog.length === 0 ? (
2549
+ <div className="backlog-empty">
2550
+ {t("No pending tasks at the moment.")}
2551
+ </div>
2552
+ ) : (
2553
+ <ul className="backlog-list">
2554
+ {backlog.map((item) => (
2555
+ <li key={item.id} className="backlog-item">
2556
+ <div className="backlog-text">
2557
+ {getTruncatedText(item.text, 180)}
2558
+ </div>
2559
+ <div className="backlog-actions">
2560
+ <button
2561
+ type="button"
2562
+ className="ghost"
2563
+ onClick={() => editBacklogItem(item)}
2564
+ >
2565
+ {t("Edit")}
2566
+ </button>
2567
+ <button
2568
+ type="button"
2569
+ onClick={() => launchBacklogItem(item)}
2570
+ disabled={!connected}
2571
+ >
2572
+ {t("Launch")}
2573
+ </button>
2574
+ <button
2575
+ type="button"
2576
+ className="ghost"
2577
+ onClick={() => removeFromBacklog(item.id)}
2578
+ >
2579
+ {t("Delete")}
2580
+ </button>
2581
+ </div>
2582
+ {item.attachments?.length ? (
2583
+ <div className="backlog-meta">
2584
+ {t("{{count}} attachment(s)", {
2585
+ count: item.attachments.length,
2586
+ })}
2587
+ </div>
2588
+ ) : null}
2589
+ </li>
2590
+ ))}
2591
+ </ul>
2592
+ )}
2593
+ </section>
2594
+ </div>
2595
+ <div className="side-footer">
2596
+ <button
2597
+ type="button"
2598
+ className={`side-footer-button ${
2599
+ activePane === "settings" ? "is-active" : ""
2600
+ }`}
2601
+ onClick={handleOpenSettings}
2602
+ aria-pressed={activePane === "settings"}
2603
+ >
2604
+ <span className="side-footer-icon" aria-hidden="true">
2605
+ <FontAwesomeIcon icon={faGear} />
2606
+ </span>
2607
+ {t("Settings")}
2608
+ </button>
2609
+ </div>
2610
+ </aside>
2611
+
2612
+ <section className="conversation is-chat-narrow" ref={conversationRef}>
2613
+ <div className="pane-stack">
2614
+ <ChatToolbar
2615
+ t={t}
2616
+ activePane={activePane}
2617
+ handleViewSelect={handleViewSelect}
2618
+ handleDiffSelect={handleDiffSelect}
2619
+ terminalEnabled={terminalEnabled}
2620
+ hasMessages={hasMessages}
2621
+ handleClearChat={handleClearChat}
2622
+ />
2623
+ <ChatMessages
2624
+ t={t}
2625
+ activePane={activePane}
2626
+ listRef={listRef}
2627
+ showChatInfoPanel={showChatInfoPanel}
2628
+ repoTitle={repoTitle}
2629
+ activeBranchLabel={activeBranchLabel}
2630
+ shortSha={shortSha}
2631
+ activeCommit={activeCommit}
2632
+ showProviderMeta={showProviderMeta}
2633
+ activeProviderLabel={activeProviderLabel}
2634
+ activeModelLabel={activeModelLabel}
2635
+ showInternetAccess={showInternetAccess}
2636
+ showGitCredentialsShared={showGitCredentialsShared}
2637
+ activeTaskLabel={activeTaskLabel}
2638
+ currentMessages={currentMessages}
2639
+ chatHistoryWindow={chatHistoryWindow}
2640
+ activeChatKey={activeChatKey}
2641
+ setShowOlderMessagesByTab={setShowOlderMessagesByTab}
2642
+ showChatCommands={showChatCommands}
2643
+ showToolResults={showToolResults}
2644
+ commandPanelOpen={commandPanelOpen}
2645
+ setCommandPanelOpen={setCommandPanelOpen}
2646
+ toolResultPanelOpen={toolResultPanelOpen}
2647
+ setToolResultPanelOpen={setToolResultPanelOpen}
2648
+ renderMessageAttachments={renderMessageAttachments}
2649
+ currentProcessing={currentProcessing}
2650
+ currentInteractionBlocked={currentInteractionBlocked}
2651
+ currentActivity={currentActivity}
2652
+ extractVibe80Blocks={extractVibe80Blocks}
2653
+ handleChoiceClick={handleChoiceClick}
2654
+ choiceSelections={choiceSelections}
2655
+ openVibe80Form={openVibe80Form}
2656
+ copyTextToClipboard={copyTextToClipboard}
2657
+ openFileInExplorer={openFileInExplorer}
2658
+ setInput={setInput}
2659
+ inputRef={inputRef}
2660
+ markBacklogItemDone={markBacklogItemDone}
2661
+ setBacklogMessagePage={setBacklogMessagePage}
2662
+ activeWorktreeId={activeWorktreeId}
2663
+ BACKLOG_PAGE_SIZE={BACKLOG_PAGE_SIZE}
2664
+ MAX_USER_DISPLAY_LENGTH={MAX_USER_DISPLAY_LENGTH}
2665
+ getTruncatedText={getTruncatedText}
2666
+ annotationMode={annotationMode}
2667
+ scopedAnnotations={scopedAnnotations}
2668
+ setAnnotationDraft={setAnnotationDraft}
2669
+ removeAnnotation={removeAnnotation}
2670
+ addOrFocusAnnotation={addOrFocusAnnotation}
2671
+ />
2672
+ <Suspense fallback={null}>
2673
+ <DiffPanel
2674
+ t={t}
2675
+ activePane={activePane}
2676
+ isInWorktree={isInWorktree}
2677
+ diffStatusLines={diffStatusLines}
2678
+ connected={connected}
2679
+ currentProcessing={currentProcessing}
2680
+ hasCurrentChanges={hasCurrentChanges}
2681
+ sendCommitMessage={sendCommitMessage}
2682
+ diffFiles={diffFiles}
2683
+ currentDiff={currentDiff}
2684
+ untrackedFilePanels={untrackedFilePanels}
2685
+ untrackedLoading={untrackedLoading}
2686
+ />
2687
+ </Suspense>
2688
+ <Suspense fallback={null}>
2689
+ <ExplorerPanel
2690
+ t={t}
2691
+ activePane={activePane}
2692
+ repoName={repoName}
2693
+ activeWorktree={activeWorktree}
2694
+ isInWorktree={isInWorktree}
2695
+ activeWorktreeId={activeWorktreeId}
2696
+ attachmentSession={attachmentSession}
2697
+ requestExplorerTree={requestExplorerTree}
2698
+ requestExplorerStatus={requestExplorerStatus}
2699
+ activeExplorer={activeExplorer}
2700
+ renderExplorerNodes={renderExplorerNodes}
2701
+ explorerStatusByPath={explorerStatusByPath}
2702
+ explorerDirStatus={explorerDirStatus}
2703
+ saveExplorerFile={saveExplorerFile}
2704
+ updateExplorerDraft={updateExplorerDraft}
2705
+ setActiveExplorerFile={setActiveExplorerFile}
2706
+ closeExplorerFile={closeExplorerFile}
2707
+ startExplorerRename={startExplorerRename}
2708
+ createExplorerFile={createExplorerFile}
2709
+ createExplorerFolder={createExplorerFolder}
2710
+ deleteExplorerSelection={deleteExplorerSelection}
2711
+ getLanguageForPath={getLanguageForPath}
2712
+ themeMode={themeMode}
2713
+ />
2714
+ </Suspense>
2715
+ <Suspense fallback={null}>
2716
+ <TerminalPanel
2717
+ t={t}
2718
+ terminalEnabled={terminalEnabled}
2719
+ activePane={activePane}
2720
+ repoName={repoName}
2721
+ activeWorktree={activeWorktree}
2722
+ isInWorktree={isInWorktree}
2723
+ terminalContainerRef={terminalContainerRef}
2724
+ attachmentSession={attachmentSession}
2725
+ />
2726
+ </Suspense>
2727
+ <Suspense fallback={null}>
2728
+ <LogsPanel
2729
+ t={t}
2730
+ activePane={activePane}
2731
+ filteredRpcLogs={filteredRpcLogs}
2732
+ logFilter={logFilter}
2733
+ setLogFilter={setLogFilter}
2734
+ scopedRpcLogs={scopedRpcLogs}
2735
+ handleClearRpcLogs={handleClearRpcLogs}
2736
+ />
2737
+ </Suspense>
2738
+ <Suspense fallback={null}>
2739
+ <SettingsPanel
2740
+ t={t}
2741
+ activePane={activePane}
2742
+ handleSettingsBack={handleSettingsBack}
2743
+ language={language}
2744
+ setLanguage={setLanguage}
2745
+ showChatCommands={showChatCommands}
2746
+ setShowChatCommands={setShowChatCommands}
2747
+ showToolResults={showToolResults}
2748
+ setShowToolResults={setShowToolResults}
2749
+ notificationsEnabled={notificationsEnabled}
2750
+ setNotificationsEnabled={setNotificationsEnabled}
2751
+ themeMode={themeMode}
2752
+ setThemeMode={setThemeMode}
2753
+ gitIdentityName={gitIdentityName}
2754
+ setGitIdentityName={setGitIdentityName}
2755
+ gitIdentityEmail={gitIdentityEmail}
2756
+ setGitIdentityEmail={setGitIdentityEmail}
2757
+ gitIdentityGlobal={gitIdentityGlobal}
2758
+ gitIdentityRepo={gitIdentityRepo}
2759
+ gitIdentityLoading={gitIdentityLoading}
2760
+ gitIdentitySaving={gitIdentitySaving}
2761
+ gitIdentityError={gitIdentityError}
2762
+ gitIdentityMessage={gitIdentityMessage}
2763
+ handleSaveGitIdentity={handleSaveGitIdentity}
2764
+ attachmentSession={attachmentSession}
2765
+ />
2766
+ </Suspense>
2767
+ </div>
2768
+ <ChatComposer
2769
+ t={t}
2770
+ activePane={activePane}
2771
+ isDraggingAttachments={isDraggingAttachments}
2772
+ onSubmit={onSubmit}
2773
+ onDragEnterComposer={onDragEnterComposer}
2774
+ onDragOverComposer={onDragOverComposer}
2775
+ onDragLeaveComposer={onDragLeaveComposer}
2776
+ onDropAttachments={onDropAttachments}
2777
+ composerRef={composerRef}
2778
+ draftAttachments={draftAttachments}
2779
+ getAttachmentExtension={getAttachmentExtension}
2780
+ formatAttachmentSize={formatAttachmentSize}
2781
+ removeDraftAttachment={removeDraftAttachment}
2782
+ commandMenuOpen={commandMenuOpen}
2783
+ filteredCommands={filteredCommands}
2784
+ setInput={setInput}
2785
+ setCommandMenuOpen={setCommandMenuOpen}
2786
+ setCommandQuery={setCommandQuery}
2787
+ inputRef={inputRef}
2788
+ commandSelection={commandSelection}
2789
+ triggerAttachmentPicker={triggerAttachmentPicker}
2790
+ attachmentSession={attachmentSession}
2791
+ attachmentsLoading={attachmentsLoading}
2792
+ isMobileLayout={isMobileLayout}
2793
+ uploadInputRef={uploadInputRef}
2794
+ onUploadAttachments={onUploadAttachments}
2795
+ input={input}
2796
+ handleInputChange={handleInputChange}
2797
+ handleComposerKeyDown={handleComposerKeyDown}
2798
+ onPasteAttachments={onPasteAttachments}
2799
+ canInterrupt={canInterrupt}
2800
+ interruptTurn={interruptTurn}
2801
+ connected={connected}
2802
+ modelSelectorVisible={composerModelVisible}
2803
+ modelOptions={activeModelOptions}
2804
+ selectedModel={composerSelectedModel}
2805
+ modelLoading={activeModelLoading}
2806
+ modelDisabled={composerModelDisabled}
2807
+ modelError={activeModelError}
2808
+ onModelChange={handleComposerModelChange}
2809
+ isCodexReady={isCodexReady}
2810
+ interactionBlocked={currentInteractionBlocked}
2811
+ attachmentsError={attachmentsError}
2812
+ />
2813
+ </section>
2814
+ </div>
2815
+ {activeForm ? (
2816
+ <div
2817
+ className="vibe80-form-overlay"
2818
+ role="dialog"
2819
+ aria-modal="true"
2820
+ onClick={closeVibe80Form}
2821
+ >
2822
+ <div
2823
+ className="vibe80-form-dialog"
2824
+ onClick={(event) => event.stopPropagation()}
2825
+ >
2826
+ <div className="vibe80-form-header">
2827
+ <div className="vibe80-form-title">
2828
+ {activeForm.question || t("Form")}
2829
+ </div>
2830
+ <button
2831
+ type="button"
2832
+ className="vibe80-form-close"
2833
+ aria-label={t("Close")}
2834
+ onClick={closeVibe80Form}
2835
+ >
2836
+ <FontAwesomeIcon icon={faXmark} />
2837
+ </button>
2838
+ </div>
2839
+ <form
2840
+ className="vibe80-form-body"
2841
+ onSubmit={(event) => {
2842
+ if (currentInteractionBlocked) {
2843
+ event.preventDefault();
2844
+ return;
2845
+ }
2846
+ submitActiveForm(event);
2847
+ }}
2848
+ >
2849
+ {activeForm.fields.map((field) => {
2850
+ const fieldId = `vibe80-${activeForm.key}-${field.id}`;
2851
+ const value = activeFormValues[field.id] ?? "";
2852
+ if (field.type === "checkbox") {
2853
+ return (
2854
+ <div className="vibe80-form-field" key={field.id}>
2855
+ <label className="vibe80-form-checkbox">
2856
+ <input
2857
+ type="checkbox"
2858
+ checked={Boolean(activeFormValues[field.id])}
2859
+ onChange={(event) =>
2860
+ updateActiveFormValue(
2861
+ field.id,
2862
+ event.target.checked
2863
+ )
2864
+ }
2865
+ />
2866
+ <span>{field.label}</span>
2867
+ </label>
2868
+ </div>
2869
+ );
2870
+ }
2871
+ if (field.type === "textarea") {
2872
+ return (
2873
+ <div className="vibe80-form-field" key={field.id}>
2874
+ <label className="vibe80-form-label" htmlFor={fieldId}>
2875
+ {field.label}
2876
+ </label>
2877
+ <textarea
2878
+ id={fieldId}
2879
+ className="vibe80-form-input"
2880
+ rows={4}
2881
+ value={value}
2882
+ onChange={(event) =>
2883
+ updateActiveFormValue(field.id, event.target.value)
2884
+ }
2885
+ />
2886
+ </div>
2887
+ );
2888
+ }
2889
+ if (field.type === "radio") {
2890
+ return (
2891
+ <div className="vibe80-form-field" key={field.id}>
2892
+ <div className="vibe80-form-label">{field.label}</div>
2893
+ <div className="vibe80-form-options">
2894
+ {(field.choices || []).length ? (
2895
+ field.choices.map((choice) => (
2896
+ <label
2897
+ key={`${field.id}-${choice}`}
2898
+ className="vibe80-form-option"
2899
+ >
2900
+ <input
2901
+ type="radio"
2902
+ name={fieldId}
2903
+ value={choice}
2904
+ checked={value === choice}
2905
+ onChange={() =>
2906
+ updateActiveFormValue(field.id, choice)
2907
+ }
2908
+ />
2909
+ <span>{choice}</span>
2910
+ </label>
2911
+ ))
2912
+ ) : (
2913
+ <div className="vibe80-form-empty">
2914
+ {t("No options.")}
2915
+ </div>
2916
+ )}
2917
+ </div>
2918
+ </div>
2919
+ );
2920
+ }
2921
+ if (field.type === "select") {
2922
+ return (
2923
+ <div className="vibe80-form-field" key={field.id}>
2924
+ <label className="vibe80-form-label" htmlFor={fieldId}>
2925
+ {field.label}
2926
+ </label>
2927
+ <select
2928
+ id={fieldId}
2929
+ className="vibe80-form-input vibe80-form-select"
2930
+ value={value}
2931
+ onChange={(event) =>
2932
+ updateActiveFormValue(field.id, event.target.value)
2933
+ }
2934
+ >
2935
+ {(field.choices || []).length ? (
2936
+ field.choices.map((choice) => (
2937
+ <option key={`${field.id}-${choice}`} value={choice}>
2938
+ {choice}
2939
+ </option>
2940
+ ))
2941
+ ) : (
2942
+ <option value="">{t("No options")}</option>
2943
+ )}
2944
+ </select>
2945
+ </div>
2946
+ );
2947
+ }
2948
+ return (
2949
+ <div className="vibe80-form-field" key={field.id}>
2950
+ <label className="vibe80-form-label" htmlFor={fieldId}>
2951
+ {field.label}
2952
+ </label>
2953
+ <input
2954
+ id={fieldId}
2955
+ className="vibe80-form-input"
2956
+ type="text"
2957
+ value={value}
2958
+ onChange={(event) =>
2959
+ updateActiveFormValue(field.id, event.target.value)
2960
+ }
2961
+ />
2962
+ </div>
2963
+ );
2964
+ })}
2965
+ <div className="vibe80-form-actions">
2966
+ <button
2967
+ type="button"
2968
+ className="vibe80-form-cancel"
2969
+ onClick={closeVibe80Form}
2970
+ >
2971
+ {t("Cancel")}
2972
+ </button>
2973
+ <button
2974
+ type="submit"
2975
+ className="vibe80-form-submit"
2976
+ disabled={currentInteractionBlocked}
2977
+ >
2978
+ {t("Send")}
2979
+ </button>
2980
+ </div>
2981
+ </form>
2982
+ </div>
2983
+ </div>
2984
+ ) : null}
2985
+ {handoffOpen ? (
2986
+ <div
2987
+ className="handoff-modal-overlay"
2988
+ role="dialog"
2989
+ aria-modal="true"
2990
+ onClick={closeHandoffQr}
2991
+ >
2992
+ <div
2993
+ className="handoff-modal"
2994
+ onClick={(event) => event.stopPropagation()}
2995
+ >
2996
+ <div className="handoff-modal-header">
2997
+ <div className="handoff-modal-title">{t("Continue on mobile")}</div>
2998
+ <button
2999
+ type="button"
3000
+ className="handoff-modal-close"
3001
+ aria-label={t("Close")}
3002
+ onClick={closeHandoffQr}
3003
+ >
3004
+ <FontAwesomeIcon icon={faXmark} />
3005
+ </button>
3006
+ </div>
3007
+ <div className="handoff-modal-body">
3008
+ <p className="handoff-modal-text">
3009
+ {t(
3010
+ "Scan this QR code in the Android app to resume the current session."
3011
+ )}
3012
+ </p>
3013
+ {handoffError ? (
3014
+ <div className="handoff-modal-error">{handoffError}</div>
3015
+ ) : null}
3016
+ {handoffQrDataUrl ? (
3017
+ <div className="handoff-modal-qr">
3018
+ <img src={handoffQrDataUrl} alt={t("QR code")} />
3019
+ </div>
3020
+ ) : (
3021
+ <div className="handoff-modal-placeholder">
3022
+ {handoffLoading
3023
+ ? t("Generating QR code...")
3024
+ : t("QR code unavailable.")}
3025
+ </div>
3026
+ )}
3027
+ {typeof handoffRemaining === "number" ? (
3028
+ <div className="handoff-modal-meta">
3029
+ {handoffRemaining > 0
3030
+ ? t("Expires in {{seconds}}s", {
3031
+ seconds: handoffRemaining,
3032
+ })
3033
+ : t("QR code expired")}
3034
+ </div>
3035
+ ) : null}
3036
+ <div className="handoff-modal-actions">
3037
+ <button
3038
+ type="button"
3039
+ className="session-button"
3040
+ onClick={requestHandoffQr}
3041
+ disabled={handoffLoading}
3042
+ >
3043
+ {t("Regenerate")}
3044
+ </button>
3045
+ </div>
3046
+ </div>
3047
+ </div>
3048
+ </div>
3049
+ ) : null}
3050
+ {closeConfirm ? (
3051
+ <div
3052
+ className="worktree-close-confirm-overlay"
3053
+ role="dialog"
3054
+ aria-modal="true"
3055
+ onClick={closeCloseConfirm}
3056
+ >
3057
+ <div
3058
+ className="worktree-close-confirm-dialog"
3059
+ onClick={(event) => event.stopPropagation()}
3060
+ >
3061
+ <div className="worktree-close-confirm-header">
3062
+ <div className="worktree-close-confirm-title">
3063
+ {t("Close the worktree?")}
3064
+ </div>
3065
+ <button
3066
+ type="button"
3067
+ className="worktree-close-confirm-close"
3068
+ aria-label={t("Close")}
3069
+ onClick={closeCloseConfirm}
3070
+ >
3071
+ <FontAwesomeIcon icon={faXmark} />
3072
+ </button>
3073
+ </div>
3074
+ <div className="worktree-close-confirm-body">
3075
+ {t("All changes will be lost. What would you like to do?")}
3076
+ </div>
3077
+ <div className="worktree-close-confirm-actions">
3078
+ <button
3079
+ type="button"
3080
+ className="worktree-close-confirm-cancel"
3081
+ onClick={closeCloseConfirm}
3082
+ >
3083
+ {t("Cancel")}
3084
+ </button>
3085
+ <button
3086
+ type="button"
3087
+ className="worktree-close-confirm-delete"
3088
+ onClick={handleConfirmDelete}
3089
+ >
3090
+ {t("Delete worktree")}
3091
+ </button>
3092
+ </div>
3093
+ </div>
3094
+ </div>
3095
+ ) : null}
3096
+ {attachmentPreview ? (
3097
+ <div
3098
+ className="attachment-modal"
3099
+ role="dialog"
3100
+ aria-modal="true"
3101
+ onClick={() => setAttachmentPreview(null)}
3102
+ >
3103
+ <button
3104
+ type="button"
3105
+ className="attachment-modal-close"
3106
+ aria-label={t("Close")}
3107
+ onClick={() => setAttachmentPreview(null)}
3108
+ >
3109
+ <FontAwesomeIcon icon={faXmark} />
3110
+ </button>
3111
+ <div
3112
+ className="attachment-modal-body"
3113
+ onClick={(event) => event.stopPropagation()}
3114
+ >
3115
+ <img
3116
+ src={attachmentPreview.url}
3117
+ alt={attachmentPreview.name || t("Preview")}
3118
+ />
3119
+ {attachmentPreview.name ? (
3120
+ <div className="attachment-modal-name">
3121
+ {attachmentPreview.name}
3122
+ </div>
3123
+ ) : null}
3124
+ </div>
3125
+ </div>
3126
+ ) : null}
3127
+ </div>
3128
+ );
3129
+ }
3130
+
3131
+ export default App;