@chrysb/alphaclaw 0.3.5-beta.1 → 0.4.0

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 (37) hide show
  1. package/bin/alphaclaw.js +1 -31
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +53 -0
  4. package/lib/public/js/app.js +126 -105
  5. package/lib/public/js/components/credentials-modal.js +36 -8
  6. package/lib/public/js/components/file-tree.js +212 -22
  7. package/lib/public/js/components/file-viewer/index.js +44 -6
  8. package/lib/public/js/components/file-viewer/status-banners.js +11 -6
  9. package/lib/public/js/components/file-viewer/toolbar.js +43 -1
  10. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
  11. package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
  12. package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
  13. package/lib/public/js/components/file-viewer/use-file-viewer.js +94 -2
  14. package/lib/public/js/components/google/account-row.js +98 -0
  15. package/lib/public/js/components/google/add-account-modal.js +93 -0
  16. package/lib/public/js/components/google/index.js +439 -0
  17. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  18. package/lib/public/js/components/icons.js +26 -0
  19. package/lib/public/js/components/sidebar-git-panel.js +43 -14
  20. package/lib/public/js/components/sidebar.js +91 -75
  21. package/lib/public/js/lib/api.js +72 -8
  22. package/lib/public/js/lib/browse-file-policies.js +29 -11
  23. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  24. package/lib/public/shared/browse-file-policies.json +13 -0
  25. package/lib/server/constants.js +19 -7
  26. package/lib/server/google-state.js +187 -0
  27. package/lib/server/helpers.js +12 -4
  28. package/lib/server/onboarding/github.js +21 -2
  29. package/lib/server/onboarding/index.js +1 -3
  30. package/lib/server/onboarding/openclaw.js +3 -0
  31. package/lib/server/onboarding/workspace.js +40 -0
  32. package/lib/server/routes/browse/index.js +90 -2
  33. package/lib/server/routes/google.js +414 -213
  34. package/lib/setup/gitignore +3 -0
  35. package/lib/setup/hourly-git-sync.sh +28 -1
  36. package/package.json +1 -1
  37. package/lib/public/js/components/google.js +0 -228
