@myrialabs/clopen 0.2.3 → 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 (35) hide show
  1. package/backend/engine/adapters/claude/stream.ts +107 -0
  2. package/backend/engine/adapters/opencode/stream.ts +81 -1
  3. package/backend/engine/types.ts +17 -0
  4. package/backend/git/git-service.ts +2 -1
  5. package/backend/ws/git/commit-message.ts +108 -0
  6. package/backend/ws/git/index.ts +3 -1
  7. package/backend/ws/system/index.ts +7 -1
  8. package/backend/ws/system/operations.ts +28 -2
  9. package/frontend/App.svelte +3 -0
  10. package/frontend/components/auth/SetupPage.svelte +2 -2
  11. package/frontend/components/chat/input/ChatInput.svelte +1 -1
  12. package/frontend/components/chat/message/ChatMessage.svelte +64 -16
  13. package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
  14. package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
  15. package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
  16. package/frontend/components/common/feedback/UpdateBanner.svelte +17 -6
  17. package/frontend/components/git/BranchManager.svelte +143 -155
  18. package/frontend/components/git/CommitForm.svelte +61 -11
  19. package/frontend/components/settings/SettingsModal.svelte +1 -1
  20. package/frontend/components/settings/SettingsView.svelte +1 -1
  21. package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
  22. package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
  23. package/frontend/components/settings/git/GitSettings.svelte +392 -0
  24. package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
  25. package/frontend/components/settings/model/ModelSettings.svelte +172 -289
  26. package/frontend/components/workspace/PanelHeader.svelte +1 -3
  27. package/frontend/components/workspace/WorkspaceLayout.svelte +11 -1
  28. package/frontend/components/workspace/panels/GitPanel.svelte +12 -5
  29. package/frontend/main.ts +4 -0
  30. package/frontend/stores/features/settings.svelte.ts +13 -2
  31. package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
  32. package/frontend/stores/ui/update.svelte.ts +45 -4
  33. package/package.json +1 -1
  34. package/shared/types/git.ts +15 -0
  35. package/shared/types/stores/settings.ts +12 -0
@@ -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}
@@ -674,7 +674,7 @@
674
674
  isPulling = true;
675
675
  try {
676
676
  const prevBehind = branchInfo?.behind ?? 0;
677
- const result = await ws.http('git:pull', { projectId, remote: selectedRemote });
677
+ const result = await ws.http('git:pull', { projectId, remote: selectedRemote, branch: branchInfo?.current });
678
678
  if (!result.success) {
679
679
  if (result.message.includes('conflict')) {
680
680
  await loadAll();
@@ -704,7 +704,7 @@
704
704
  isPushing = true;
705
705
  try {
706
706
  const prevAhead = branchInfo?.ahead ?? 0;
707
- const result = await ws.http('git:push', { projectId, remote: selectedRemote });
707
+ const result = await ws.http('git:push', { projectId, remote: selectedRemote, branch: branchInfo?.current });
708
708
  if (!result.success) {
709
709
  showError('Push Failed', result.message);
710
710
  } else {
@@ -971,8 +971,14 @@
971
971
  if (changeDebounce) clearTimeout(changeDebounce);
972
972
  changeDebounce = setTimeout(async () => {
973
973
  changeDebounce = null;
974
- // Refresh git status
975
- await loadStatus();
974
+ // Refresh git status and branches (branch switch also modifies working tree)
975
+ const prevBranch = branchInfo?.current;
976
+ await Promise.all([loadStatus(), loadBranches()]);
977
+
978
+ // If branch changed, also refresh remotes
979
+ if (branchInfo?.current !== prevBranch) {
980
+ loadRemotes();
981
+ }
976
982
 
977
983
  // Refresh the active diff tab if currently viewing one
978
984
  if (activeTab && !activeTab.isLoading && activeTab.section !== 'commit') {
@@ -1016,8 +1022,9 @@
1016
1022
  const unsub = ws.on('git:changed', (payload: any) => {
1017
1023
  if (payload.projectId !== projectId || !isRepo) return;
1018
1024
  scheduleGitRefresh();
1019
- // Also refresh branches in case of branch switch/create/delete
1025
+ // Refresh branches and remotes in case of branch switch/create/delete
1020
1026
  loadBranches();
1027
+ loadRemotes();
1021
1028
  // Refresh log if it was already loaded (History tab was visited)
1022
1029
  if (commits.length > 0) {
1023
1030
  loadLog(true);
package/frontend/main.ts CHANGED
@@ -6,6 +6,10 @@ import { mount } from 'svelte';
6
6
  import './app.css';
7
7
  import App from './App.svelte';
8
8
 
9
+ if (import.meta.env.DEV) {
10
+ document.title = 'Clopen - DEV';
11
+ }
12
+
9
13
  const app = mount(App, {
10
14
  target: document.getElementById('app')!
11
15
  });