@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,36 +1,44 @@
1
1
  <script lang="ts">
2
+ import Icon from '$frontend/components/common/display/Icon.svelte';
2
3
  import { settings, updateSettings } from '$frontend/stores/features/settings.svelte';
3
4
  import { modelStore } from '$frontend/stores/features/models.svelte';
4
- import { ENGINES, type EngineType } from '$shared/constants/engines';
5
- import type { EngineModel } from '$shared/types/engine';
5
+ import { ENGINES } from '$shared/constants/engines';
6
+ import type { EngineType } from '$shared/types/engine';
7
+ import type { CommitMessageFormat } from '$shared/types/git';
8
+ import type { IconName } from '$shared/types/ui/icons';
9
+ import EngineModelPicker from './EngineModelPicker.svelte';
6
10
 
7
- let searchQuery = $state('');
8
- let refreshing = $state(false);
9
- let collapsedProviders = $state<Set<string>>(new Set());
11
+ type Tab = 'assistant' | 'commit-message';
10
12
 
11
- // Handle engine selection restore remembered model or pick first
12
- async function selectEngine(engineType: EngineType) {
13
+ const tabs: { id: Tab; label: string; icon: IconName }[] = [
14
+ { id: 'assistant', label: 'Assistant', icon: 'lucide:bot' },
15
+ { id: 'commit-message', label: 'Commit Message', icon: 'lucide:git-branch' }
16
+ ];
17
+
18
+ let activeTab = $state<Tab>('assistant');
19
+
20
+ // --- Assistant ---
21
+
22
+ function handleAssistantEngineChange(engineType: EngineType) {
13
23
  updateSettings({ selectedEngine: engineType });
14
- searchQuery = '';
15
24
 
16
- // Restore remembered model for this engine
17
25
  const memory = settings.engineModelMemory || {};
18
26
  const remembered = memory[engineType];
19
27
 
20
28
  if (engineType !== 'claude-code') {
21
- const models = await modelStore.fetchModels(engineType);
22
- const target = (remembered && models.find(m => m.id === remembered))
23
- || models.find(m => m.recommended)
24
- || models[0];
25
- if (target) {
26
- updateSettings({
27
- selectedModel: target.id,
28
- engineModelMemory: { ...memory, [engineType]: target.id }
29
- });
30
- } else {
31
- // No models available — clear the model selection
32
- updateSettings({ selectedModel: '' });
33
- }
29
+ modelStore.fetchModels(engineType).then(models => {
30
+ const target = (remembered && models.find(m => m.id === remembered))
31
+ || models.find(m => m.recommended)
32
+ || models[0];
33
+ if (target) {
34
+ updateSettings({
35
+ selectedModel: target.id,
36
+ engineModelMemory: { ...memory, [engineType]: target.id }
37
+ });
38
+ } else {
39
+ updateSettings({ selectedModel: '' });
40
+ }
41
+ });
34
42
  } else {
35
43
  const models = modelStore.getByEngine('claude-code');
36
44
  const target = (remembered && models.find(m => m.id === remembered))
@@ -42,17 +50,12 @@
42
50
  engineModelMemory: { ...memory, [engineType]: target.id }
43
51
  });
44
52
  } else {
45
- // No models available — clear the model selection
46
53
  updateSettings({ selectedModel: '' });
47
54
  }
48
55
  }
49
-
50
- // Open accordion for the selected model's provider
51
- syncAccordionState();
52
56
  }
53
57
 