@@ -0,0 +1,439 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
+ import htm from "https://esm.sh/htm";
4
+ import {
5
+ checkGoogleApis,
6
+ disconnectGoogle,
7
+ saveGoogleAccount,
8
+ } from "../../lib/api.js";
9
+ import { getDefaultScopes, toggleScopeLogic } from "../scope-picker.js";
10
+ import { CredentialsModal } from "../credentials-modal.js";
11
+ import { ConfirmDialog } from "../confirm-dialog.js";
12
+ import { showToast } from "../toast.js";
13
+ import { PageHeader } from "../page-header.js";
14
+ import { ActionButton } from "../action-button.js";
15
+ import { GoogleAccountRow } from "./account-row.js";
16
+ import { AddGoogleAccountModal } from "./add-account-modal.js";
17
+ import { useGoogleAccounts } from "./use-google-accounts.js";
18
+
19
+ const html = htm.bind(h);
20
+
21
+ const hasScopesChanged = (nextScopes = [], savedScopes = []) =>
22
+ nextScopes.length !== savedScopes.length ||
23
+ nextScopes.some((scope) => !savedScopes.includes(scope));
24
+
25
+ const isPersonalAccount = (account = {}) => Boolean(account.personal);
26
+
27
+ const kGoogleIconPath = "/assets/icons/google_icon.svg";
28
+
29
+ export const Google = ({ gatewayStatus }) => {
30
+ const {
31
+ accounts,
32
+ loading,
33
+ hasCompanyCredentials,
34
+ refreshAccounts,
35
+ } = useGoogleAccounts({ gatewayStatus });
36
+ const [expandedAccountId, setExpandedAccountId] = useState("");
37
+ const [scopesByAccountId, setScopesByAccountId] = useState({});
38
+ const [savedScopesByAccountId, setSavedScopesByAccountId] = useState({});
39
+ const [apiStatusByAccountId, setApiStatusByAccountId] = useState({});
40
+ const [checkingByAccountId, setCheckingByAccountId] = useState({});
41
+ const [addMenuOpen, setAddMenuOpen] = useState(false);
42
+ const [credentialsModalState, setCredentialsModalState] = useState({
43
+ visible: false,
44
+ accountId: "",
45
+ client: "default",
46
+ personal: false,
47
+ title: "Connect Google Workspace",
48
+ submitLabel: "Connect Google",
49
+ defaultInstrType: "workspace",
50
+ initialValues: {},
51
+ });
52
+ const [addCompanyModalOpen, setAddCompanyModalOpen] = useState(false);
53
+ const [savingAddCompany, setSavingAddCompany] = useState(false);
54
+ const [disconnectAccountId, setDisconnectAccountId] = useState("");
55
+
56
+ const hasPersonalAccount = useMemo(
57
+ () => accounts.some((account) => isPersonalAccount(account)),
58
+ [accounts],
59
+ );
60
+ const hasCompanyAccount = useMemo(
61
+ () => accounts.some((account) => !isPersonalAccount(account)),
62
+ [accounts],
63
+ );
64
+
65
+ const getAccountById = useCallback(
66
+ (accountId) => accounts.find((account) => account.id === accountId) || null,
67
+ [accounts],
68
+ );
69
+
70
+ const ensureScopesForAccount = useCallback((account) => {
71
+ const nextScopes = Array.isArray(account.activeScopes) && account.activeScopes.length
72
+ ? account.activeScopes
73
+ : Array.isArray(account.services) && account.services.length
74
+ ? account.services
75
+ : getDefaultScopes();
76
+ setSavedScopesByAccountId((prev) => ({ ...prev, [account.id]: [...nextScopes] }));
77
+ setScopesByAccountId((prev) => {
78
+ const current = prev[account.id];
79
+ if (!current || !hasScopesChanged(current, nextScopes)) {
80
+ return { ...prev, [account.id]: [...nextScopes] };
81
+ }
82
+ return prev;
83
+ });
84
+ }, []);
85
+
86
+ useEffect(() => {
87
+ if (!accounts.length) {
88
+ setExpandedAccountId("");
89
+ return;
90
+ }
91
+ const firstAwaitingSignInId =
92
+ accounts.find((account) => !account.authenticated)?.id || "";
93
+ setExpandedAccountId((previousId) => {
94
+ if (previousId && accounts.some((account) => account.id === previousId)) {
95
+ return previousId;
96
+ }
97
+ return firstAwaitingSignInId;
98
+ });
99
+ accounts.forEach((account) => ensureScopesForAccount(account));
100
+ }, [accounts, ensureScopesForAccount]);
101
+
102
+ const startAuth = useCallback(
103
+ (accountId) => {
104
+ const account = getAccountById(accountId);
105
+ if (!account) return;
106
+ const scopes = scopesByAccountId[accountId] || account.activeScopes || getDefaultScopes();
107
+ if (!scopes.length) {
108
+ window.alert("Select at least one service");
109
+ return;
110
+ }
111
+ const authUrl =
112
+ `/auth/google/start?accountId=${encodeURIComponent(accountId)}` +
113
+ `&services=${encodeURIComponent(scopes.join(","))}&_ts=${Date.now()}`;
114
+ const popup = window.open(
115
+ authUrl,
116
+ `google-auth-${accountId}`,
117
+ "popup=yes,width=500,height=700",
118
+ );
119
+ if (!popup || popup.closed) window.location.href = authUrl;
120
+ },
121
+ [getAccountById, scopesByAccountId],
122
+ );
123
+
124
+ const handleToggleScope = (accountId, scope) => {
125
+ setScopesByAccountId((prev) => ({
126
+ ...prev,
127
+ [accountId]: toggleScopeLogic(prev[accountId] || [], scope),
128
+ }));
129
+ };
130
+
131
+ const handleCheckApis = useCallback(async (accountId) => {
132
+ setApiStatusByAccountId((prev) => {
133
+ const next = { ...prev };
134
+ delete next[accountId];
135
+ return next;
136
+ });
137
+ setCheckingByAccountId({ [accountId]: true });
138
+ try {
139
+ const data = await checkGoogleApis(accountId);
140
+ if (data.results) {
141
+ setApiStatusByAccountId((prev) => ({ ...prev, [accountId]: data.results }));
142
+ }
143
+ } finally {
144
+ setCheckingByAccountId((prev) => {
145
+ if (!prev[accountId]) return prev;
146
+ const next = { ...prev };
147
+ delete next[accountId];
148
+ return next;
149
+ });
150
+ }
151
+ }, []);
152
+
153
+ useEffect(() => {
154
+ const handler = async (event) => {
155
+ if (event.data?.google === "success") {
156
+ showToast("✓ Google account connected", "success");
157
+ const accountId = String(event.data?.accountId || "").trim();
158
+ setApiStatusByAccountId({});
159
+ await refreshAccounts();
160
+ if (accountId) {
161
+ await handleCheckApis(accountId);
162
+ }
163
+ } else if (event.data?.google === "error") {
164
+ showToast(`✗ Google auth failed: ${event.data.message || "unknown"}`, "error");
165
+ }
166
+ };
167
+ window.addEventListener("message", handler);
168
+ return () => window.removeEventListener("message", handler);
169
+ }, [handleCheckApis, refreshAccounts]);
170
+
171
+ useEffect(() => {
172
+ if (!expandedAccountId) return;
173
+ const account = getAccountById(expandedAccountId);
174
+ if (!account?.authenticated) return;
175
+ if (checkingByAccountId[expandedAccountId]) return;
176
+ if (apiStatusByAccountId[expandedAccountId]) return;
177
+ handleCheckApis(expandedAccountId);
178
+ }, [
179
+ accounts,
180
+ apiStatusByAccountId,
181
+ checkingByAccountId,
182
+ expandedAccountId,
183
+ getAccountById,
184
+ handleCheckApis,
185
+ ]);
186
+
187
+ const handleDisconnect = async (accountId) => {
188
+ const data = await disconnectGoogle(accountId);
189
+ if (!data.ok) {
190
+ showToast(`Failed to disconnect: ${data.error || "unknown"}`, "error");
191
+ return;
192
+ }
193
+ showToast("Google account disconnected", "success");
194
+ setApiStatusByAccountId((prev) => {
195
+ const next = { ...prev };
196
+ delete next[accountId];
197
+ return next;
198
+ });
199
+ await refreshAccounts();
200
+ };
201
+
202
+ const openCredentialsModal = ({
203
+ accountId = "",
204
+ client = "default",
205
+ personal = false,
206
+ title = "Connect Google Workspace",
207
+ submitLabel = "Connect Google",
208
+ defaultInstrType = personal ? "personal" : "workspace",
209
+ initialValues = {},
210
+ }) => {
211
+ setCredentialsModalState({
212
+ visible: true,
213
+ accountId,
214
+ client,
215
+ personal,
216
+ title,
217
+ submitLabel,
218
+ defaultInstrType,
219
+ initialValues,
220
+ });
221
+ };
222
+
223
+ const closeCredentialsModal = () => {
224
+ setCredentialsModalState((prev) => ({ ...prev, visible: false }));
225
+ };
226
+
227
+ const handleCredentialsSaved = async (account) => {
228
+ await refreshAccounts();
229
+ if (account?.id) startAuth(account.id);
230
+ };
231
+
232
+ const handleAddCompanyAccount = async ({ email, setError }) => {
233
+ setSavingAddCompany(true);
234
+ try {
235
+ const data = await saveGoogleAccount({
236
+ email,
237
+ client: "default",
238
+ personal: false,
239
+ services: getDefaultScopes(),
240
+ });
241
+ if (!data.ok) {
242
+ setError?.(data.error || "Could not add account");
243
+ return;
244
+ }
245
+ setAddCompanyModalOpen(false);
246
+ await refreshAccounts();
247
+ if (data.accountId) startAuth(data.accountId);
248
+ } finally {
249
+ setSavingAddCompany(false);
250
+ }
251
+ };
252
+
253
+ const handleAddCompanyClick = () => {
254
+ setAddMenuOpen(false);
255
+ if (hasCompanyAccount && hasCompanyCredentials) {
256
+ setAddCompanyModalOpen(true);
257
+ return;
258
+ }
259
+ openCredentialsModal({
260
+ client: "default",
261
+ personal: false,
262
+ title: "Add Company Account",
263
+ submitLabel: "Save Credentials",
264
+ defaultInstrType: "workspace",
265
+ });
266
+ };
267
+
268
+ const handleAddPersonalClick = () => {
269
+ setAddMenuOpen(false);
270
+ openCredentialsModal({
271
+ client: "personal",
272
+ personal: true,
273
+ title: "Add Personal Account",
274
+ submitLabel: "Save Credentials",
275
+ defaultInstrType: "personal",
276
+ });
277
+ };
278
+
279
+ const handleEditCredentials = (accountId) => {
280
+ const account = getAccountById(accountId);
281
+ if (!account) return;
282
+ const personal = isPersonalAccount(account);
283
+ openCredentialsModal({
284
+ accountId: account.id,
285
+ client: personal ? "personal" : (account.client || "default"),
286
+ personal,
287
+ title: `Edit Credentials (${account.email})`,
288
+ submitLabel: "Save Credentials",
289
+ defaultInstrType: personal ? "personal" : "workspace",
290
+ initialValues: {
291
+ email: account.email,
292
+ },
293
+ });
294
+ };
295
+
296
+ const renderEmptyState = () => html`
297
+ <div class="text-center space-y-2 py-1">
298
+ <div class="rounded-lg border border-border bg-black/20 px-3 py-5">
299
+ <div class="flex flex-col items-center justify-center gap-1.5">
300
+ <img
301
+ src=${kGoogleIconPath}
302
+ alt="Google logo"
303
+ class="h-4 w-4 shrink-0"
304
+ loading="lazy"
305
+ decoding="async"
306
+ />
307
+ <p class="text-xs text-gray-500">
308
+ Connect Gmail, Calendar, Contacts, Drive, Sheets, Tasks, Docs, and Meet.
309
+ </p>
310
+ </div>
311
+ </div>
312
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
313
+ <${ActionButton}
314
+ onClick=${handleAddCompanyClick}
315
+ tone="primary"
316
+ size="sm"
317
+ idleLabel="Add Company Account"
318
+ className="w-full font-medium"
319
+ />
320
+ <${ActionButton}
321
+ onClick=${handleAddPersonalClick}
322
+ tone="secondary"
323
+ size="sm"
324
+ idleLabel="Add Personal Account"
325
+ className="w-full font-medium"
326
+ />
327
+ </div>
328
+ </div>
329
+ `;
330
+
331
+ return html`
332
+ <div class="bg-surface border border-border rounded-xl p-4">
333
+ <${PageHeader}
334
+ title="Google Accounts"
335
+ actions=${html`
336
+ ${accounts.length
337
+ ? html`
338
+ <div class="relative">
339
+ <button
340
+ type="button"
341
+ onclick=${() => setAddMenuOpen((prev) => !prev)}
342
+ class="text-xs font-medium px-3 py-1.5 rounded-lg ac-btn-secondary"
343
+ >
344
+ + Add Account
345
+ </button>
346
+ ${addMenuOpen
347
+ ? html`
348
+ <div
349
+ class="absolute right-0 top-full mt-2 min-w-[210px] rounded-lg border border-border bg-modal p-1 z-20"
350
+ >
351
+ <button
352
+ type="button"
353
+ onclick=${handleAddCompanyClick}
354
+ class="w-full text-left px-2.5 py-1.5 text-xs rounded-md hover:bg-black/30"
355
+ >
356
+ Company account
357
+ </button>
358
+ ${!hasPersonalAccount
359
+ ? html`<button
360
+ type="button"
361
+ onclick=${handleAddPersonalClick}
362
+ class="w-full text-left px-2.5 py-1.5 text-xs rounded-md hover:bg-black/30"
363
+ >
364
+ Personal account
365
+ </button>`
366
+ : null}
367
+ </div>
368
+ `
369
+ : null}
370
+ </div>
371
+ `
372
+ : null}
373
+ `}
374
+ />
375
+ ${loading
376
+ ? html`<div class="text-gray-500 text-sm text-center py-2">Loading...</div>`
377
+ : accounts.length
378
+ ? html`
379
+ <div class="space-y-2 mt-3">
380
+ ${accounts.map((account) =>
381
+ html`<${GoogleAccountRow}
382
+ key=${account.id}
383
+ account=${account}
384
+ personal=${isPersonalAccount(account)}
385
+ expanded=${expandedAccountId === account.id}
386
+ onToggleExpanded=${(accountId) =>
387
+ setExpandedAccountId((prev) => (prev === accountId ? "" : accountId))}
388
+ scopes=${scopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()}
389
+ savedScopes=${savedScopesByAccountId[account.id] || account.activeScopes || getDefaultScopes()}
390
+ apiStatus=${apiStatusByAccountId[account.id] || {}}
391
+ checkingApis=${expandedAccountId === account.id && Boolean(checkingByAccountId[account.id])}
392
+ onToggleScope=${handleToggleScope}
393
+ onCheckApis=${handleCheckApis}
394
+ onUpdatePermissions=${(accountId) => startAuth(accountId)}
395
+ onEditCredentials=${handleEditCredentials}
396
+ onDisconnect=${(accountId) => setDisconnectAccountId(accountId)}
397
+ />`,
398
+ )}
399
+ </div>
400
+ `
401
+ : renderEmptyState()}
402
+ </div>
403
+
404
+ <${CredentialsModal}
405
+ visible=${credentialsModalState.visible}
406
+ onClose=${closeCredentialsModal}
407
+ onSaved=${handleCredentialsSaved}
408
+ title=${credentialsModalState.title}
409
+ submitLabel=${credentialsModalState.submitLabel}
410
+ defaultInstrType=${credentialsModalState.defaultInstrType}
411
+ client=${credentialsModalState.client}
412
+ personal=${credentialsModalState.personal}
413
+ accountId=${credentialsModalState.accountId}
414
+ initialValues=${credentialsModalState.initialValues}
415
+ />
416
+
417
+ <${AddGoogleAccountModal}
418
+ visible=${addCompanyModalOpen}
419
+ onClose=${() => setAddCompanyModalOpen(false)}
420
+ onSubmit=${handleAddCompanyAccount}
421
+ loading=${savingAddCompany}
422
+ title="Add Company Account"
423
+ />
424
+
425
+ <${ConfirmDialog}
426
+ visible=${Boolean(disconnectAccountId)}
427
+ title="Disconnect Google account?"
428
+ message="Your agent will lose access to Gmail, Calendar, and other Google Workspace services until you reconnect."
429
+ confirmLabel="Disconnect"
430
+ cancelLabel="Cancel"
431
+ onCancel=${() => setDisconnectAccountId("")}
432
+ onConfirm=${async () => {
433
+ const accountId = disconnectAccountId;
434
+ setDisconnectAccountId("");
435
+ await handleDisconnect(accountId);
436
+ }}
437
+ />
438
+ `;
439
+ };
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useState } from "https://esm.sh/preact/hooks";
2
+ import { fetchGoogleAccounts } from "../../lib/api.js";
3
+
4
+ export const useGoogleAccounts = ({ gatewayStatus }) => {
5
+ const [accounts, setAccounts] = useState([]);
6
+ const [loading, setLoading] = useState(true);
7
+ const [hasCompanyCredentials, setHasCompanyCredentials] = useState(false);
8
+ const [hasPersonalCredentials, setHasPersonalCredentials] = useState(false);
9
+
10
+ const refreshAccounts = useCallback(async () => {
11
+ setLoading(true);
12
+ try {
13
+ const data = await fetchGoogleAccounts();
14
+ if (data.ok) {
15
+ setAccounts(Array.isArray(data.accounts) ? data.accounts : []);
16
+ setHasCompanyCredentials(Boolean(data.hasCompanyCredentials));
17
+ setHasPersonalCredentials(Boolean(data.hasPersonalCredentials));
18
+ }
19
+ } finally {
20
+ setLoading(false);
21
+ }
22
+ }, []);
23
+
24
+ useEffect(() => {
25
+ refreshAccounts();
26
+ }, [refreshAccounts]);
27
+
28
+ useEffect(() => {
29
+ if (gatewayStatus === "running") {
30
+ refreshAccounts();
31
+ }
32
+ }, [gatewayStatus, refreshAccounts]);
33
+
34
+ return {
35
+ accounts,
36
+ loading,
37
+ hasCompanyCredentials,
38
+ hasPersonalCredentials,
39
+ refreshAccounts,
40
+ };
41
+ };
@@ -248,3 +248,29 @@ export const LockLineIcon = ({ className = "" }) => html`
248
248
  />
249
249
  </svg>
250
250
  `;
