@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,109 @@
1
+ import React from "react";
2
+ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3
+ import {
4
+ faBroom,
5
+ faCodeCompare,
6
+ faComments,
7
+ faFolderTree,
8
+ faTerminal,
9
+ } from "@fortawesome/free-solid-svg-icons";
10
+
11
+ export default function ChatToolbar({
12
+ t,
13
+ activePane,
14
+ handleViewSelect,
15
+ handleDiffSelect,
16
+ terminalEnabled,
17
+ hasMessages,
18
+ handleClearChat,
19
+ }) {
20
+ if (activePane === "settings") {
21
+ return null;
22
+ }
23
+
24
+ return (
25
+ <div className="chat-toolbar" role="toolbar" aria-label={t("Chat tools")}>
26
+ <div className="chat-toolbar-group">
27
+ <button
28
+ type="button"
29
+ className={`chat-toolbar-button ${activePane === "chat" ? "is-active" : ""}`}
30
+ onClick={() => handleViewSelect("chat")}
31
+ aria-pressed={activePane === "chat"}
32
+ aria-label={t("Messages")}
33
+ title={t("Messages")}
34
+ >
35
+ <span className="chat-toolbar-icon-wrap" aria-hidden="true">
36
+ <span className="chat-toolbar-icon">
37
+ <FontAwesomeIcon icon={faComments} />
38
+ </span>
39
+ </span>
40
+ <span className="chat-toolbar-label">{t("Messages")}</span>
41
+ </button>
42
+ <button
43
+ type="button"
44
+ className={`chat-toolbar-button ${activePane === "diff" ? "is-active" : ""}`}
45
+ onClick={handleDiffSelect}
46
+ aria-pressed={activePane === "diff"}
47
+ aria-label={t("Diff")}
48
+ title={t("Diff")}
49
+ >
50
+ <span className="chat-toolbar-icon-wrap" aria-hidden="true">
51
+ <span className="chat-toolbar-icon">
52
+ <FontAwesomeIcon icon={faCodeCompare} />
53
+ </span>
54
+ </span>
55
+ <span className="chat-toolbar-label">{t("Diff")}</span>
56
+ </button>
57
+ <button
58
+ type="button"
59
+ className={`chat-toolbar-button ${activePane === "explorer" ? "is-active" : ""}`}
60
+ onClick={() => handleViewSelect("explorer")}
61
+ aria-pressed={activePane === "explorer"}
62
+ aria-label={t("Explorer")}
63
+ title={t("Explorer")}
64
+ >
65
+ <span className="chat-toolbar-icon-wrap" aria-hidden="true">
66
+ <span className="chat-toolbar-icon">
67
+ <FontAwesomeIcon icon={faFolderTree} />
68
+ </span>
69
+ </span>
70
+ <span className="chat-toolbar-label">{t("Explorer")}</span>
71
+ </button>
72
+ <button
73
+ type="button"
74
+ className={`chat-toolbar-button ${activePane === "terminal" ? "is-active" : ""}`}
75
+ onClick={() => handleViewSelect("terminal")}
76
+ aria-pressed={activePane === "terminal"}
77
+ aria-label={t("Terminal")}
78
+ title={t("Terminal")}
79
+ disabled={!terminalEnabled}
80
+ >
81
+ <span className="chat-toolbar-icon-wrap" aria-hidden="true">
82
+ <span className="chat-toolbar-icon">
83
+ <FontAwesomeIcon icon={faTerminal} />
84
+ </span>
85
+ </span>
86
+ <span className="chat-toolbar-label">{t("Terminal")}</span>
87
+ </button>
88
+ </div>
89
+ <div className="chat-toolbar-divider" />
90
+ <div className="chat-toolbar-group">
91
+ <button
92
+ type="button"
93
+ className="chat-toolbar-button is-danger"
94
+ onClick={() => handleClearChat()}
95
+ aria-label={t("Clear")}
96
+ title={t("Clear")}
97
+ disabled={!hasMessages}
98
+ >
99
+ <span className="chat-toolbar-icon-wrap" aria-hidden="true">
100
+ <span className="chat-toolbar-icon" aria-hidden="true">
101
+ <FontAwesomeIcon icon={faBroom} />
102
+ </span>
103
+ </span>
104
+ <span className="chat-toolbar-label">{t("Clear")}</span>
105
+ </button>
106
+ </div>
107
+ </div>
108
+ );
109
+ }
@@ -0,0 +1,462 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ export default function useChatComposer({
4
+ t,
5
+ input,
6
+ setInput,
7
+ inputRef,
8
+ handleSendMessageRef,
9
+ attachmentSession,
10
+ apiFetch,
11
+ normalizeAttachments,
12
+ setDraftAttachments,
13
+ draftAttachments,
14
+ setAttachmentsLoading,
15
+ setAttachmentsError,
16
+ showToast,
17
+ uploadInputRef,
18
+ attachmentsLoading,
19
+ conversationRef,
20
+ composerRef,
21
+ isMobileLayout,
22
+ }) {
23
+ const [commandMenuOpen, setCommandMenuOpen] = useState(false);
24
+ const [commandQuery, setCommandQuery] = useState("");
25
+ const [commandSelection, setCommandSelection] = useState(null);
26
+ const [isDraggingAttachments, setIsDraggingAttachments] = useState(false);
27
+ const dragCounterRef = useRef(0);
28
+
29
+ const commandOptions = useMemo(
30
+ () => [
31
+ {
32
+ id: "todo",
33
+ label: "/todo",
34
+ description: t("Add to backlog"),
35
+ insert: "/todo ",
36
+ },
37
+ {
38
+ id: "backlog",
39
+ label: "/backlog",
40
+ description: t("Show backlog"),
41
+ insert: "/backlog",
42
+ },
43
+ {
44
+ id: "open",
45
+ label: "/open",
46
+ description: t("Open path"),
47
+ insert: "/open ",
48
+ },
49
+ {
50
+ id: "run",
51
+ label: "/run",
52
+ description: t("Run shell command"),
53
+ insert: "/run ",
54
+ },
55
+ {
56
+ id: "screenshot",
57
+ label: "/screenshot",
58
+ description: t("Capture screenshot"),
59
+ insert: "/screenshot",
60
+ },
61
+ {
62
+ id: "git",
63
+ label: "/git",
64
+ description: t("Run git command"),
65
+ insert: "/git ",
66
+ },
67
+ {
68
+ id: "diff",
69
+ label: "/diff",
70
+ description: t("Open diff view"),
71
+ insert: "/diff",
72
+ },
73
+ {
74
+ id: "annotation",
75
+ label: "/annotation",
76
+ description: t("Toggle annotation mode (EXPERIMENTAL)"),
77
+ insert: "/annotation on",
78
+ },
79
+ ],
80
+ [t]
81
+ );
82
+
83
+ const filteredCommands = useMemo(() => {
84
+ const query = commandQuery.trim().toLowerCase();
85
+ if (!query) {
86
+ return commandOptions;
87
+ }
88
+ return commandOptions.filter((cmd) =>
89
+ cmd.label.toLowerCase().includes(query)
90
+ );
91
+ }, [commandOptions, commandQuery]);
92
+
93
+ useEffect(() => {
94
+ if (!commandMenuOpen) {
95
+ setCommandSelection(null);
96
+ return;
97
+ }
98
+ if (!filteredCommands.length) {
99
+ setCommandSelection(null);
100
+ return;
101
+ }
102
+ setCommandSelection((current) =>
103
+ filteredCommands.some((cmd) => cmd.id === current)
104
+ ? current
105
+ : filteredCommands[0].id
106
+ );
107
+ }, [commandMenuOpen, filteredCommands]);
108
+
109
+ const handleInputChange = useCallback(
110
+ (event) => {
111
+ const { value } = event.target;
112
+ setInput(value);
113
+ if (value.startsWith("/") && !value.includes(" ")) {
114
+ setCommandMenuOpen(true);
115
+ setCommandQuery(value.slice(1));
116
+ } else {
117
+ setCommandMenuOpen(false);
118
+ setCommandQuery("");
119
+ }
120
+ if (!inputRef.current) {
121
+ return;
122
+ }
123
+ inputRef.current.style.height = "auto";
124
+ inputRef.current.style.height = `${inputRef.current.scrollHeight}px`;
125
+ },
126
+ [inputRef, setInput]
127
+ );
128
+
129
+ const handleComposerKeyDown = useCallback(
130
+ (event) => {
131
+ if (commandMenuOpen) {
132
+ if (event.key === "Escape") {
133
+ event.preventDefault();
134
+ setCommandMenuOpen(false);
135
+ return;
136
+ }
137
+ if (event.key === "ArrowDown") {
138
+ event.preventDefault();
139
+ if (!filteredCommands.length) {
140
+ return;
141
+ }
142
+ const index = filteredCommands.findIndex(
143
+ (cmd) => cmd.id === commandSelection
144
+ );
145
+ const nextIndex =
146
+ index === -1 || index === filteredCommands.length - 1
147
+ ? 0
148
+ : index + 1;
149
+ setCommandSelection(filteredCommands[nextIndex].id);
150
+ return;
151
+ }
152
+ if (event.key === "ArrowUp") {
153
+ event.preventDefault();
154
+ if (!filteredCommands.length) {
155
+ return;
156
+ }
157
+ const index = filteredCommands.findIndex(
158
+ (cmd) => cmd.id === commandSelection
159
+ );
160
+ const nextIndex =
161
+ index <= 0 ? filteredCommands.length - 1 : index - 1;
162
+ setCommandSelection(filteredCommands[nextIndex].id);
163
+ return;
164
+ }
165
+ if (event.key === "Enter" && !event.shiftKey) {
166
+ if (commandSelection) {
167
+ event.preventDefault();
168
+ const selected = filteredCommands.find(
169
+ (cmd) => cmd.id === commandSelection
170
+ );
171
+ if (selected) {
172
+ setInput(selected.insert);
173
+ setCommandMenuOpen(false);
174
+ setCommandQuery("");
175
+ inputRef.current?.focus();
176
+ }
177
+ return;
178
+ }
179
+ }
180
+ }
181
+ if (event.isComposing) {
182
+ return;
183
+ }
184
+ if (event.key === "Enter" && !event.shiftKey) {
185
+ event.preventDefault();
186
+ handleSendMessageRef.current?.();
187
+ }
188
+ },
189
+ [
190
+ commandMenuOpen,
191
+ filteredCommands,
192
+ commandSelection,
193
+ handleSendMessageRef,
194
+ inputRef,
195
+ setInput,
196
+ ]
197
+ );
198
+
199
+ useEffect(() => {
200
+ if (!inputRef.current) {
201
+ return;
202
+ }
203
+ inputRef.current.style.height = "auto";
204
+ inputRef.current.style.height = `${inputRef.current.scrollHeight}px`;
205
+ }, [input, isMobileLayout, inputRef]);
206
+
207
+ useEffect(() => {
208
+ if (!conversationRef.current || !composerRef.current) {
209
+ return undefined;
210
+ }
211
+ const updateComposerSpace = () => {
212
+ if (!conversationRef.current || !composerRef.current) {
213
+ return;
214
+ }
215
+ const rect = composerRef.current.getBoundingClientRect();
216
+ const extra = isMobileLayout ? 12 : 16;
217
+ conversationRef.current.style.setProperty(
218
+ "--composer-space",
219
+ `${Math.ceil(rect.height + extra)}px`
220
+ );
221
+ };
222
+ updateComposerSpace();
223
+ let observer;
224
+ if (typeof ResizeObserver !== "undefined") {
225
+ observer = new ResizeObserver(updateComposerSpace);
226
+ observer.observe(composerRef.current);
227
+ }
228
+ window.addEventListener("resize", updateComposerSpace);
229
+ return () => {
230
+ window.removeEventListener("resize", updateComposerSpace);
231
+ if (observer) {
232
+ observer.disconnect();
233
+ }
234
+ };
235
+ }, [isMobileLayout, draftAttachments.length, conversationRef, composerRef]);
236
+
237
+ const uploadFiles = useCallback(
238
+ async (files) => {
239
+ if (!files.length || !attachmentSession?.sessionId) {
240
+ return;
241
+ }
242
+ try {
243
+ setAttachmentsLoading(true);
244
+ setAttachmentsError("");
245
+ const formData = new FormData();
246
+ files.forEach((file) => formData.append("files", file));
247
+ const response = await apiFetch(
248
+ `/api/v1/sessions/${encodeURIComponent(
249
+ attachmentSession.sessionId
250
+ )}/attachments/upload`,
251
+ {
252
+ method: "POST",
253
+ body: formData,
254
+ }
255
+ );
256
+ if (!response.ok) {
257
+ throw new Error("Upload failed.");
258
+ }
259
+ const data = await response.json();
260
+ const uploaded = normalizeAttachments(data.files || []);
261
+ setDraftAttachments((current) => [...current, ...uploaded]);
262
+ } catch (error) {
263
+ setAttachmentsError(error.message || t("Unable to upload attachments."));
264
+ } finally {
265
+ setAttachmentsLoading(false);
266
+ }
267
+ },
268
+ [
269
+ apiFetch,
270
+ attachmentSession?.sessionId,
271
+ normalizeAttachments,
272
+ setAttachmentsError,
273
+ setAttachmentsLoading,
274
+ setDraftAttachments,
275
+ t,
276
+ ]
277
+ );
278
+
279
+ const captureScreenshot = useCallback(async () => {
280
+ if (!attachmentSession?.sessionId) {
281
+ showToast(t("Session not found."), "error");
282
+ return;
283
+ }
284
+ if (!navigator.mediaDevices?.getDisplayMedia) {
285
+ showToast(t("Screenshot failed."), "error");
286
+ return;
287
+ }
288
+ try {
289
+ const stream = await navigator.mediaDevices.getDisplayMedia({
290
+ video: { cursor: "never" },
291
+ audio: false,
292
+ });
293
+ const video = document.createElement("video");
294
+ video.srcObject = stream;
295
+ await new Promise((resolve) => {
296
+ video.onloadedmetadata = () => resolve();
297
+ });
298
+ await video.play();
299
+ const canvas = document.createElement("canvas");
300
+ canvas.width = video.videoWidth || 1;
301
+ canvas.height = video.videoHeight || 1;
302
+ const ctx = canvas.getContext("2d");
303
+ if (!ctx) {
304
+ throw new Error("Canvas unavailable");
305
+ }
306
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
307
+ stream.getTracks().forEach((track) => track.stop());
308
+ const blob = await new Promise((resolve, reject) => {
309
+ canvas.toBlob(
310
+ (result) =>
311
+ result ? resolve(result) : reject(new Error("Blob failed")),
312
+ "image/png"
313
+ );
314
+ });
315
+ const filename = `screenshot-${Date.now()}.png`;
316
+ const file = new File([blob], filename, { type: "image/png" });
317
+ await uploadFiles([file]);
318
+ showToast(t("Screenshot captured."));
319
+ } catch (error) {
320
+ showToast(error?.message || t("Screenshot failed."), "error");
321
+ }
322
+ }, [attachmentSession?.sessionId, showToast, t, uploadFiles]);
323
+
324
+ const onUploadAttachments = useCallback(
325
+ async (event) => {
326
+ const files = Array.from(event.target.files || []);
327
+ await uploadFiles(files);
328
+ event.target.value = "";
329
+ },
330
+ [uploadFiles]
331
+ );
332
+
333
+ const onPasteAttachments = useCallback(
334
+ async (event) => {
335
+ if (!attachmentSession?.sessionId) {
336
+ return;
337
+ }
338
+ const items = Array.from(event.clipboardData?.items || []);
339
+ const files = items
340
+ .filter((item) => item.kind === "file")
341
+ .map((item) => item.getAsFile())
342
+ .filter(Boolean);
343
+ if (!files.length) {
344
+ return;
345
+ }
346
+ event.preventDefault();
347
+ await uploadFiles(files);
348
+ },
349
+ [attachmentSession?.sessionId, uploadFiles]
350
+ );
351
+
352
+ const onDragOverComposer = useCallback(
353
+ (event) => {
354
+ if (!attachmentSession?.sessionId) {
355
+ return;
356
+ }
357
+ if (event.dataTransfer?.types?.includes("Files")) {
358
+ event.preventDefault();
359
+ }
360
+ },
361
+ [attachmentSession?.sessionId]
362
+ );
363
+
364
+ const onDragEnterComposer = useCallback(
365
+ (event) => {
366
+ if (!attachmentSession?.sessionId) {
367
+ return;
368
+ }
369
+ if (event.dataTransfer?.types?.includes("Files")) {
370
+ event.preventDefault();
371
+ dragCounterRef.current += 1;
372
+ setIsDraggingAttachments(true);
373
+ }
374
+ },
375
+ [attachmentSession?.sessionId]
376
+ );
377
+
378
+ const onDragLeaveComposer = useCallback(
379
+ (event) => {
380
+ if (!attachmentSession?.sessionId) {
381
+ return;
382
+ }
383
+ if (event.dataTransfer?.types?.includes("Files")) {
384
+ event.preventDefault();
385
+ dragCounterRef.current = Math.max(0, dragCounterRef.current - 1);
386
+ if (dragCounterRef.current === 0) {
387
+ setIsDraggingAttachments(false);
388
+ }
389
+ }
390
+ },
391
+ [attachmentSession?.sessionId]
392
+ );
393
+
394
+ const onDropAttachments = useCallback(
395
+ async (event) => {
396
+ if (!attachmentSession?.sessionId) {
397
+ return;
398
+ }
399
+ event.preventDefault();
400
+ event.stopPropagation();
401
+ dragCounterRef.current = 0;
402
+ setIsDraggingAttachments(false);
403
+ const files = Array.from(event.dataTransfer?.files || []);
404
+ if (!files.length) {
405
+ return;
406
+ }
407
+ await uploadFiles(files);
408
+ },
409
+ [attachmentSession?.sessionId, uploadFiles]
410
+ );
411
+
412
+ const removeDraftAttachment = useCallback(
413
+ (identifier) => {
414
+ if (!identifier) {
415
+ return;
416
+ }
417
+ setDraftAttachments((current) =>
418
+ current.filter((item) => {
419
+ const key = item?.path || item?.name;
420
+ return key !== identifier;
421
+ })
422
+ );
423
+ },
424
+ [setDraftAttachments]
425
+ );
426
+
427
+ const triggerAttachmentPicker = useCallback(() => {
428
+ if (!attachmentSession || attachmentsLoading) {
429
+ return;
430
+ }
431
+ requestAnimationFrame(() => {
432
+ uploadInputRef.current?.click();
433
+ });
434
+ }, [attachmentSession, attachmentsLoading, uploadInputRef]);
435
+
436
+ const onSubmit = useCallback((event) => {
437
+ event.preventDefault();
438
+ handleSendMessageRef.current?.();
439
+ }, [handleSendMessageRef]);
440
+
441
+ return {
442
+ commandMenuOpen,
443
+ setCommandMenuOpen,
444
+ commandQuery,
445
+ setCommandQuery,
446
+ commandSelection,
447
+ filteredCommands,
448
+ isDraggingAttachments,
449
+ handleInputChange,
450
+ handleComposerKeyDown,
451
+ onSubmit,
452
+ onUploadAttachments,
453
+ onPasteAttachments,
454
+ onDragOverComposer,
455
+ onDragEnterComposer,
456
+ onDragLeaveComposer,
457
+ onDropAttachments,
458
+ removeDraftAttachment,
459
+ triggerAttachmentPicker,
460
+ captureScreenshot,
461
+ };
462
+ }
@@ -0,0 +1,129 @@
1
+ import React from "react";
2
+ import { Diff, Hunk } from "react-diff-view";
3
+
4
+ export default function DiffPanel({
5
+ t,
6
+ activePane,
7
+ isInWorktree,
8
+ diffStatusLines,
9
+ connected,
10
+ currentProcessing,
11
+ hasCurrentChanges,
12
+ sendCommitMessage,
13
+ diffFiles,
14
+ currentDiff,
15
+ untrackedFilePanels,
16
+ untrackedLoading,
17
+ }) {
18
+ const hasUntrackedPanels = Array.isArray(untrackedFilePanels) && untrackedFilePanels.length > 0;
19
+ const renderUntrackedText = (content) => {
20
+ const lines = String(content || "").split(/\r?\n/);
21
+ return (
22
+ <table className="diff-view untracked-diff-view">
23
+ <tbody>
24
+ {lines.map((line, index) => (
25
+ <tr key={`line-${index}`} className="diff-line diff-line-add">
26
+ <td className="diff-gutter diff-gutter-added">
27
+ <span className="diff-line-number">{index + 1}</span>
28
+ </td>
29
+ <td className="diff-code diff-code-added">
30
+ {line.length > 0 ? line : "\u00a0"}
31
+ </td>
32
+ </tr>
33
+ ))}
34
+ </tbody>
35
+ </table>
36
+ );
37
+ };
38
+
39
+ return (
40
+ <div className={`diff-panel ${activePane === "diff" ? "" : "is-hidden"}`}>
41
+ <div className="diff-header">
42
+ <div className="diff-title">
43
+ {isInWorktree ? t("Worktree diff") : t("Repository diff")}
44
+ </div>
45
+ {diffStatusLines.length > 0 && (
46
+ <div className="diff-count">
47
+ {t("{{count}} files modified", {
48
+ count: diffStatusLines.length,
49
+ })}
50
+ </div>
51
+ )}
52
+ <div className="diff-actions">
53
+ <button
54
+ type="button"
55
+ className="diff-action-button"
56
+ onClick={() => sendCommitMessage("Commit")}
57
+ disabled={!connected || currentProcessing || !hasCurrentChanges}
58
+ title={t("Send 'Commit' in chat")}
59
+ >
60
+ {t("Commit")}
61
+ </button>
62
+ <button
63
+ type="button"
64
+ className="diff-action-button primary"
65
+ onClick={() => sendCommitMessage("Commit & Push")}
66
+ disabled={!connected || currentProcessing || !hasCurrentChanges}
67
+ title={t("Send 'Commit & Push' in chat")}
68
+ >
69
+ {t("Commit & Push")}
70
+ </button>
71
+ </div>
72
+ </div>
73
+ {diffStatusLines.length > 0 && (
74
+ <div className="diff-status">
75
+ {diffStatusLines.map((line, index) => (
76
+ <div key={`${line}-${index}`}>{line}</div>
77
+ ))}
78
+ </div>
79
+ )}
80
+ {diffFiles.length > 0 || hasUntrackedPanels ? (
81
+ <div className="diff-body">
82
+ {diffFiles.map((file) => {
83
+ const fileLabel = file.newPath || file.oldPath || t("Diff");
84
+ return (
85
+ <div
86
+ key={`${file.oldPath}-${file.newPath}-${file.type}`}
87
+ className="diff-file"
88
+ >
89
+ <div className="diff-file-header">{fileLabel}</div>
90
+ <Diff viewType="unified" diffType={file.type} hunks={file.hunks}>
91
+ {(hunks) =>
92
+ hunks.map((hunk) => (
93
+ <Hunk key={hunk.content} hunk={hunk} />
94
+ ))
95
+ }
96
+ </Diff>
97
+ </div>
98
+ );
99
+ })}
100
+ {hasUntrackedPanels &&
101
+ untrackedFilePanels.map((panel) => (
102
+ <div key={`untracked-${panel.path}`} className="diff-file">
103
+ <div className="diff-file-header">{`?? ${panel.path}`}</div>
104
+ {panel.error ? (
105
+ <pre className="diff-fallback">{t("Unable to load file.")}</pre>
106
+ ) : panel.binary ? (
107
+ <pre className="diff-fallback">{t("binary data")}</pre>
108
+ ) : (
109
+ <>
110
+ {renderUntrackedText(panel.content || "")}
111
+ {panel.truncated && (
112
+ <div className="diff-file-note">{t("File truncated for display.")}</div>
113
+ )}
114
+ </>
115
+ )}
116
+ </div>
117
+ ))}
118
+ {untrackedLoading && (
119
+ <div className="diff-file-note">{t("Loading untracked files...")}</div>
120
+ )}
121
+ </div>
122
+ ) : currentDiff.diff ? (
123
+ <pre className="diff-fallback">{currentDiff.diff}</pre>
124
+ ) : (
125
+ <div className="diff-empty">{t("No changes detected.")}</div>
126
+ )}
127
+ </div>
128
+ );
129
+ }