@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,328 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import { Terminal } from "@xterm/xterm";
3
+ import { FitAddon } from "@xterm/addon-fit";
4
+
5
+ const terminalWsUrl = (sessionId, worktreeId) => {
6
+ const params = new URLSearchParams();
7
+ if (sessionId) {
8
+ params.set("session", sessionId);
9
+ }
10
+ if (worktreeId) {
11
+ params.set("worktreeId", worktreeId);
12
+ }
13
+ return `/terminal-ws?${params.toString()}`;
14
+ };
15
+
16
+ export default function useTerminalSession({
17
+ activePane,
18
+ activeWorktreeId,
19
+ attachmentSessionId,
20
+ terminalEnabled,
21
+ terminalContainerRef,
22
+ terminalDisposableRef,
23
+ terminalFitRef,
24
+ terminalRef,
25
+ terminalSessionRef,
26
+ terminalSocketRef,
27
+ terminalWorktreeRef,
28
+ themeMode,
29
+ workspaceToken,
30
+ }) {
31
+ const connectTerminal = useCallback(() => {
32
+ if (!terminalEnabled) {
33
+ return;
34
+ }
35
+ if (!workspaceToken) {
36
+ return;
37
+ }
38
+ const sessionId = attachmentSessionId;
39
+ if (!sessionId) {
40
+ return;
41
+ }
42
+ const worktreeId =
43
+ activeWorktreeId && activeWorktreeId !== "main"
44
+ ? activeWorktreeId
45
+ : null;
46
+ if (
47
+ terminalSocketRef.current &&
48
+ terminalSocketRef.current.readyState <= WebSocket.OPEN &&
49
+ terminalSessionRef.current === sessionId &&
50
+ terminalWorktreeRef.current === worktreeId
51
+ ) {
52
+ return;
53
+ }
54
+ if (terminalSocketRef.current) {
55
+ terminalSocketRef.current.close();
56
+ }
57
+ const term = terminalRef.current;
58
+ if (term) {
59
+ term.reset();
60
+ }
61
+ const socket = new WebSocket(terminalWsUrl(sessionId, worktreeId));
62
+ terminalSocketRef.current = socket;
63
+ terminalSessionRef.current = sessionId;
64
+ terminalWorktreeRef.current = worktreeId;
65
+ let authenticated = false;
66
+
67
+ socket.addEventListener("open", () => {
68
+ socket.send(JSON.stringify({ type: "auth", token: workspaceToken }));
69
+ });
70
+
71
+ socket.addEventListener("message", (event) => {
72
+ let payload = null;
73
+ try {
74
+ payload = JSON.parse(event.data);
75
+ } catch {
76
+ return;
77
+ }
78
+ if (!payload?.type) {
79
+ return;
80
+ }
81
+ if (payload.type === "auth_ok") {
82
+ if (authenticated) {
83
+ return;
84
+ }
85
+ authenticated = true;
86
+ const term = terminalRef.current;
87
+ const fitAddon = terminalFitRef.current;
88
+ if (term && fitAddon) {
89
+ fitAddon.fit();
90
+ socket.send(
91
+ JSON.stringify({ type: "init", cols: term.cols, rows: term.rows })
92
+ );
93
+ }
94
+ return;
95
+ }
96
+ if (!authenticated) {
97
+ return;
98
+ }
99
+ const term = terminalRef.current;
100
+ if (!term) {
101
+ return;
102
+ }
103
+ if (payload.type === "output" && typeof payload.data === "string") {
104
+ term.write(payload.data);
105
+ return;
106
+ }
107
+ if (payload.type === "exit") {
108
+ term.write(`\r\n[terminal exited ${payload.code}]\r\n`);
109
+ }
110
+ });
111
+
112
+ socket.addEventListener("close", () => {
113
+ const term = terminalRef.current;
114
+ if (term) {
115
+ term.write("\r\n[terminal disconnected]\r\n");
116
+ }
117
+ });
118
+ }, [
119
+ activeWorktreeId,
120
+ attachmentSessionId,
121
+ terminalEnabled,
122
+ terminalFitRef,
123
+ terminalRef,
124
+ terminalSessionRef,
125
+ terminalSocketRef,
126
+ terminalWorktreeRef,
127
+ workspaceToken,
128
+ ]);
129
+
130
+ useEffect(() => {
131
+ if (!terminalEnabled) {
132
+ return;
133
+ }
134
+ if (activePane !== "terminal") {
135
+ return;
136
+ }
137
+ if (!terminalContainerRef.current || terminalRef.current) {
138
+ return;
139
+ }
140
+ const isDark = themeMode === "dark";
141
+ const term = new Terminal({
142
+ fontFamily:
143
+ '"Space Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
144
+ fontSize: 13,
145
+ cursorBlink: true,
146
+ theme: {
147
+ background: isDark ? "#0f1110" : "#fbf6ee",
148
+ foreground: isDark ? "#e6edf3" : "#2a2418",
149
+ cursor: isDark ? "#e6edf3" : "#2a2418",
150
+ selection: isDark
151
+ ? "rgba(255, 255, 255, 0.2)"
152
+ : "rgba(20, 19, 17, 0.15)",
153
+ },
154
+ });
155
+ if (typeof term.setOption !== "function") {
156
+ term.setOption = (key, value) => {
157
+ if (key && typeof key === "object") {
158
+ term.options = key;
159
+ return;
160
+ }
161
+ term.options = { [key]: value };
162
+ };
163
+ }
164
+ const fitAddon = new FitAddon();
165
+ term.loadAddon(fitAddon);
166
+ term.open(terminalContainerRef.current);
167
+ fitAddon.fit();
168
+ term.focus();
169
+ terminalRef.current = term;
170
+ terminalFitRef.current = fitAddon;
171
+ terminalDisposableRef.current = term.onData((data) => {
172
+ const socket = terminalSocketRef.current;
173
+ if (socket && socket.readyState === WebSocket.OPEN) {
174
+ socket.send(JSON.stringify({ type: "input", data }));
175
+ }
176
+ });
177
+ }, [
178
+ activePane,
179
+ terminalContainerRef,
180
+ terminalDisposableRef,
181
+ terminalEnabled,
182
+ terminalFitRef,
183
+ terminalRef,
184
+ terminalSocketRef,
185
+ themeMode,
186
+ ]);
187
+
188
+ useEffect(() => {
189
+ const term = terminalRef.current;
190
+ if (!term) {
191
+ return;
192
+ }
193
+ const theme =
194
+ themeMode === "dark"
195
+ ? {
196
+ background: "#15120d",
197
+ foreground: "#f2e9dc",
198
+ cursor: "#f2e9dc",
199
+ }
200
+ : {
201
+ background: "#fbf6ee",
202
+ foreground: "#2a2418",
203
+ cursor: "#2a2418",
204
+ };
205
+ if (typeof term.setOption === "function") {
206
+ term.setOption("theme", theme);
207
+ } else {
208
+ term.options = { theme };
209
+ }
210
+ }, [terminalRef, themeMode]);
211
+
212
+ useEffect(() => {
213
+ return () => {
214
+ if (terminalDisposableRef.current) {
215
+ terminalDisposableRef.current.dispose();
216
+ terminalDisposableRef.current = null;
217
+ }
218
+ if (terminalRef.current) {
219
+ terminalRef.current.dispose();
220
+ terminalRef.current = null;
221
+ }
222
+ terminalFitRef.current = null;
223
+ };
224
+ }, [terminalDisposableRef, terminalFitRef, terminalRef]);
225
+
226
+ useEffect(() => {
227
+ if (activePane !== "terminal") {
228
+ return;
229
+ }
230
+ if (!terminalEnabled) {
231
+ return;
232
+ }
233
+ if (terminalRef.current) {
234
+ const isDark = themeMode === "dark";
235
+ terminalRef.current.setOption("theme", {
236
+ background: isDark ? "#0f1110" : "#fbf6ee",
237
+ foreground: isDark ? "#e6edf3" : "#2a2418",
238
+ cursor: isDark ? "#e6edf3" : "#2a2418",
239
+ selection: isDark
240
+ ? "rgba(255, 255, 255, 0.2)"
241
+ : "rgba(20, 19, 17, 0.15)",
242
+ });
243
+ }
244
+ if (terminalFitRef.current) {
245
+ requestAnimationFrame(() => {
246
+ const fitAddon = terminalFitRef.current;
247
+ const term = terminalRef.current;
248
+ if (!fitAddon || !term) {
249
+ return;
250
+ }
251
+ fitAddon.fit();
252
+ term.focus();
253
+ const socket = terminalSocketRef.current;
254
+ if (socket && socket.readyState === WebSocket.OPEN) {
255
+ socket.send(
256
+ JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })
257
+ );
258
+ }
259
+ });
260
+ }
261
+ connectTerminal();
262
+ }, [
263
+ activePane,
264
+ connectTerminal,
265
+ terminalEnabled,
266
+ terminalFitRef,
267
+ terminalRef,
268
+ terminalSocketRef,
269
+ themeMode,
270
+ ]);
271
+
272
+ useEffect(() => {
273
+ const handleResize = () => {
274
+ const term = terminalRef.current;
275
+ const fitAddon = terminalFitRef.current;
276
+ const socket = terminalSocketRef.current;
277
+ if (!term || !fitAddon) {
278
+ return;
279
+ }
280
+ fitAddon.fit();
281
+ if (socket && socket.readyState === WebSocket.OPEN) {
282
+ socket.send(
283
+ JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })
284
+ );
285
+ }
286
+ };
287
+ window.addEventListener("resize", handleResize);
288
+ return () => window.removeEventListener("resize", handleResize);
289
+ }, [terminalFitRef, terminalRef, terminalSocketRef]);
290
+
291
+ useEffect(() => {
292
+ if (!attachmentSessionId && terminalSocketRef.current) {
293
+ terminalSocketRef.current.close();
294
+ terminalSocketRef.current = null;
295
+ terminalSessionRef.current = null;
296
+ terminalWorktreeRef.current = null;
297
+ }
298
+ }, [attachmentSessionId, terminalSessionRef, terminalSocketRef, terminalWorktreeRef]);
299
+
300
+ useEffect(() => {
301
+ if (terminalEnabled) {
302
+ return;
303
+ }
304
+ if (terminalSocketRef.current) {
305
+ terminalSocketRef.current.close();
306
+ terminalSocketRef.current = null;
307
+ }
308
+ terminalSessionRef.current = null;
309
+ terminalWorktreeRef.current = null;
310
+ if (terminalDisposableRef.current) {
311
+ terminalDisposableRef.current.dispose();
312
+ terminalDisposableRef.current = null;
313
+ }
314
+ if (terminalRef.current) {
315
+ terminalRef.current.dispose();
316
+ terminalRef.current = null;
317
+ }
318
+ terminalFitRef.current = null;
319
+ }, [
320
+ terminalDisposableRef,
321
+ terminalEnabled,
322
+ terminalFitRef,
323
+ terminalRef,
324
+ terminalSessionRef,
325
+ terminalSocketRef,
326
+ terminalWorktreeRef,
327
+ ]);
328
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ export default function useToolbarExport() {
4
+ const [toolbarExportOpen, setToolbarExportOpen] = useState(false);
5
+ const toolbarExportRef = useRef(null);
6
+
7
+ useEffect(() => {
8
+ if (!toolbarExportOpen) {
9
+ return;
10
+ }
11
+ const handlePointerDown = (event) => {
12
+ const target = event.target;
13
+ if (toolbarExportRef.current?.contains(target)) {
14
+ return;
15
+ }
16
+ setToolbarExportOpen(false);
17
+ };
18
+ document.addEventListener("pointerdown", handlePointerDown);
19
+ return () => document.removeEventListener("pointerdown", handlePointerDown);
20
+ }, [toolbarExportOpen]);
21
+
22
+ return {
23
+ toolbarExportOpen,
24
+ setToolbarExportOpen,
25
+ toolbarExportRef,
26
+ };
27
+ }
@@ -0,0 +1,43 @@
1
+ import { useCallback } from "react";
2
+
3
+ export default function useTurnInterrupt({
4
+ activeWorktreeId,
5
+ isInWorktree,
6
+ currentTurnIdForActive,
7
+ socketRef,
8
+ setWorktrees,
9
+ setActivity,
10
+ }) {
11
+ const interruptTurn = useCallback(() => {
12
+ if (!currentTurnIdForActive || !socketRef.current) {
13
+ return;
14
+ }
15
+ const payload = {
16
+ type: "turn_interrupt",
17
+ turnId: currentTurnIdForActive,
18
+ worktreeId: isInWorktree && activeWorktreeId ? activeWorktreeId : undefined,
19
+ };
20
+ socketRef.current.send(JSON.stringify(payload));
21
+ if (isInWorktree && activeWorktreeId) {
22
+ setWorktrees((current) => {
23
+ const next = new Map(current);
24
+ const wt = next.get(activeWorktreeId);
25
+ if (wt) {
26
+ next.set(activeWorktreeId, { ...wt, activity: "Interruption..." });
27
+ }
28
+ return next;
29
+ });
30
+ return;
31
+ }
32
+ setActivity("Interruption...");
33
+ }, [
34
+ activeWorktreeId,
35
+ currentTurnIdForActive,
36
+ isInWorktree,
37
+ setActivity,
38
+ setWorktrees,
39
+ socketRef,
40
+ ]);
41
+
42
+ return { interruptTurn };
43
+ }
@@ -0,0 +1,128 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export default function useVibe80Forms({
4
+ t,
5
+ choicesKey,
6
+ input,
7
+ setInput,
8
+ handleSendMessageRef,
9
+ draftAttachments,
10
+ setDraftAttachments,
11
+ }) {
12
+ const [choiceSelections, setChoiceSelections] = useState({});
13
+ const [activeForm, setActiveForm] = useState(null);
14
+ const [activeFormValues, setActiveFormValues] = useState({});
15
+
16
+ useEffect(() => {
17
+ if (!choicesKey) {
18
+ setChoiceSelections({});
19
+ return;
20
+ }
21
+ try {
22
+ const stored = JSON.parse(localStorage.getItem(choicesKey) || "{}");
23
+ setChoiceSelections(
24
+ stored && typeof stored === "object" && !Array.isArray(stored)
25
+ ? stored
26
+ : {}
27
+ );
28
+ } catch (error) {
29
+ setChoiceSelections({});
30
+ }
31
+ }, [choicesKey]);
32
+
33
+ useEffect(() => {
34
+ if (!choicesKey) {
35
+ return;
36
+ }
37
+ localStorage.setItem(choicesKey, JSON.stringify(choiceSelections));
38
+ }, [choiceSelections, choicesKey]);
39
+
40
+ const openVibe80Form = useCallback((block, blockKey) => {
41
+ if (!block?.fields?.length) {
42
+ return;
43
+ }
44
+ const defaults = {};
45
+ block.fields.forEach((field) => {
46
+ if (field.type === "checkbox") {
47
+ defaults[field.id] = Boolean(field.defaultChecked);
48
+ } else if (field.type === "radio" || field.type === "select") {
49
+ defaults[field.id] = field.choices?.[0] || "";
50
+ } else {
51
+ defaults[field.id] = field.defaultValue || "";
52
+ }
53
+ });
54
+ setActiveForm({ ...block, key: blockKey });
55
+ setActiveFormValues(defaults);
56
+ }, []);
57
+
58
+ const closeVibe80Form = useCallback(() => {
59
+ setActiveForm(null);
60
+ setActiveFormValues({});
61
+ }, []);
62
+
63
+ const updateActiveFormValue = useCallback((fieldId, value) => {
64
+ setActiveFormValues((current) => ({
65
+ ...current,
66
+ [fieldId]: value,
67
+ }));
68
+ }, []);
69
+
70
+ const sendFormMessage = useCallback(
71
+ (text) => {
72
+ const preservedInput = input;
73
+ const preservedAttachments = draftAttachments;
74
+ handleSendMessageRef.current?.(text, []);
75
+ setInput(preservedInput);
76
+ setDraftAttachments(preservedAttachments);
77
+ },
78
+ [draftAttachments, handleSendMessageRef, input, setDraftAttachments, setInput]
79
+ );
80
+
81
+ const submitActiveForm = useCallback(
82
+ (event) => {
83
+ event?.preventDefault();
84
+ if (!activeForm) {
85
+ return;
86
+ }
87
+ const lines = activeForm.fields.map((field) => {
88
+ let value = activeFormValues[field.id];
89
+ if (field.type === "checkbox") {
90
+ value = value ? "1" : "0";
91
+ }
92
+ if (value === undefined || value === null) {
93
+ value = "";
94
+ }
95
+ return `${field.id}=${value}`;
96
+ });
97
+ sendFormMessage(lines.join("\n"));
98
+ closeVibe80Form();
99
+ },
100
+ [activeForm, activeFormValues, closeVibe80Form, sendFormMessage]
101
+ );
102
+
103
+ const handleChoiceClick = useCallback(
104
+ (choice, blockKey, choiceIndex) => {
105
+ setChoiceSelections((prev) => ({
106
+ ...prev,
107
+ [blockKey]: choiceIndex,
108
+ }));
109
+ setInput(choice);
110
+ handleSendMessageRef.current?.(choice);
111
+ },
112
+ [handleSendMessageRef, setInput]
113
+ );
114
+
115
+ return {
116
+ choiceSelections,
117
+ setChoiceSelections,
118
+ activeForm,
119
+ activeFormValues,
120
+ openVibe80Form,
121
+ closeVibe80Form,
122
+ updateActiveFormValue,
123
+ submitActiveForm,
124
+ handleChoiceClick,
125
+ setActiveForm,
126
+ setActiveFormValues,
127
+ };
128
+ }