251
+
252
+ export const DeleteBinLineIcon = ({ className = "" }) => html`
253
+ <svg
254
+ class=${className}
255
+ viewBox="0 0 24 24"
256
+ fill="currentColor"
257
+ aria-hidden="true"
258
+ >
259
+ <path
260
+ d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM18 8H6V20H18V8ZM9 11H11V17H9V11ZM13 11H15V17H13V11ZM9 4V6H15V4H9Z"
261
+ />
262
+ </svg>
263
+ `;
264
+
265
+ export const RestartLineIcon = ({ className = "" }) => html`
266
+ <svg
267
+ class=${className}
268
+ viewBox="0 0 24 24"
269
+ fill="currentColor"
270
+ aria-hidden="true"
271
+ >
272
+ <path
273
+ d="M18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"
274
+ />
275
+ </svg>
276
+ `;
@@ -43,8 +43,8 @@ const getChangedFilePresentation = (changedFile) => {
43
43
  return {
44
44
  statusLabel: "D",
45
45
  statusClass: "is-deleted",
46
- rowClass: "",
47
- canOpen: false,
46
+ rowClass: "is-clickable",
47
+ canOpen: true,
48
48
  };
49
49
  }
50
50
  return {
@@ -62,6 +62,14 @@ const formatDelta = (value, prefix) => {
62
62
  return `${prefix}${numericValue}`;
63
63
  };
64
64
 
65
+ const isDirectoryChangePath = (changedPath, statusKind) => {
66
+ const safePath = String(changedPath || "").trim();
67
+ const safeStatusKind = String(statusKind || "").toUpperCase();
68
+ if (!safePath) return false;
69
+ if (safePath.endsWith("/")) return true;
70
+ return safeStatusKind === "U" && safePath.endsWith("\\");
71
+ };
72
+
65
73
  const getRemoteSyncPresentation = (summary) => {
66
74
  const safeState = String(summary?.syncState || "").trim();
67
75
  const aheadCount = Number(summary?.aheadCount) || 0;
@@ -108,31 +116,41 @@ const getRemoteSyncPresentation = (summary) => {
108
116
  };
109
117
  };
110
118
 
111
- const buildSyncCommitMessage = (changedFiles) => {
112
- const filePaths = Array.isArray(changedFiles)
113
- ? changedFiles
114
- .map((file) => String(file?.path || "").trim())
115
- .filter(Boolean)
116
- : [];
117
- const totalCount = filePaths.length;
119
+ const buildSyncCommitMessage = (summary) => {
120
+ const changedFiles = Array.isArray(summary?.changedFiles) ? summary.changedFiles : [];
121
+ const changedFilesCount = Number(summary?.changedFilesCount) || 0;
122
+ const filePaths = changedFiles
123
+ .map((file) => String(file?.path || "").trim())
124
+ .filter(Boolean);
125
+ const totalCount = changedFilesCount || filePaths.length;
118
126
  if (totalCount <= 0) return "sync changes";
119
127
 
120
- const fileNames = filePaths.map((filePath) => filePath.split("/").filter(Boolean).pop() || filePath);
128
+ const fileNames = filePaths
129
+ .map((filePath) => filePath.split("/").filter(Boolean).pop() || filePath);
121
130
  const uniqueFileNames = Array.from(new Set(fileNames));
131
+ if (uniqueFileNames.length <= 0) {
132
+ const noun = totalCount === 1 ? "file" : "files";
133
+ return `Edited ${totalCount} ${noun}`;
134
+ }
135
+
122
136
  const shownFileNames = uniqueFileNames.slice(0, kSyncCommitFileNameLimit);
123
- const remainingCount = Math.max(0, uniqueFileNames.length - shownFileNames.length);
137
+ const remainingCount = Math.max(0, totalCount - shownFileNames.length);
124
138
  const noun = totalCount === 1 ? "file" : "files";
125
139
  const suffix = remainingCount > 0 ? ` +${remainingCount} more` : "";
126
140
  return `Edited ${totalCount} ${noun} - ${shownFileNames.join(", ")}${suffix}`;
127
141
  };
128
142
 
129
- export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
143
+ export const SidebarGitPanel = ({
144
+ onSelectFile = () => {},
145
+ isActive = true,
146
+ }) => {
130
147
  const [loading, setLoading] = useState(true);
131
148
  const [syncing, setSyncing] = useState(false);
132
149
  const [error, setError] = useState("");
133
150
  const [summary, setSummary] = useState(null);
134
151
 
135
152
  useEffect(() => {
153
+ if (!isActive) return () => {};
136
154
  let active = true;
137
155
  let intervalId = null;
138
156
 
@@ -164,7 +182,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
164
182
  if (intervalId) window.clearInterval(intervalId);
165
183
  window.removeEventListener("alphaclaw:browse-file-saved", handleFileSaved);
166
184
  };
167
- }, []);
185
+ }, [isActive]);
168
186
 
169
187
  if (loading) {
170
188
  return html`
@@ -194,7 +212,7 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
194
212
  if (!canSyncChanges || syncing) return;
195
213
  try {
196
214
  setSyncing(true);
197
- const commitMessage = buildSyncCommitMessage(summary?.changedFiles || []);
215
+ const commitMessage = buildSyncCommitMessage(summary);
198
216
  const syncResult = await syncBrowseChanges(commitMessage);
199
217
  if (syncResult?.committed || syncResult?.pushed) {
200
218
  window.dispatchEvent(new CustomEvent("alphaclaw:browse-git-synced"));
@@ -270,6 +288,17 @@ export const SidebarGitPanel = ({ onSelectFile = () => {} }) => {
270
288
  title=${changedPath}
271
289
  onclick=${() => {
272
290
  if (!presentation.canOpen || !changedPath) return;
291
+ const directorySelection = isDirectoryChangePath(
292
+ changedPath,
293
+ changedFile?.statusKind,
294
+ );
295
+ if (directorySelection) {
296
+ onSelectFile(changedPath, {
297
+ directory: true,
298
+ preservePreview: true,
299
+ });
300
+ return;
301
+ }
273
302
  onSelectFile(changedPath, { view: "diff" });
274
303
  }}
275
304
  >