@myrialabs/clopen 0.2.2 → 0.2.4

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 (62) hide show
  1. package/.dockerignore +5 -0
  2. package/.env.example +2 -5
  3. package/CONTRIBUTING.md +4 -0
  4. package/README.md +4 -2
  5. package/backend/database/queries/message-queries.ts +42 -0
  6. package/backend/database/utils/connection.ts +5 -5
  7. package/backend/engine/adapters/claude/environment.ts +3 -4
  8. package/backend/engine/adapters/claude/stream.ts +107 -0
  9. package/backend/engine/adapters/opencode/server.ts +7 -1
  10. package/backend/engine/adapters/opencode/stream.ts +81 -1
  11. package/backend/engine/types.ts +17 -0
  12. package/backend/git/git-executor.ts +2 -1
  13. package/backend/git/git-service.ts +2 -1
  14. package/backend/index.ts +10 -10
  15. package/backend/snapshot/blob-store.ts +2 -2
  16. package/backend/utils/env.ts +13 -15
  17. package/backend/utils/index.ts +4 -1
  18. package/backend/utils/paths.ts +11 -0
  19. package/backend/utils/port-utils.ts +19 -6
  20. package/backend/ws/git/commit-message.ts +108 -0
  21. package/backend/ws/git/index.ts +3 -1
  22. package/backend/ws/messages/crud.ts +52 -0
  23. package/backend/ws/system/index.ts +7 -1
  24. package/backend/ws/system/operations.ts +28 -2
  25. package/bin/clopen.ts +15 -15
  26. package/docker-compose.yml +31 -0
  27. package/frontend/App.svelte +3 -0
  28. package/frontend/components/auth/SetupPage.svelte +45 -13
  29. package/frontend/components/chat/input/ChatInput.svelte +1 -1
  30. package/frontend/components/chat/message/ChatMessage.svelte +64 -16
  31. package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
  32. package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
  33. package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
  34. package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
  35. package/frontend/components/common/feedback/UpdateBanner.svelte +19 -8
  36. package/frontend/components/git/BranchManager.svelte +143 -155
  37. package/frontend/components/git/CommitForm.svelte +61 -11
  38. package/frontend/components/history/HistoryModal.svelte +30 -78
  39. package/frontend/components/history/HistoryView.svelte +45 -92
  40. package/frontend/components/settings/SettingsModal.svelte +1 -1
  41. package/frontend/components/settings/SettingsView.svelte +1 -1
  42. package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
  43. package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
  44. package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
  45. package/frontend/components/settings/git/GitSettings.svelte +392 -0
  46. package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
  47. package/frontend/components/settings/model/ModelSettings.svelte +172 -289
  48. package/frontend/components/workspace/PanelHeader.svelte +1 -3
  49. package/frontend/components/workspace/WorkspaceLayout.svelte +11 -1
  50. package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
  51. package/frontend/components/workspace/panels/GitPanel.svelte +53 -8
  52. package/frontend/main.ts +4 -0
  53. package/frontend/stores/features/auth.svelte.ts +28 -0
  54. package/frontend/stores/features/settings.svelte.ts +13 -2
  55. package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
  56. package/frontend/stores/ui/update.svelte.ts +51 -4
  57. package/package.json +2 -2
  58. package/scripts/dev.ts +3 -2
  59. package/scripts/start.ts +24 -0
  60. package/shared/types/git.ts +15 -0
  61. package/shared/types/stores/settings.ts +12 -0
  62. package/vite.config.ts +2 -2
@@ -1,16 +1,17 @@
1
1
  <script lang="ts">
2
- import { updateState, runUpdate, dismissUpdate, checkForUpdate } from '$frontend/stores/ui/update.svelte';
2
+ import { updateState, runUpdate, dismissUpdate, checkForUpdate, showRestartModal } from '$frontend/stores/ui/update.svelte';
3
3
  import { systemSettings, updateSystemSettings } from '$frontend/stores/features/settings.svelte';
4
4
  import Icon from '$frontend/components/common/display/Icon.svelte';
5
5
  import { slide } from 'svelte/transition';
6
6
 