54
- // Handle model selection — also save to per-engine memory
55
- function selectModel(modelId: string) {
58
+ function handleAssistantModelChange(modelId: string) {
56
59
  const memory = settings.engineModelMemory || {};
57
60
  updateSettings({
58
61
  selectedModel: modelId,
@@ -60,298 +63,178 @@
60
63
  });
61
64
  }
62
65
 
63
- // Refresh models (bypass cache)
64
- async function handleRefresh() {
65
- refreshing = true;
66
- try {
67
- await modelStore.refreshModels(settings.selectedEngine);
68
- } finally {
69
- refreshing = false;
70
- }
71
- }
66
+ // --- Commit Message ---
72
67
 
73
- // Toggle provider accordion
74
- function toggleProvider(provider: string) {
75
- const next = new Set(collapsedProviders);
76
- if (next.has(provider)) {
77
- next.delete(provider);
78
- } else {
79
- next.add(provider);
80
- }
81
- collapsedProviders = next;
82
- }
68
+ const formatOptions: { id: CommitMessageFormat; label: string; desc: string; icon: IconName }[] = [
69
+ { id: 'single-line', label: 'Single Line', desc: 'type(scope): subject', icon: 'lucide:minus' },
70
+ { id: 'multi-line', label: 'Multi Line', desc: 'Subject + body', icon: 'lucide:align-left' }
71
+ ];
83
72
 
84
- // Sync accordion state: open only the provider containing the selected model
85
- function syncAccordionState() {
86
- const allProviders = [...groupedModels.keys()];
87
- const selectedModel = settings.selectedModel;
88
- let selectedProvider: string | null = null;
73
+ const commitGen = $derived(settings.commitGenerator);
74
+ const useCustomModel = $derived(commitGen.useCustomModel);
89
75
 
90
- for (const [provider, models] of groupedModels) {
91
- if (models.some(m => m.id === selectedModel)) {
92
- selectedProvider = provider;
93
- break;
94
- }
95
- }
76
+ // Resolve which model is being used for display
77
+ const activeEngine = $derived(useCustomModel ? commitGen.engine : settings.selectedEngine);
78
+ const activeModel = $derived(useCustomModel ? commitGen.model : settings.selectedModel);
79
+ const activeEngineMeta = $derived(ENGINES.find(e => e.type === activeEngine));
80
+ const activeModelMeta = $derived(modelStore.getById(activeModel));
96
81
 
97
- // Collapse all, then open the one with selected model
98
- const collapsed = new Set(allProviders);
99
- if (selectedProvider) {
100
- collapsed.delete(selectedProvider);
101
- }
102
- collapsedProviders = collapsed;
82
+ function toggleCustomModel() {
83
+ updateSettings({
84
+ commitGenerator: { ...commitGen, useCustomModel: !useCustomModel }
85
+ });
103
86
  }
104
87
 
105
- // Get models for the currently selected engine, filtered by search
106
- const filteredModels = $derived.by(() => {
107
- const models = modelStore.getByEngine(settings.selectedEngine);
108
- if (!searchQuery.trim()) return models;
109
-
110
- const q = searchQuery.toLowerCase();
111
- return models.filter(m =>
112
- m.name.toLowerCase().includes(q) ||
113
- m.modelId.toLowerCase().includes(q) ||
114
- m.provider.toLowerCase().includes(q) ||
115
- m.capabilities.some(c => c.toLowerCase().includes(q))
116
- );
117
- });
118
-
119
- // Group models by provider
120
- const groupedModels = $derived.by(() => {
121
- const groups = new Map<string, EngineModel[]>();
122
- for (const model of filteredModels) {
123
- const key = model.provider;
124
- if (!groups.has(key)) groups.set(key, []);
125
- groups.get(key)!.push(model);
126
- }
127
- return groups;
128
- });
129
-
130
- // Fetch models on mount for non-claude-code, then sync accordion
131
- $effect(() => {
132
- if (settings.selectedEngine !== 'claude-code') {
133
- modelStore.fetchModels(settings.selectedEngine);
134
- }
135
- });
88
+ function handleCommitEngineChange(engineType: EngineType) {
89
+ const models = modelStore.getByEngine(engineType);
90
+ const defaultModel = engineType === 'claude-code'
91
+ ? 'claude-code:haiku'
92
+ : (models[0]?.id || '');
93
+ updateSettings({
94
+ commitGenerator: {
95
+ ...commitGen,
96
+ engine: engineType,
97
+ model: defaultModel
98
+ }
99
+ });
136
100
 
137
- // Sync accordion: open all when searching, restore default when cleared
138
- $effect(() => {
139
- if (searchQuery.trim()) {
140
- // Searching — open all accordions
141
- collapsedProviders = new Set();
142
- } else if (groupedModels.size > 0) {
143
- // Not searching — only open the one with selected model
144
- syncAccordionState();
101
+ if (engineType !== 'claude-code') {
102
+ modelStore.fetchModels(engineType).then(fetched => {
103
+ if (fetched.length > 0) {
104
+ updateSettings({
105
+ commitGenerator: { ...settings.commitGenerator, model: fetched[0].id }
106
+ });
107
+ }
108
+ });
145
109
  }
146
- });
110
+ }
147
111
 
148
- function formatContext(tokens: number): string {
149
- return tokens >= 1000000
150
- ? `${(tokens / 1000000).toFixed(1)}M`
151
- : `${(tokens / 1000).toFixed(0)}K`;
112
+ function handleCommitModelChange(modelId: string) {
113
+ updateSettings({
114
+ commitGenerator: { ...commitGen, model: modelId }
115
+ });
152
116
  }
153
117
 
154
- function formatProvider(provider: string): string {
155
- return provider
156
- .split(/[-_]/)
157
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
158
- .join(' ');
118
+ function selectFormat(format: CommitMessageFormat) {
119
+ updateSettings({
120
+ commitGenerator: { ...commitGen, format }
121
+ });
159
122
  }
160
123
  </script>
161
124
 
162
- <!-- Claude SVG logo (Anthropic) -->
163
- {#snippet claudeLogo(active: boolean)}
164
- <svg viewBox="0 0 24 24" fill="none" class="w-5 h-5" aria-hidden="true">
165
- <path d="M16.091 4L9.115 20h-1.19L14.901 4h1.19zm-5.726 5.2L14.8 20h-1.218L9.2 9.6l1.165-.4z"
166
- fill={active ? '#8b5cf6' : 'currentColor'} />
167
- </svg>
168
- {/snippet}
169
-
170
- <!-- OpenCode SVG logo -->
171
- {#snippet opencodeLogo(active: boolean)}
172
- <svg viewBox="0 0 24 24" fill="none" class="w-5 h-5" aria-hidden="true">
173
- <path d="M8.5 6L3 12l5.5 6M15.5 6L21 12l-5.5 6M13.5 4l-3 16"
174
- stroke={active ? '#8b5cf6' : 'currentColor'}
175
- stroke-width="2"
176
- stroke-linecap="round"
177
- stroke-linejoin="round" />
178
- </svg>
179
- {/snippet}
180
-
181
125
  <div class="py-1">
182
- <!-- Engine Selection -->
183
- <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">AI Engine</h3>
184
- <p class="text-sm text-slate-600 dark:text-slate-500 mb-4">
185
- Select the AI engine to power your conversations
186
- </p>
187
-
188
- <div class="flex gap-3 mb-6">
189
- {#each ENGINES as engine (engine.type)}
190
- {@const isActive = settings.selectedEngine === engine.type}
126
+ <!-- Tab Switcher -->
127
+ <div class="flex gap-1 p-1 mb-5 bg-slate-100 dark:bg-slate-800/60 rounded-lg">
128
+ {#each tabs as tab (tab.id)}
129
+ {@const isActive = activeTab === tab.id}
191
130
  <button
192
131
  type="button"
193
- class="flex-1 flex items-center gap-3 p-3.5 overflow-hidden border-2 rounded-xl text-left cursor-pointer transition-all duration-200
132
+ class="flex-1 flex items-center justify-center gap-2 py-2 px-3 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer
194
133
  {isActive
195
- ? 'border-violet-600 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/12 dark:to-purple-500/8'
196
- : 'border-slate-200 dark:border-slate-800 bg-slate-100/80 dark:bg-slate-800/80 hover:border-violet-500/20 dark:hover:border-violet-500/35'}"
197
- onclick={() => selectEngine(engine.type)}
134
+ ? 'bg-white dark:bg-slate-700 text-slate-900 dark:text-slate-100 shadow-sm'
135
+ : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
136
+ onclick={() => activeTab = tab.id}
198
137
  >
199
- <div>
200
- <div class="flex dark:hidden items-center justify-center w-5 h-5">{@html engine.icon.light}</div>
201
- <div class="hidden dark:flex items-center justify-center w-5 h-5">{@html engine.icon.dark}</div>
202
- </div>
203
- <div>
204
- <div class="font-bold text-sm text-slate-900 dark:text-slate-100">{engine.name}</div>
205
- <div class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{engine.description}</div>
206
- </div>
207
- {#if isActive}
208
- <div class="flex items-center justify-center w-5 h-5 bg-gradient-to-br from-violet-600 to-purple-600 rounded-full text-white ml-auto flex-shrink-0">
209
- <svg viewBox="0 0 24 24" fill="none" class="w-3 h-3" aria-hidden="true">
210
- <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" />
211
- </svg>
212
- </div>
213
- {/if}
138
+ <Icon name={tab.icon} class="w-4 h-4 {isActive ? 'text-violet-600' : ''}" />
139
+ {tab.label}
214
140
  </button>
215
141
  {/each}
216
142
  </div>
217
143
 
218
- <!-- Model Selection -->
219
- <div class="flex items-center justify-between mb-1.5">
220
- <h3 class="text-base font-bold text-slate-900 dark:text-slate-100">Model</h3>
221
- <button
222
- type="button"
223
- class="flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg transition-colors cursor-pointer
224
- text-slate-500 hover:text-violet-600 hover:bg-violet-500/10 dark:hover:text-violet-400 dark:hover:bg-violet-500/15
225
- disabled:opacity-50 disabled:cursor-not-allowed"
226
- onclick={handleRefresh}
227
- disabled={refreshing || modelStore.loading}
228
- >
229
- <svg viewBox="0 0 24 24" fill="none" class="w-3.5 h-3.5 {refreshing ? 'animate-spin' : ''}" aria-hidden="true">
230
- <path d="M21 12a9 9 0 11-2.636-6.364M21 3v5h-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
231
- </svg>
232
- {refreshing ? 'Refreshing...' : 'Refresh'}
233
- </button>
234
- </div>
235
- <p class="text-sm text-slate-600 dark:text-slate-500 mb-3">
236
- Select the AI model for the {ENGINES.find(e => e.type === settings.selectedEngine)?.name || 'selected'} engine
237
- </p>
238
-
239
- <!-- Search -->
240
- <div class="relative mb-3">
241
- <svg viewBox="0 0 24 24" fill="none" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" aria-hidden="true">
242
- <circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2" />
243
- <path d="M21 21l-4.35-4.35" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
244
- </svg>
245
- <input
246
- type="text"
247
- bind:value={searchQuery}
248
- placeholder="Search models..."
249
- class="w-full pl-9 pr-3 py-2 text-sm bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-600 transition-colors text-slate-900 dark:text-slate-100 placeholder-slate-400"
144
+ <!-- ===== ASSISTANT TAB ===== -->
145
+ {#if activeTab === 'assistant'}
146
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-4">
147
+ Configure the engine and model for chat
148
+ </p>
149
+
150
+ <EngineModelPicker
151
+ engine={settings.selectedEngine}
152
+ model={settings.selectedModel}
153
+ onEngineChange={handleAssistantEngineChange}
154
+ onModelChange={handleAssistantModelChange}
250
155
  />
251
- </div>
252
-
253
- <!-- Model List -->
254
- <div class="flex flex-col gap-1.5">
255
- {#if modelStore.loading && settings.selectedEngine !== 'claude-code' && !refreshing}
256
- <!-- Loading skeleton for Open Code only -->
257
- <div class="border border-slate-200/80 dark:border-slate-700/50 rounded-lg overflow-hidden">
258
- <div class="bg-white/80 dark:bg-slate-800/40 px-3 py-3 flex items-center gap-3">
259
- <div class="w-4 h-4 rounded-full bg-slate-200 dark:bg-slate-700 animate-pulse"></div>
260
- <div class="h-3.5 w-32 rounded bg-slate-200 dark:bg-slate-700 animate-pulse"></div>
261
- </div>
262
- <div class="px-4 py-2.5 space-y-2.5">
263
- {#each Array(3) as _}
264
- <div class="flex items-center gap-3 py-2">
265
- <div class="w-4 h-4 rounded-full bg-slate-200/80 dark:bg-slate-700/60 animate-pulse"></div>
266
- <div class="flex-1 space-y-1.5">
267
- <div class="h-3.5 w-40 rounded bg-slate-200/80 dark:bg-slate-700/60 animate-pulse"></div>
268
- <div class="flex gap-1.5">
269
- <div class="h-3 w-14 rounded bg-slate-200/60 dark:bg-slate-700/40 animate-pulse"></div>
270
- <div class="h-3 w-12 rounded bg-slate-200/60 dark:bg-slate-700/40 animate-pulse"></div>
271
- </div>
272
- </div>
273
- </div>
274
- {/each}
275
- </div>
276
- </div>
277
- {:else if filteredModels.length === 0}
278
- <div class="py-4 text-sm text-slate-500 text-center">
279
- {searchQuery ? 'No models matching your search.' : 'No models available for this engine.'}
280
- </div>
281
- {:else}
282
- <!-- Grouped by provider with accordion -->
283
- {#each [...groupedModels.entries()] as [provider, providerModels] (provider)}
284
- {@const isCollapsed = collapsedProviders.has(provider)}
285
- {@const hasSelectedModel = providerModels.some(m => m.id === settings.selectedModel)}
286
- <div class="border border-slate-200/80 dark:border-slate-700/50 rounded-lg overflow-hidden">
287
- <!-- Accordion header -->
156
+ {/if}
157
+
158
+ <!-- ===== COMMIT MESSAGE TAB ===== -->
159
+ {#if activeTab === 'commit-message'}
160
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-4">
161
+ Configure the engine, model, and format for commits
162
+ </p>
163
+
164
+ <!-- Format Selection -->
165
+ <div class="mb-5">
166
+ <label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Message Format</label>
167
+ <div class="flex gap-2">
168
+ {#each formatOptions as fmt (fmt.id)}
169
+ {@const isActive = commitGen.format === fmt.id}
288
170
  <button
289
171
  type="button"
290
- class="flex items-center gap-2.5 w-full px-3 py-2.5 text-left cursor-pointer transition-colors
291
- bg-white/80 dark:bg-slate-800/40 hover:bg-white dark:hover:bg-slate-800/60"
292
- onclick={() => toggleProvider(provider)}
172
+ class="flex-1 flex items-center gap-2.5 p-3 border-2 rounded-xl text-left cursor-pointer transition-all duration-200
173
+ {isActive
174
+ ? 'border-violet-600 bg-gradient-to-br from-violet-500/10 to-purple-500/5 dark:from-violet-500/12 dark:to-purple-500/8'
175
+ : 'border-slate-200 dark:border-slate-800 bg-slate-100/80 dark:bg-slate-800/80 hover:border-violet-500/20 dark:hover:border-violet-500/35'}"
176
+ onclick={() => selectFormat(fmt.id)}
293
177
  >
294
- <svg viewBox="0 0 24 24" fill="none"
295
- class="w-4 h-4 text-slate-400 transition-transform duration-200 flex-shrink-0
296
- {isCollapsed ? '' : 'rotate-90'}"
297
- aria-hidden="true">
298
- <path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
299
- </svg>
300
- <span class="text-sm font-semibold text-slate-800 dark:text-slate-200">
301
- {formatProvider(provider)}
302
- </span>
303
- <span class="text-xs text-slate-400 dark:text-slate-500">
304
- {providerModels.length} {providerModels.length === 1 ? 'model' : 'models'}
305
- </span>
306
- {#if hasSelectedModel}
307
- <div class="w-1.5 h-1.5 rounded-full bg-violet-500 ml-auto flex-shrink-0"></div>
308
- {/if}
178
+ <Icon name={fmt.icon} class="w-4 h-4 {isActive ? 'text-violet-600' : 'text-slate-400'}" />
179
+ <div>
180
+ <div class="text-sm font-medium text-slate-900 dark:text-slate-100">{fmt.label}</div>
181
+ <div class="text-xs text-slate-500 dark:text-slate-400 font-mono">{fmt.desc}</div>
182
+ </div>
309
183
  </button>
184
+ {/each}
185
+ </div>
186
+ </div>
310
187
 
311
- <!-- Accordion body -->
312
- {#if !isCollapsed}
313
- <div class="flex flex-col bg-white/40 dark:bg-slate-800/20">
314
- {#each providerModels as model (model.id)}
315
- {@const isSelected = settings.selectedModel === model.id}
316
- {@const caps = model.capabilities}
317
- <button
318
- type="button"
319
- class="flex items-start gap-3 px-3 py-2.5 text-left cursor-pointer transition-all duration-150
320
- {isSelected
321
- ? 'bg-violet-500/10 dark:bg-violet-500/12'
322
- : 'hover:bg-slate-100/80 dark:hover:bg-slate-700/30'}"
323
- onclick={() => selectModel(model.id)}
324
- >
325
- <!-- Radio indicator -->
326
- <div class="flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center mt-0.5
327
- {isSelected ? 'border-violet-600' : 'border-slate-300 dark:border-slate-600'}">
328
- {#if isSelected}
329
- <div class="w-2 h-2 rounded-full bg-violet-600"></div>
330
- {/if}
331
- </div>
332
-
333
- <!-- Model info -->
334
- <div class="flex-1 min-w-0">
335
- <div class="flex items-center gap-2">
336
- <span class="text-sm font-medium text-slate-900 dark:text-slate-100">{model.name}</span>
337
- <!-- <span class="text-2xs text-slate-400 dark:text-slate-500">{formatContext(model.contextWindow)}</span> -->
338
- </div>
339
- {#if caps.length > 0}
340
- <div class="flex flex-wrap gap-1 mt-1.5">
341
- {#each caps as cap}
342
- <span class="px-1.5 py-0.5 text-2xs rounded bg-slate-100 dark:bg-slate-700/50 text-slate-500 dark:text-slate-400 leading-none">
343
- {cap}
344
- </span>
345
- {/each}
346
- </div>
347
- {/if}
348
- </div>
349
- </button>
350
- {/each}
188
+ <!-- Custom Model Toggle -->
189
+ <div class="mb-5">
190
+ <button
191
+ type="button"
192
+ class="flex items-center gap-3 w-full text-left"
193
+ onclick={toggleCustomModel}
194
+ >
195
+ <div class="relative w-9 h-5 rounded-full transition-colors duration-200 flex-shrink-0
196
+ {useCustomModel ? 'bg-violet-600' : 'bg-slate-300 dark:bg-slate-600'}">
197
+ <div class="absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform duration-200
198
+ {useCustomModel ? 'translate-x-4.5' : 'translate-x-0.5'}"></div>
199
+ </div>
200
+ <div>
201
+ <span class="text-sm font-medium text-slate-900 dark:text-slate-100">Use custom model</span>
202
+ <p class="text-xs text-slate-500 dark:text-slate-400">Use a different engine and model instead of the assistant model</p>
203
+ </div>
204
+ </button>
205
+ </div>
206
+
207
+ <!-- Current Model Info (hidden when custom model is active) -->
208
+ {#if !useCustomModel}
209
+ <div class="mb-2">
210
+ <label class="block text-sm font-semibold text-slate-700 dark:text-slate-300 mb-2">Model</label>
211
+ <div class="flex items-center gap-3 px-3.5 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-50 dark:bg-slate-800/50">
212
+ {#if activeEngineMeta}
213
+ <div class="flex-shrink-0">
214
+ <div class="flex dark:hidden items-center justify-center w-4 h-4">{@html activeEngineMeta.icon.light}</div>
215
+ <div class="hidden dark:flex items-center justify-center w-4 h-4">{@html activeEngineMeta.icon.dark}</div>
351
216
  </div>
352
217
  {/if}
218
+ <div class="flex-1 min-w-0">
219
+ <span class="text-sm font-medium text-slate-900 dark:text-slate-100">
220
+ {activeModelMeta?.name || activeModel}
221
+ </span>
222
+ <span class="text-xs text-slate-500 dark:text-slate-400 ml-1.5">(same as assistant)</span>
223
+ </div>
353
224
  </div>
354
- {/each}
225
+ </div>
355
226
  {/if}
356
- </div>
227
+
228
+ <!-- Custom Engine & Model Selection (only when toggled on) -->
229
+ {#if useCustomModel}
230
+ <div class="mb-2">
231
+ <EngineModelPicker
232
+ engine={commitGen.engine}
233
+ model={commitGen.model}
234
+ onEngineChange={handleCommitEngineChange}
235
+ onModelChange={handleCommitModelChange}
236
+ />
237
+ </div>
238
+ {/if}
239
+ {/if}
357
240
  </div>
@@ -548,7 +548,7 @@
548
548
  {@const gitRemotes = gitPanelRef?.panelActions?.getRemotes() || []}
549
549
 
550
550
  <!-- Remote selector -->
551
- {#if hasRemotes}
551
+ {#if hasRemotes && gitRemotes.length > 1}
552
552
  <div class="relative">
553
553
  <button
554
554
  type="button"
@@ -586,8 +586,6 @@
586
586
  </div>
587
587
  {/if}
588
588
  </div>
589
- {:else if gitPanelRef?.panelActions?.getIsRepo()}
590
- <span class="text-xs text-slate-400 italic px-1">no remote</span>
591
589
  {/if}
592
590
 
593
591
  <!-- Fetch -->
@@ -19,13 +19,14 @@
19
19
  import ModalProvider from '$frontend/components/common/overlay/ModalProvider.svelte';
20
20
  import SettingsModal from '$frontend/components/settings/SettingsModal.svelte';
21
21
  import HistoryModal from '$frontend/components/history/HistoryModal.svelte';
22
+ import NotificationToast from '$frontend/components/common/feedback/NotificationToast.svelte';
22
23
 
23
24
  // Services
24
25
  import { initializeTheme } from '$frontend/utils/theme';
25
26
  import { initializeStore } from '$frontend/stores/core/app.svelte';
26
27
  import { initializeProjects } from '$frontend/stores/core/projects.svelte';
27
28
  import { initializeSessions } from '$frontend/stores/core/sessions.svelte';
28
- import { initializeNotifications } from '$frontend/stores/ui/notification.svelte';
29
+ import { initializeNotifications, notificationStore } from '$frontend/stores/ui/notification.svelte';
29
30
  import { applyServerSettings, loadSystemSettings } from '$frontend/stores/features/settings.svelte';
30
31
  import { initPresence } from '$frontend/stores/core/presence.svelte';
31
32
  import ws from '$frontend/utils/ws';
@@ -163,3 +164,12 @@
163
164
 
164
165
  <!-- History Modal -->
165
166
  <HistoryModal bind:isOpen={showHistoryModal} onClose={closeHistoryModal} />
167
+
168
+ <!-- Toast Notifications -->
169
+ {#if notificationStore.notifications.length > 0}
170
+ <div class="fixed top-4 right-4 z-[200] flex flex-col gap-2">
171
+ {#each notificationStore.notifications as notification (notification.id)}
172
+ <NotificationToast {notification} />
173
+ {/each}
174
+ </div>
175
+ {/if}
@@ -139,6 +139,8 @@
139
139
  // Container width detection for 2-column layout
140
140
  let containerRef = $state<HTMLDivElement | null>(null);
141
141
  let containerWidth = $state(0);
142
+ let leftPanelWidth = $state(288); // default w-72
143
+ let isResizing = $state(false);
142
144
  const TWO_COLUMN_THRESHOLD = $derived(Math.round(600 * (settings.fontSize / 13)));
143
145
 
144
146
  // FileTree ref
@@ -1101,6 +1103,26 @@
1101
1103
  }
1102
1104
  });
1103
1105
 
1106
+ function startColumnResize(e: MouseEvent) {
1107
+ isResizing = true;
1108
+ const startX = e.clientX;
1109
+ const startWidth = leftPanelWidth;
1110
+
1111
+ function onMouseMove(e: MouseEvent) {
1112
+ const delta = e.clientX - startX;
1113
+ leftPanelWidth = Math.max(120, Math.min(startWidth + delta, containerWidth - 120));
1114
+ }
1115
+
1116
+ function onMouseUp() {
1117
+ isResizing = false;
1118
+ window.removeEventListener('mousemove', onMouseMove);
1119
+ window.removeEventListener('mouseup', onMouseUp);
1120
+ }
1121
+
1122
+ window.addEventListener('mousemove', onMouseMove);
1123
+ window.addEventListener('mouseup', onMouseUp);
1124
+ }
1125
+
1104
1126
  // Monitor container width for responsive layout
1105
1127
  onMount(() => {
1106
1128
  if (containerRef && typeof ResizeObserver !== 'undefined') {
@@ -1182,12 +1204,13 @@
1182
1204
  {:else}
1183
1205
  <div class="flex-1 overflow-hidden">
1184
1206
  <!-- Unified layout: always render both Tree and Viewer to preserve internal state -->
1185
- <div class="h-full flex">
1207
+ <div class="h-full flex" class:select-none={isResizing} class:cursor-col-resize={isResizing}>
1186
1208
  <!-- Tree panel: always rendered, hidden via CSS in 1-column viewer mode -->
1187
1209
  <div
1188
1210
  class={isTwoColumnMode
1189
- ? 'w-72 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700'
1211
+ ? 'flex-shrink-0 h-full overflow-hidden'
1190
1212
  : (viewMode === 'tree' ? 'w-full h-full overflow-hidden' : 'hidden')}
1213
+ style={isTwoColumnMode ? `width: ${leftPanelWidth}px` : undefined}
1191
1214
  >
1192
1215
  <div class="h-full overflow-auto" bind:this={treeScrollContainer}>
1193
1216
  <FileTree
@@ -1210,7 +1233,22 @@
1210
1233
  </div>
1211
1234
  </div>
1212
1235
 
1213
- <!-- Editor panel: always rendered, hidden via CSS in 1-column tree mode -->
1236
+ {#if isTwoColumnMode}
1237
+ <!-- Column resize handle -->
1238
+ <div
1239
+ class="relative flex-shrink-0 h-full w-px cursor-col-resize group"
1240
+ role="separator"
1241
+ aria-orientation="vertical"
1242
+ onmousedown={startColumnResize}
1243
+ >
1244
+ <!-- Invisible extended hit area (6px each side) -->
1245
+ <div class="absolute inset-y-0 -left-1.5 -right-1.5 cursor-col-resize z-10"></div>
1246
+ <!-- Visual line: 1px default, expands to 4px on hover -->
1247
+ <div class="absolute inset-y-0 left-1/2 -translate-x-1/2 w-px group-hover:w-1 bg-slate-200 dark:bg-slate-700 group-hover:bg-blue-400 dark:group-hover:bg-blue-500 transition-all duration-150"></div>
1248
+ </div>
1249
+ {/if}
1250
+
1251
+ <!-- Editor panel: always rendered, hidden via CSS in 1-column tree mode -->
1214
1252
  <div
1215
1253
  class={isTwoColumnMode
1216
1254
  ? 'flex-1 h-full overflow-hidden flex flex-col'