7
7
  const showBanner = $derived(
8
- !updateState.dismissed && (
8
+ updateState.pendingRestart ||
9
+ (!updateState.dismissed && (
9
10
  updateState.updateAvailable ||
10
11
  updateState.updating ||
11
12
  updateState.updateSuccess ||
12
13
  updateState.error
13
- )
14
+ ))
14
15
  );
15
16
 
16
17
  function handleUpdate() {
@@ -28,13 +29,17 @@
28
29
  function handleRetry() {
29
30
  checkForUpdate();
30
31
  }
32
+
33
+ function handleShowRestart() {
34
+ showRestartModal();
35
+ }
31
36
  </script>
32
37
 
33
38
  {#if showBanner}
34
39
  <div
35
40
  transition:slide={{ duration: 300 }}
36
41
  class="flex items-center justify-center gap-2 px-4 py-1.5 text-sm font-medium
37
- {updateState.updateSuccess
42
+ {updateState.updateSuccess || updateState.pendingRestart
38
43
  ? 'bg-emerald-600 text-white'
39
44
  : updateState.error
40
45
  ? 'bg-red-600 text-white'
@@ -44,17 +49,23 @@
44
49
  role="status"
45
50
  aria-live="polite"
46
51
  >
47
- {#if updateState.updateSuccess}
52
+ {#if updateState.updateSuccess || updateState.pendingRestart}
48
53
  <Icon name="lucide:package-check" class="w-4 h-4" />
49
- <span>Updated to v{updateState.latestVersion} — restart clopen to apply</span>
54
+ <span>Updated to v{updateState.latestVersion} — restart required</span>
55
+ <button
56
+ onclick={handleShowRestart}
57
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
58
+ >
59
+ How to restart
60
+ </button>
50
61
  {:else if updateState.error}
51
62
  <Icon name="lucide:package-x" class="w-4 h-4" />
52
- <span>Update failed</span>
63
+ <span>{updateState.errorType === 'check' ? 'Unable to check for updates' : 'Update failed'}</span>
53
64
  <button
54
65
  onclick={handleRetry}
55
66
  class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
56
67
  >
57
- Retry
68
+ {updateState.errorType === 'check' ? 'Check again' : 'Retry'}
58
69
  </button>
59
70
  <button
60
71
  onclick={handleDismiss}
@@ -35,7 +35,6 @@
35
35
  let showAddRemoteForm = $state(false);
36
36
  let newRemoteName = $state('origin');
37
37
  let newRemoteUrl = $state('');
38
- let showRemoteSection = $state(false);
39
38
 
40
39
  // Confirm dialog
41
40
  let showConfirmDialog = $state(false);
@@ -168,49 +167,6 @@
168
167
  {/snippet}
169
168
 
170
169
  {#snippet children()}
171
- <!-- Create branch form -->
172
- {#if showCreateForm}
173
- <div class="mb-4 p-3 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg space-y-2">
174
- <input
175
- type="text"
176
- bind:value={newBranchName}
177
- placeholder="New branch name..."
178
- class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40"
179
- onkeydown={(e) => e.key === 'Enter' && handleCreate()}
180
- autofocus
181
- />
182
- <div class="flex gap-2">
183
- <button
184
- type="button"
185
- class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer border-none
186
- {newBranchName.trim()
187
- ? 'bg-violet-600 text-white hover:bg-violet-700'
188
- : 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'}"
189
- onclick={handleCreate}
190
- disabled={!newBranchName.trim()}
191
- >
192
- Create Branch
193
- </button>
194
- <button
195
- type="button"
196
- class="px-3 py-2 text-sm font-medium bg-transparent border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
197
- onclick={() => { showCreateForm = false; newBranchName = ''; }}
198
- >
199
- Cancel
200
- </button>
201
- </div>
202
- </div>
203
- {:else}
204
- <button
205
- type="button"
206
- class="flex items-center justify-center gap-2 w-full mb-4 py-2.5 px-3 border border-dashed border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 hover:text-violet-600 hover:border-violet-400 transition-colors cursor-pointer bg-transparent"
207
- onclick={() => showCreateForm = true}
208
- >
209
- <Icon name="lucide:plus" class="w-4 h-4" />
210
- <span>Create New Branch</span>
211
- </button>
212
- {/if}
213
-
214
170
  <!-- Search -->
215
171
  <div class="mb-4">
216
172
  <div class="flex items-center gap-2 py-2.5 px-3.5 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-lg">
@@ -242,7 +198,7 @@
242
198
  {activeTab === 'local'
243
199
  ? 'bg-violet-500/10 text-violet-600'
244
200
  : 'bg-transparent text-slate-500 hover:text-slate-700 dark:hover:text-slate-300'}"
245
- onclick={() => activeTab = 'local'}
201
+ onclick={() => { activeTab = 'local'; showCreateForm = false; newBranchName = ''; }}
246
202
  >
247
203
  Local ({filteredLocal.length})
248
204
  </button>
@@ -261,6 +217,49 @@
261
217
  <!-- Branch list -->
262
218
  <div class="space-y-1.5 max-h-80 overflow-y-auto">
263
219
  {#if activeTab === 'local'}
220
+ <!-- Create branch form (Local tab only) -->
221
+ {#if showCreateForm}
222
+ <div class="mb-2 p-3 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg space-y-2">
223
+ <input
224
+ type="text"
225
+ bind:value={newBranchName}
226
+ placeholder="New branch name..."
227
+ class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40"
228
+ onkeydown={(e) => e.key === 'Enter' && handleCreate()}
229
+ autofocus
230
+ />
231
+ <div class="flex gap-2">
232
+ <button
233
+ type="button"
234
+ class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer border-none
235
+ {newBranchName.trim()
236
+ ? 'bg-violet-600 text-white hover:bg-violet-700'
237
+ : 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'}"
238
+ onclick={handleCreate}
239
+ disabled={!newBranchName.trim()}
240
+ >
241
+ Create Branch
242
+ </button>
243
+ <button
244
+ type="button"
245
+ class="px-3 py-2 text-sm font-medium bg-transparent border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
246
+ onclick={() => { showCreateForm = false; newBranchName = ''; }}
247
+ >
248
+ Cancel
249
+ </button>
250
+ </div>
251
+ </div>
252
+ {:else}
253
+ <button
254
+ type="button"
255
+ class="flex items-center justify-center gap-2 w-full mb-2 py-2.5 px-3 border border-dashed border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-500 hover:text-violet-600 hover:border-violet-400 transition-colors cursor-pointer bg-transparent"
256
+ onclick={() => showCreateForm = true}
257
+ >
258
+ <Icon name="lucide:plus" class="w-4 h-4" />
259
+ <span>Create New Branch</span>
260
+ </button>
261
+ {/if}
262
+
264
263
  {#each filteredLocal as branch (branch.name)}
265
264
  <div
266
265
  class="group flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors
@@ -320,129 +319,118 @@
320
319
  </div>
321
320
  {/each}
322
321
  {:else if activeTab === 'remote'}
323
- {#each filteredRemote as branch (branch.name)}
324
- <div
325
- class="group flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800/50 transition-colors border border-slate-200 dark:border-slate-700"
326
- >
327
- <div class="flex items-center gap-2.5 flex-1 min-w-0">
328
- <Icon name="lucide:cloud" class="w-4 h-4 text-slate-400 shrink-0" />
329
- <span class="text-sm text-slate-900 dark:text-slate-100 truncate">{branch.name}</span>
330
- </div>
331
-
332
- <div class="items-center gap-1 shrink-0 hidden group-hover:flex">
333
- <button
334
- type="button"
335
- class="flex items-center justify-center w-8 h-8 rounded-lg text-slate-500 hover:bg-violet-500/10 hover:text-violet-600 transition-colors cursor-pointer bg-transparent border-none"
336
- onclick={() => handleSwitchRemote(branch.name)}
337
- title="Checkout remote branch"
338
- >
339
- <Icon name="lucide:arrow-right" class="w-4 h-4" />
340
- </button>
341
- </div>
322
+ {#if isLoadingRemotes}
323
+ <div class="flex items-center justify-center py-8">
324
+ <div class="w-4 h-4 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
342
325
  </div>
343
- {:else}
326
+ {:else if remotes.length === 0}
344
327
  <div class="flex flex-col items-center gap-2 py-8 text-slate-500 dark:text-slate-400 text-sm">
345
- <Icon name="lucide:search-x" class="w-10 h-10 opacity-40" />
346
- <p class="font-medium">No remote branches found</p>
328
+ <Icon name="lucide:server-off" class="w-10 h-10 opacity-40" />
329
+ <p class="font-medium">No remote connections</p>
330
+ <p class="text-xs text-center opacity-70">Add a remote below to track remote branches</p>
347
331
  </div>
348
- {/each}
349
- {/if}
350
- </div>
351
-
352
- <!-- Remote Connections Section -->
353
- <div class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-700">
354
- <button
355
- type="button"
356
- class="flex items-center gap-2 w-full text-left bg-transparent border-none cursor-pointer px-0 py-1"
357
- onclick={() => showRemoteSection = !showRemoteSection}
358
- >
359
- <Icon name={showRemoteSection ? 'lucide:chevron-down' : 'lucide:chevron-right'} class="w-3.5 h-3.5 text-slate-400" />
360
- <Icon name="lucide:server" class="w-3.5 h-3.5 text-slate-500" />
361
- <span class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
362
- Remote Servers ({remotes.length})
363
- </span>
364
- </button>
365
-
366
- {#if showRemoteSection}
367
- <div class="mt-2 space-y-2">
368
- {#if isLoadingRemotes}
369
- <div class="flex items-center justify-center py-4">
370
- <div class="w-4 h-4 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
371
- </div>
372
- {:else}
373
- <!-- Existing remotes -->
374
- {#each remotes as remote (remote.name)}
375
- <div class="group flex items-center gap-2.5 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50/50 dark:bg-slate-800/30">
376
- <div class="flex items-center justify-center w-6 h-6 rounded-md bg-slate-100 dark:bg-slate-800">
377
- <Icon name="lucide:globe" class="w-3.5 h-3.5 text-slate-500" />
378
- </div>
379
- <div class="flex-1 min-w-0">
380
- <div class="text-sm font-medium text-slate-900 dark:text-slate-100">{remote.name}</div>
381
- <div class="text-xs text-slate-500 dark:text-slate-400 truncate font-mono">{remote.fetchUrl}</div>
382
- </div>
383
- <button
384
- type="button"
385
- class="flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer bg-transparent border-none shrink-0 opacity-0 group-hover:opacity-100"
386
- onclick={() => handleRemoveRemote(remote.name)}
387
- title="Disconnect remote"
388
- >
389
- <Icon name="lucide:unlink" class="w-3.5 h-3.5" />
390
- </button>
391
- </div>
392
- {/each}
393
-
394
- <!-- Add remote form -->
395
- {#if showAddRemoteForm}
396
- <div class="p-3 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg space-y-2">
397
- <input
398
- type="text"
399
- bind:value={newRemoteName}
400
- placeholder="Name (e.g. origin)"
401
- class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40"
402
- />
403
- <input
404
- type="text"
405
- bind:value={newRemoteUrl}
406
- placeholder="URL (e.g. https://github.com/user/repo.git)"
407
- class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40 font-mono"
408
- onkeydown={(e) => e.key === 'Enter' && handleAddRemote()}
409
- autofocus
410
- />
411
- <div class="flex gap-2">
412
- <button
413
- type="button"
414
- class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer border-none
415
- {newRemoteName.trim() && newRemoteUrl.trim()
416
- ? 'bg-violet-600 text-white hover:bg-violet-700'
417
- : 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'}"
418
- onclick={handleAddRemote}
419
- disabled={!newRemoteName.trim() || !newRemoteUrl.trim()}
420
- >
421
- Connect
422
- </button>
332
+ {:else}
333
+ {#each remotes as remote (remote.name)}
334
+ {@const remoteBranches = filteredRemote.filter(b => b.name.startsWith(remote.name + '/'))}
335
+ {#if !searchQuery || remoteBranches.length > 0}
336
+ <div class="space-y-1">
337
+ <!-- Remote group header -->
338
+ <div class="group flex items-center gap-2 px-2 py-1.5 rounded-lg">
339
+ <Icon name="lucide:server" class="w-3.5 h-3.5 text-slate-400 shrink-0" />
340
+ <span class="text-xs font-semibold text-slate-600 dark:text-slate-300">{remote.name}</span>
341
+ <span class="text-xs text-slate-400 dark:text-slate-500 font-mono truncate flex-1">{remote.fetchUrl}</span>
423
342
  <button
424
343
  type="button"
425
- class="px-3 py-2 text-sm font-medium bg-transparent border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
426
- onclick={() => { showAddRemoteForm = false; newRemoteName = 'origin'; newRemoteUrl = ''; }}
344
+ class="flex items-center justify-center w-6 h-6 rounded-md text-slate-400 hover:bg-red-500/10 hover:text-red-500 transition-colors cursor-pointer bg-transparent border-none shrink-0 opacity-0 group-hover:opacity-100"
345
+ onclick={() => handleRemoveRemote(remote.name)}
346
+ title="Disconnect remote"
427
347
  >
428
- Cancel
348
+ <Icon name="lucide:unlink" class="w-3.5 h-3.5" />
429
349
  </button>
430
350
  </div>
351
+
352
+ <!-- Branches under this remote -->
353
+ {#if remoteBranches.length > 0}
354
+ <div class="ml-5 space-y-1">
355
+ {#each remoteBranches as branch (branch.name)}
356
+ <div class="group flex items-center gap-2.5 px-3 py-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800/50 transition-colors border border-slate-200 dark:border-slate-700">
357
+ <Icon name="lucide:git-branch" class="w-3.5 h-3.5 text-slate-400 shrink-0" />
358
+ <span class="text-sm text-slate-900 dark:text-slate-100 truncate flex-1">
359
+ {branch.name.substring(remote.name.length + 1)}
360
+ </span>
361
+ <button
362
+ type="button"
363
+ class="flex items-center justify-center w-7 h-7 rounded-lg text-slate-400 hover:bg-violet-500/10 hover:text-violet-600 transition-colors cursor-pointer bg-transparent border-none shrink-0 opacity-0 group-hover:opacity-100"
364
+ onclick={() => handleSwitchRemote(branch.name)}
365
+ title="Checkout remote branch locally"
366
+ >
367
+ <Icon name="lucide:arrow-right" class="w-4 h-4" />
368
+ </button>
369
+ </div>
370
+ {/each}
371
+ </div>
372
+ {:else if !searchQuery}
373
+ <p class="ml-7 text-xs text-slate-400 dark:text-slate-500 py-1">No branches — try Fetch</p>
374
+ {/if}
431
375
  </div>
432
- {:else}
376
+ {/if}
377
+ {/each}
378
+ {/if}
379
+ {/if}
380
+ </div>
381
+
382
+ <!-- Add remote (only shown in Remote tab) -->
383
+ {#if activeTab === 'remote'}
384
+ <div class="mt-3 pt-3 border-t border-slate-200 dark:border-slate-700">
385
+ {#if showAddRemoteForm}
386
+ <div class="p-3 bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-lg space-y-2">
387
+ <input
388
+ type="text"
389
+ bind:value={newRemoteName}
390
+ placeholder="Name (e.g. origin)"
391
+ class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40"
392
+ />
393
+ <input
394
+ type="text"
395
+ bind:value={newRemoteUrl}
396
+ placeholder="URL (e.g. https://github.com/user/repo.git)"
397
+ class="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 outline-none focus:border-violet-500/40 font-mono"
398
+ onkeydown={(e) => e.key === 'Enter' && handleAddRemote()}
399
+ autofocus
400
+ />
401
+ <div class="flex gap-2">
433
402
  <button
434
403
  type="button"
435
- class="flex items-center gap-2 w-full px-3 py-2 text-xs text-slate-500 hover:text-violet-600 hover:bg-violet-500/5 rounded-lg transition-colors cursor-pointer bg-transparent border-none"
436
- onclick={() => { showAddRemoteForm = true; showRemoteSection = true; }}
404
+ class="flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors cursor-pointer border-none
405
+ {newRemoteName.trim() && newRemoteUrl.trim()
406
+ ? 'bg-violet-600 text-white hover:bg-violet-700'
407
+ : 'bg-slate-200 dark:bg-slate-700 text-slate-400 dark:text-slate-500 cursor-not-allowed'}"
408
+ onclick={handleAddRemote}
409
+ disabled={!newRemoteName.trim() || !newRemoteUrl.trim()}
437
410
  >
438
- <Icon name="lucide:plus" class="w-3.5 h-3.5" />
439
- <span>Add Remote Server</span>
411
+ Connect
440
412
  </button>
441
- {/if}
442
- {/if}
443
- </div>
444
- {/if}
445
- </div>
413
+ <button
414
+ type="button"
415
+ class="px-3 py-2 text-sm font-medium bg-transparent border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors cursor-pointer"
416
+ onclick={() => { showAddRemoteForm = false; newRemoteName = 'origin'; newRemoteUrl = ''; }}
417
+ >
418
+ Cancel
419
+ </button>
420
+ </div>
421
+ </div>
422
+ {:else}
423
+ <button
424
+ type="button"
425
+ class="flex items-center gap-2 w-full px-3 py-2 text-xs text-slate-500 hover:text-violet-600 hover:bg-violet-500/5 rounded-lg transition-colors cursor-pointer bg-transparent border-none"
426
+ onclick={() => showAddRemoteForm = true}
427
+ >
428
+ <Icon name="lucide:plus" class="w-3.5 h-3.5" />
429
+ <span>Add Remote</span>
430
+ </button>
431
+ {/if}
432
+ </div>
433
+ {/if}
446
434
  {/snippet}
447
435
  </Modal>
448
436
 
@@ -1,5 +1,10 @@
1
1
  <script lang="ts">
2
+ import { tick } from 'svelte';
2
3
  import Icon from '$frontend/components/common/display/Icon.svelte';
4
+ import { settings } from '$frontend/stores/features/settings.svelte';
5
+ import { projectState } from '$frontend/stores/core/projects.svelte';
6
+ import { showError } from '$frontend/stores/ui/notification.svelte';
7
+ import ws from '$frontend/utils/ws';
3
8
 
4
9
  interface Props {
5
10
  stagedCount: number;
@@ -11,6 +16,7 @@
11
16
 
12
17
  let commitMessage = $state('');
13
18
  let textareaEl = $state<HTMLTextAreaElement | null>(null);
19
+ let isGenerating = $state(false);
14
20
 
15
21
  function handleCommit() {
16
22
  if (!commitMessage.trim() || stagedCount === 0) return;
@@ -42,21 +48,65 @@
42
48
  function handleInput() {
43
49
  autoResize();
44
50
  }
51
+
52
+ async function generateCommitMessage() {
53
+ const projectId = projectState.currentProject?.id;
54
+ if (!projectId || stagedCount === 0 || isGenerating) return;
55
+
56
+ isGenerating = true;
57
+ try {
58
+ const { useCustomModel, engine, model, format } = settings.commitGenerator;
59
+ const resolvedEngine = useCustomModel ? engine : settings.selectedEngine;
60
+ const resolvedModel = useCustomModel ? model : settings.selectedModel;
61
+ const result = await ws.http('git:generate-commit-message', {
62
+ projectId,
63
+ engine: resolvedEngine,
64
+ model: resolvedModel,
65
+ format
66
+ });
67
+ commitMessage = result.message;
68
+ await tick();
69
+ autoResize();
70
+ } catch (err) {
71
+ showError('Generate Failed', err instanceof Error ? err.message : 'Failed to generate commit message');
72
+ } finally {
73
+ isGenerating = false;
74
+ }
75
+ }
45
76
  </script>
46
77
 
47
78
  <div class="px-2 py-2">
48
79
  <div class="flex flex-col gap-1.5">
49
- <textarea
50
- bind:this={textareaEl}
51
- bind:value={commitMessage}
52
- placeholder="Commit message..."
53
- class="w-full px-2.5 py-2 text-sm bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-600 resize-none outline-none focus:ring-1 focus:ring-violet-500 transition-colors"
54
- rows="1"
55
- style="overflow-y: hidden;"
56
- onkeydown={handleKeydown}
57
- oninput={handleInput}
58
- disabled={isCommitting}
59
- ></textarea>
80
+ <div class="relative">
81
+ <textarea
82
+ bind:this={textareaEl}
83
+ bind:value={commitMessage}
84
+ placeholder="Commit message..."
85
+ class="w-full px-2.5 py-2 pr-8 text-sm bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-600 resize-none outline-none focus:ring-1 focus:ring-violet-500 transition-colors"
86
+ rows="1"
87
+ style="overflow-y: hidden;"
88
+ onkeydown={handleKeydown}
89
+ oninput={handleInput}
90
+ disabled={isCommitting || isGenerating}
91
+ ></textarea>
92
+ <!-- AI generate button -->
93
+ <button
94
+ type="button"
95
+ class="absolute top-1.5 right-1.5 flex items-center justify-center w-6 h-6 rounded transition-all duration-150
96
+ {stagedCount > 0 && !isGenerating && !isCommitting
97
+ ? 'text-slate-400 hover:text-violet-500 hover:bg-violet-500/10 cursor-pointer'
98
+ : 'text-slate-300 dark:text-slate-700 cursor-not-allowed'}"
99
+ onclick={generateCommitMessage}
100
+ disabled={stagedCount === 0 || isGenerating || isCommitting}
101
+ title="Generate commit message with AI"
102
+ >
103
+ {#if isGenerating}
104
+ <div class="w-3.5 h-3.5 border-2 border-violet-400/30 border-t-violet-500 rounded-full animate-spin"></div>
105
+ {:else}
106
+ <Icon name="lucide:sparkles" class="w-3.5 h-3.5" />
107
+ {/if}
108
+ </button>
109
+ </div>
60
110
  <button
61
111
  type="button"
62
112
  class="flex items-center justify-center gap-1.5 w-full py-1.5 px-3 rounded-md text-xs font-medium transition-all duration-150
@@ -5,7 +5,6 @@
5
5
  import { addNotification } from '$frontend/stores/ui/notification.svelte';
6
6
  import ws from '$frontend/utils/ws';
7
7
  import type { ChatSession } from '$shared/types/database/schema';
8
- import type { SDKMessage } from '$shared/types/messaging';
9
8
  import Icon from '$frontend/components/common/display/Icon.svelte';
10
9
  import AvatarBubble from '$frontend/components/common/display/AvatarBubble.svelte';
11
10
  import Modal from '$frontend/components/common/overlay/Modal.svelte';
@@ -55,7 +54,6 @@
55
54
 
56
55
  // Cache for session data to avoid multiple API calls
57
56
  let sessionDataCache = $state<Record<string, {
58
- messages: SDKMessage[];
59
57
  title: string;
60
58
  summary: string;
61
59
  count: number;
@@ -64,87 +62,30 @@
64
62
  }>>({});
65
63
  let loadingSessionData = $state(false);
66
64
 
67
- // Helper to get session data from cache or API
65
+ // Helper to get session data from cache or API (single session fallback)
68
66
  async function getSessionData(sessionId: string) {
69
67
  if (sessionDataCache[sessionId]) {
70
68
  return sessionDataCache[sessionId];
71
69
  }
72
70
 
73
71
  try {
74
- const messages = await ws.http('messages:list', { session_id: sessionId, include_all: true });
75
-
76
- const firstUserMessage = messages.find((m: SDKMessage) => m.type === 'user');
77
- let title = 'New Conversation';
78
- if (firstUserMessage) {
79
- let textContent = '';
80
- if (typeof firstUserMessage.message.content === 'string') {
81
- textContent = firstUserMessage.message.content;
82
- } else if (Array.isArray(firstUserMessage.message.content)) {
83
- const textBlocks = firstUserMessage.message.content.filter((c: any) => c.type === 'text');
84
- textContent = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
85
- }
86
-
87
- if (textContent) {
88
- title = textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '');
89
- }
72
+ const previews = await ws.http('sessions:preview', { session_ids: [sessionId] });
73
+ const preview = previews[0];
74
+ if (preview) {
75
+ sessionDataCache[sessionId] = {
76
+ title: preview.title,
77
+ summary: preview.summary,
78
+ count: preview.count,
79
+ userCount: preview.userCount,
80
+ assistantCount: preview.assistantCount
81
+ };
82
+ return sessionDataCache[sessionId];
90
83
  }
91
-
92
- const assistantMessages = messages.filter((m: SDKMessage) => m.type === 'assistant');
93
- let summary = 'No messages yet';
94
- if (assistantMessages.length > 0) {
95
- const lastMessage = assistantMessages[assistantMessages.length - 1];
96
- const textBlocks = lastMessage.message.content.filter((c: any) => c.type === 'text');
97
- if (textBlocks.length > 0) {
98
- const fullText = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
99
- const cleanText = fullText.replace(/```[\s\S]*?```/g, '').trim();
100
- summary = cleanText.slice(0, 100) + (cleanText.length > 100 ? '...' : '');
101
- }
102
- }
103
-
104
- const userMessages = messages.filter((m: SDKMessage) => {
105
- if (m.type !== 'user') return false;
106
- let textContent = '';
107
- if (typeof m.message.content === 'string') {
108
- textContent = m.message.content;
109
- } else if (Array.isArray(m.message.content)) {
110
- const textBlocks = m.message.content.filter(c => c.type === 'text');
111
- textContent = textBlocks.map(b => 'text' in b ? b.text : '').join(' ');
112
- }
113
- return textContent.trim().length > 0;
114
- });
115
-
116
- const totalBubbles = userMessages.length + assistantMessages.length;
117
-
118
- const data = {
119
- messages,
120
- title,
121
- summary,
122
- count: totalBubbles,
123
- userCount: userMessages.length,
124
- assistantCount: assistantMessages.length
125
- };
126
-
127
- sessionDataCache[sessionId] = data;
128
- debug.log('session', `Loaded session ${sessionId}:`, {
129
- title,
130
- totalMessages: messages.length,
131
- userCount: userMessages.length,
132
- assistantCount: assistantMessages.length,
133
- totalBubbles: totalBubbles,
134
- summary: summary.substring(0, 50)
135
- });
136
- return data;
137
84
  } catch (error) {
138
85
  debug.error('session', 'Error fetching session data:', error);
139
- return {
140
- messages: [],
141
- title: 'New Conversation',
142
- summary: 'No messages yet',
143
- count: 0,
144
- userCount: 0,
145
- assistantCount: 0
146
- };
147
86
  }
87
+
88
+ return { title: 'New Conversation', summary: 'No messages yet', count: 0, userCount: 0, assistantCount: 0 };
148
89
  }
149
90
 
150
91
  function getMessageCount(sessionId: string): number {
@@ -167,12 +108,23 @@
167
108
  loadingSessionData = true;
168
109
  try {
169
110
  // Sort newest first and load top 20 so new sessions are always included
170
- const sortedSessions = [...sessions]
111
+ const sessionIds = [...sessions]
171
112
  .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime())
172
- .slice(0, 20);
173
- await Promise.all(
174
- sortedSessions.map(session => getSessionData(session.id))
175
- );
113
+ .slice(0, 20)
114
+ .map(s => s.id);
115
+
116
+ if (sessionIds.length === 0) return;
117
+
118
+ const previews = await ws.http('sessions:preview', { session_ids: sessionIds });
119
+ for (const preview of previews) {
120
+ sessionDataCache[preview.session_id] = {
121
+ title: preview.title,
122
+ summary: preview.summary,
123
+ count: preview.count,
124
+ userCount: preview.userCount,
125
+ assistantCount: preview.assistantCount
126
+ };
127
+ }
176
128
  } catch (error) {
177
129
  debug.error('session', 'Error preloading session data:', error);
178
130
  } finally {