@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
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { query, type SDKMessage, type EngineSDKMessage, type Options, type Query, type SDKUserMessage } from '$shared/types/messaging';
10
10
  import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
11
+ import type { StructuredGenerationOptions } from '../../types';
11
12
  import { normalizePath } from './path-utils';
12
13
  import { setupEnvironmentOnce, getEngineEnv } from './environment';
13
14
  import { handleStreamError } from './error-handler';
@@ -238,4 +239,110 @@ export class ClaudeCodeEngine implements AIEngine {
238
239
  this.pendingUserAnswers.delete(toolUseId);
239
240
  return true;
240
241
  }
242
+
243
+ /**
244
+ * One-shot structured JSON generation.
245
+ * Uses query() with no tools, outputFormat, and maxTurns: 1.
246
+ */
247
+ async generateStructured<T = unknown>(options: StructuredGenerationOptions): Promise<T> {
248
+ const {
249
+ prompt,
250
+ model = 'haiku',
251
+ schema,
252
+ projectPath,
253
+ abortController,
254
+ claudeAccountId
255
+ } = options;
256
+
257
+ if (!this._isInitialized) {
258
+ await this.initialize();
259
+ }
260
+
261
+ const controller = abortController || new AbortController();
262
+ const normalizedPath = normalizePath(projectPath);
263
+
264
+ // Optimized for one-shot structured generation:
265
+ // - tools: [] prevents tool use (no agentic loops)
266
+ // - persistSession: false skips writing session to disk
267
+ // - effort: 'low' reduces processing overhead for simple tasks
268
+ // - thinking disabled removes reasoning overhead
269
+ // - minimal systemPrompt avoids loading heavy defaults
270
+ // - no maxTurns: structured output has its own retry limit
271
+ const sdkOptions: Options = {
272
+ permissionMode: 'bypassPermissions' as PermissionMode,
273
+ allowDangerouslySkipPermissions: true,
274
+ cwd: normalizedPath,
275
+ env: getEngineEnv(claudeAccountId),
276
+ systemPrompt: 'You are a structured data generator. Return JSON matching the provided schema.',
277
+ tools: [],
278
+ outputFormat: {
279
+ type: 'json_schema',
280
+ schema
281
+ },
282
+ persistSession: false,
283
+ effort: 'low',
284
+ thinking: { type: 'disabled' },
285
+ ...(model && { model }),
286
+ abortController: controller
287
+ };
288
+
289
+ // Use plain string prompt — simpler and faster than AsyncIterable
290
+ const queryInstance = query({
291
+ prompt,
292
+ options: sdkOptions
293
+ });
294
+
295
+ let structuredOutput: unknown = null;
296
+ let resultText = '';
297
+ let lastError = '';
298
+
299
+ try {
300
+ for await (const message of queryInstance) {
301
+ debug.log('engine', `[structured] message type=${message.type}, subtype=${'subtype' in message ? message.subtype : 'n/a'}`);
302
+
303
+ if (message.type === 'result') {
304
+ if (message.subtype === 'success') {
305
+ const result = message as any;
306
+ structuredOutput = result.structured_output;
307
+ resultText = result.result || '';
308
+ debug.log('engine', `[structured] success: structured_output=${!!structuredOutput}, resultLen=${resultText.length}`);
309
+ } else {
310
+ const errResult = message as any;
311
+ lastError = errResult.errors?.join('; ') || '';
312
+ const subtype = errResult.subtype || '';
313
+
314
+ // Map SDK error subtypes to user-friendly messages
315
+ if (subtype === 'error_max_structured_output_retries') {
316
+ lastError = 'Failed to generate valid structured output after multiple attempts';
317
+ } else if (subtype === 'error_max_turns') {
318
+ lastError = 'Generation exceeded turn limit';
319
+ } else if (!lastError) {
320
+ lastError = subtype || 'unknown error';
321
+ }
322
+
323
+ debug.error('engine', `[structured] result error: ${lastError}`);
324
+ }
325
+ }
326
+ }
327
+ } catch (error) {
328
+ handleStreamError(error);
329
+ // handleStreamError swallows AbortError — if we reach here without throw, it was cancelled
330
+ throw new Error('Generation was cancelled');
331
+ }
332
+
333
+ if (structuredOutput) {
334
+ return structuredOutput as T;
335
+ }
336
+
337
+ // Fallback: parse the text result as JSON
338
+ if (resultText) {
339
+ try {
340
+ return JSON.parse(resultText) as T;
341
+ } catch {
342
+ debug.warn('engine', `[structured] result text is not valid JSON: ${resultText.slice(0, 200)}`);
343
+ }
344
+ }
345
+
346
+ throw new Error(lastError || 'Claude Code did not return valid structured output');
347
+ }
241
348
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { SDKMessage, SDKUserMessage, EngineSDKMessage } from '$shared/types/messaging';
13
- import type { AIEngine, EngineQueryOptions } from '../../types';
13
+ import type { AIEngine, EngineQueryOptions, StructuredGenerationOptions } from '../../types';
14
14
  import type { EngineModel } from '$shared/types/engine';
15
15
  import type {
16
16
  Provider,
@@ -1010,4 +1010,84 @@ export class OpenCodeEngine implements AIEngine {
1010
1010
 
1011
1011
  return [{ type: 'text', text: '' }];
1012
1012
  }
1013
+
1014
+ /**
1015
+ * One-shot structured JSON generation.
1016
+ * Uses the v1 SDK client.session.prompt() (synchronous) with prompt
1017
+ * engineering for JSON output since v1 doesn't support format option.
1018
+ */
1019
+ async generateStructured<T = unknown>(options: StructuredGenerationOptions): Promise<T> {
1020
+ const {
1021
+ prompt,
1022
+ model = 'claude-sonnet',
1023
+ schema,
1024
+ projectPath,
1025
+ abortController
1026
+ } = options;
1027
+
1028
+ if (!this._isInitialized) {
1029
+ await this.initialize();
1030
+ }
1031
+
1032
+ const client = await ensureClient();
1033
+
1034
+ // Create a temporary session for this one-shot request
1035
+ const sessionResult = await client.session.create({
1036
+ query: { directory: projectPath }
1037
+ });
1038
+ const sessionId = sessionResult.data?.id;
1039
+ if (!sessionId) {
1040
+ throw new Error('Failed to create OpenCode session');
1041
+ }
1042
+
1043
+ // Parse model into providerID/modelID
1044
+ const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : ['', model];
1045
+
1046
+ // Wrap prompt with JSON instruction since v1 doesn't support format option
1047
+ const jsonPrompt = `${prompt}
1048
+
1049
+ IMPORTANT: You MUST respond with ONLY a valid JSON object matching this schema, no other text:
1050
+ ${JSON.stringify(schema, null, 2)}`;
1051
+
1052
+ debug.log('engine', `[OC structured] Sending prompt to session ${sessionId}, model=${model}`);
1053
+
1054
+ // Use v1 SDK synchronous prompt method — waits for completion
1055
+ const response = await client.session.prompt({
1056
+ path: { id: sessionId },
1057
+ body: {
1058
+ parts: [{ type: 'text', text: jsonPrompt }],
1059
+ ...(providerID && modelID ? { model: { providerID, modelID } } : {}),
1060
+ tools: {}
1061
+ },
1062
+ query: { directory: projectPath },
1063
+ ...(abortController?.signal && { signal: abortController.signal })
1064
+ });
1065
+
1066
+ const data = response.data;
1067
+ if (!data) {
1068
+ throw new Error('OpenCode returned empty response');
1069
+ }
1070
+
1071
+ debug.log('engine', `[OC structured] Got response with ${data.parts?.length || 0} parts`);
1072
+
1073
+ // Extract text content from response parts and parse as JSON
1074
+ const textParts = (data.parts || []).filter((p: any) => p.type === 'text');
1075
+ const fullText = textParts.map((p: any) => p.text || '').join('');
1076
+
1077
+ if (!fullText) {
1078
+ throw new Error('OpenCode returned no text content');
1079
+ }
1080
+
1081
+ debug.log('engine', `[OC structured] Raw text: ${fullText.slice(0, 200)}`);
1082
+
1083
+ // Try to extract JSON from the response (may include markdown fences)
1084
+ const jsonMatch = fullText.match(/```(?:json)?\s*([\s\S]*?)```/) || fullText.match(/(\{[\s\S]*\})/);
1085
+ const jsonText = jsonMatch ? jsonMatch[1].trim() : fullText.trim();
1086
+
1087
+ try {
1088
+ return JSON.parse(jsonText) as T;
1089
+ } catch {
1090
+ throw new Error(`OpenCode did not return valid JSON: ${jsonText.slice(0, 200)}`);
1091
+ }
1092
+ }
1013
1093
  }
@@ -24,6 +24,16 @@ export interface EngineQueryOptions {
24
24
  claudeAccountId?: number;
25
25
  }
26
26
 
27
+ /** Options for one-shot structured generation (no tools, no streaming) */
28
+ export interface StructuredGenerationOptions {
29
+ prompt: string;
30
+ model?: string;
31
+ schema: Record<string, unknown>;
32
+ projectPath: string;
33
+ abortController?: AbortController;
34
+ claudeAccountId?: number;
35
+ }
36
+
27
37
  /** The contract every engine adapter must fulfil */
28
38
  export interface AIEngine {
29
39
  /** Engine identifier */
@@ -61,4 +71,11 @@ export interface AIEngine {
61
71
  * Unblocks the canUseTool callback so the SDK can continue.
62
72
  */
63
73
  resolveUserAnswer?(toolUseId: string, answers: Record<string, string>): boolean;
74
+
75
+ /**
76
+ * One-shot structured JSON generation (no tools, no streaming).
77
+ * Returns parsed JSON matching the provided schema.
78
+ * Optional — engines that don't support it leave undefined.
79
+ */
80
+ generateStructured?<T = unknown>(options: StructuredGenerationOptions): Promise<T>;
64
81
  }
@@ -317,7 +317,8 @@ export class GitService {
317
317
  }
318
318
 
319
319
  async fetch(cwd: string, remote = 'origin'): Promise<string> {
320
- const result = await execGit(['fetch', remote, '--prune'], cwd, 60000);
320
+ // Use explicit refspec to ensure all branches are fetched regardless of clone config
321
+ const result = await execGit(['fetch', remote, `+refs/heads/*:refs/remotes/${remote}/*`, '--prune'], cwd, 60000);
321
322
  if (result.exitCode !== 0) {
322
323
  throw new Error(`git fetch failed: ${result.stderr}`);
323
324
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Git Commit Message Generator Handler
3
+ *
4
+ * Uses AI engines to generate structured commit messages from staged diffs.
5
+ */
6
+
7
+ import { t } from 'elysia';
8
+ import { createRouter } from '$shared/utils/ws-server';
9
+ import { execGit } from '../../git/git-executor';
10
+ import { projectQueries } from '../../database/queries/project-queries';
11
+ import { initializeEngine } from '../../engine';
12
+ import { parseModelId } from '$shared/constants/engines';
13
+ import type { EngineType } from '$shared/types/engine';
14
+ import type { GeneratedCommitMessage } from '$shared/types/git';
15
+ import { debug } from '$shared/utils/logger';
16
+
17
+ const COMMIT_MESSAGE_SCHEMA = {
18
+ type: 'object',
19
+ properties: {
20
+ type: {
21
+ type: 'string',
22
+ enum: ['feat', 'fix', 'refactor', 'docs', 'test', 'chore', 'style', 'perf', 'ci', 'build'],
23
+ description: 'The conventional commit type'
24
+ },
25
+ scope: {
26
+ type: 'string',
27
+ description: 'Optional scope of the change (e.g., component name, module)'
28
+ },
29
+ subject: {
30
+ type: 'string',
31
+ description: 'Short imperative description, lowercase, no period, max 72 chars'
32
+ },
33
+ body: {
34
+ type: 'string',
35
+ description: 'Optional longer description explaining the why behind the change'
36
+ }
37
+ },
38
+ required: ['type', 'subject']
39
+ };
40
+
41
+ export const commitMessageHandler = createRouter()
42
+ .http('git:generate-commit-message', {
43
+ data: t.Object({
44
+ projectId: t.String(),
45
+ engine: t.String(),
46
+ model: t.String(),
47
+ format: t.Union([t.Literal('single-line'), t.Literal('multi-line')])
48
+ }),
49
+ response: t.Object({
50
+ message: t.String()
51
+ })
52
+ }, async ({ data }) => {
53
+ const project = projectQueries.getById(data.projectId);
54
+ if (!project) throw new Error('Project not found');
55
+
56
+ // Get raw staged diff text
57
+ const diffResult = await execGit(['diff', '--cached'], project.path);
58
+ const rawDiff = diffResult.stdout;
59
+
60
+ if (!rawDiff.trim()) {
61
+ throw new Error('No staged changes to generate a commit message for');
62
+ }
63
+
64
+ const engineType = data.engine as EngineType;
65
+ const engine = await initializeEngine(engineType);
66
+
67
+ if (!engine.generateStructured) {
68
+ throw new Error(`Engine "${engineType}" does not support structured generation`);
69
+ }
70
+
71
+ const formatInstruction = data.format === 'multi-line'
72
+ ? 'Generate a multi-line conventional commit message with type, scope, subject, AND body fields. The body should explain WHY the change was made.'
73
+ : 'Generate a single-line conventional commit message with type, optional scope, and subject. Leave body empty.';
74
+
75
+ const prompt = `Analyze the following git diff and generate a conventional commit message.
76
+
77
+ Rules:
78
+ - type: one of feat, fix, refactor, docs, test, chore, style, perf, ci, build
79
+ - scope: optional, the area of the codebase affected (e.g., git, settings, engine)
80
+ - subject: imperative mood, lowercase, no period at end, max 72 characters
81
+ - ${formatInstruction}
82
+
83
+ Git diff:
84
+ ${rawDiff}`;
85
+
86
+ // Parse model ID: "claude-code:haiku" → modelId "haiku", "opencode:anthropic/claude-sonnet" → modelId "anthropic/claude-sonnet"
87
+ const { modelId } = parseModelId(data.model);
88
+
89
+ debug.log('git', `Generating commit message via ${engineType}/${modelId}`);
90
+
91
+ const result = await engine.generateStructured<GeneratedCommitMessage>({
92
+ prompt,
93
+ model: modelId,
94
+ schema: COMMIT_MESSAGE_SCHEMA,
95
+ projectPath: project.path
96
+ });
97
+
98
+ // Format structured output into conventional commit string
99
+ const scopePart = result.scope ? `(${result.scope})` : '';
100
+ const subject = `${result.type}${scopePart}: ${result.subject}`;
101
+
102
+ let message = subject;
103
+ if (data.format === 'multi-line' && result.body) {
104
+ message = `${subject}\n\n${result.body}`;
105
+ }
106
+
107
+ return { message };
108
+ });
@@ -12,6 +12,7 @@ import { branchHandler } from './branch';
12
12
  import { logHandler } from './log';
13
13
  import { remoteHandler } from './remote';
14
14
  import { conflictHandler } from './conflict';
15
+ import { commitMessageHandler } from './commit-message';
15
16
 
16
17
  export const gitRouter = createRouter()
17
18
  .merge(statusHandler)
@@ -21,4 +22,5 @@ export const gitRouter = createRouter()
21
22
  .merge(branchHandler)
22
23
  .merge(logHandler)
23
24
  .merge(remoteHandler)
24
- .merge(conflictHandler);
25
+ .merge(conflictHandler)
26
+ .merge(commitMessageHandler);
@@ -8,7 +8,13 @@
8
8
  */
9
9
 
10
10
  import { createRouter } from '$shared/utils/ws-server';
11
+ import { t } from 'elysia';
11
12
  import { operationsHandler } from './operations';
12
13
 
13
14
  export const systemRouter = createRouter()
14
- .merge(operationsHandler);
15
+ .merge(operationsHandler)
16
+ // Declare system:update-completed event (broadcast after successful package update)
17
+ .emit('system:update-completed', t.Object({
18
+ fromVersion: t.String(),
19
+ toVersion: t.String()
20
+ }));
@@ -13,6 +13,10 @@ import { readFileSync } from 'node:fs';
13
13
  import { createRouter } from '$shared/utils/ws-server';
14
14
  import { initializeDatabase, getDatabase } from '../../database';
15
15
  import { debug } from '$shared/utils/logger';
16
+ import { ws } from '$backend/utils/ws';
17
+
18
+ /** In-memory flag: set after successful update, cleared on server restart */
19
+ let pendingUpdate: { fromVersion: string; toVersion: string } | null = null;
16
20
 
17
21
  /** Read current version from package.json */
18
22
  function getCurrentVersion(): string {
@@ -56,7 +60,12 @@ export const operationsHandler = createRouter()
56
60
  response: t.Object({
57
61
  currentVersion: t.String(),
58
62
  latestVersion: t.String(),
59
- updateAvailable: t.Boolean()
63
+ updateAvailable: t.Boolean(),
64
+ pendingRestart: t.Boolean(),
65
+ pendingUpdate: t.Optional(t.Object({
66
+ fromVersion: t.String(),
67
+ toVersion: t.String()
68
+ }))
60
69
  })
61
70
  }, async () => {
62
71
  const currentVersion = getCurrentVersion();
@@ -67,7 +76,13 @@ export const operationsHandler = createRouter()
67
76
 
68
77
  debug.log('server', `Latest version: ${latestVersion}, update available: ${updateAvailable}`);
69
78
 
70
- return { currentVersion, latestVersion, updateAvailable };
79
+ return {
80
+ currentVersion,
81
+ latestVersion,
82
+ updateAvailable,
83
+ pendingRestart: pendingUpdate !== null,
84
+ pendingUpdate: pendingUpdate ?? undefined
85
+ };
71
86
  })
72
87
 
73
88
  // Run package update
@@ -98,9 +113,20 @@ export const operationsHandler = createRouter()
98
113
  throw new Error(`Update failed (exit code ${exitCode}): ${output}`);
99
114
  }
100
115
 
116
+ const fromVersion = getCurrentVersion();
117
+
101
118
  // Re-fetch to confirm new version
102
119
  const newVersion = await fetchLatestVersion();
103
120
 
121
+ // Set pending restart flag (persists until server restarts)
122
+ pendingUpdate = { fromVersion, toVersion: newVersion };
123
+
124
+ // Broadcast to all connected clients
125
+ ws.emit.global('system:update-completed', {
126
+ fromVersion,
127
+ toVersion: newVersion
128
+ });
129
+
104
130
  debug.log('server', `Update completed. New version: ${newVersion}`);
105
131
 
106
132
  return { success: true, output, newVersion };
@@ -4,6 +4,7 @@
4
4
  import WorkspaceLayout from '$frontend/components/workspace/WorkspaceLayout.svelte';
5
5
  import ConnectionBanner from '$frontend/components/common/feedback/ConnectionBanner.svelte';
6
6
  import UpdateBanner from '$frontend/components/common/feedback/UpdateBanner.svelte';
7
+ import RestartRequiredModal from '$frontend/components/common/feedback/RestartRequiredModal.svelte';
7
8
  import LoadingScreen from '$frontend/components/common/feedback/LoadingScreen.svelte';
8
9
  import SetupPage from '$frontend/components/auth/SetupPage.svelte';
9
10
  import LoginPage from '$frontend/components/auth/LoginPage.svelte';
@@ -70,4 +71,6 @@
70
71
  </WorkspaceLayout>
71
72
  </div>
72
73
  </div>
74
+
75
+ <RestartRequiredModal />
73
76
  {/if}
@@ -87,7 +87,7 @@
87
87
  const stepLabels: Record<WizardStep, { label: string; icon: IconName }> = {
88
88
  'auth-mode': { label: 'Login', icon: 'lucide:shield' },
89
89
  'admin-account': { label: 'Account', icon: 'lucide:user-plus' },
90
- 'engines': { label: 'Engines', icon: 'lucide:cpu' },
90
+ 'engines': { label: 'Engines', icon: 'lucide:plug' },
91
91
  'preferences': { label: 'Preferences', icon: 'lucide:palette' }
92
92
  };
93
93
 
@@ -602,7 +602,7 @@
602
602
  {:else if currentStep === 'engines'}
603
603
  <div class="space-y-4">
604
604
  <div class="text-center">
605
- <h2 class="text-base font-semibold text-slate-900 dark:text-slate-100 mb-1">AI Engines</h2>
605
+ <h2 class="text-base font-semibold text-slate-900 dark:text-slate-100 mb-1">Engines</h2>
606
606
  <p class="text-sm text-slate-500 dark:text-slate-400">
607
607
  Check your AI engine installations.
608
608
  </p>
@@ -141,7 +141,7 @@
141
141
 
142
142
  const chatPlaceholder = $derived.by(() => {
143
143
  if (chatBlockedReason === 'no-claude-account') {
144
- return 'No Claude Code account connected. Configure it in Settings → AI Engine → Claude Code → Accounts.';
144
+ return 'No Claude Code account connected. Configure it in Settings → Engines → Claude Code → Accounts.';
145
145
  }
146
146
  if (chatBlockedReason === 'no-model') {
147
147
  return 'No model selected. Please select a model to start chatting.';
@@ -24,7 +24,9 @@
24
24
  import TokenUsageModal from '../modal/TokenUsageModal.svelte';
25
25
  import DebugModal from '../modal/DebugModal.svelte';
26
26
  import Dialog from '$frontend/components/common/overlay/Dialog.svelte';
27
- import ws from '$frontend/utils/ws';
27
+ import ConflictResolutionModal from '$frontend/components/checkpoint/ConflictResolutionModal.svelte';
28
+ import { snapshotService } from '$frontend/services/snapshot/snapshot.service';
29
+ import type { RestoreConflict, ConflictResolution } from '$frontend/services/snapshot/snapshot.service';
28
30
 
29
31
  const {
30
32
  message,
@@ -39,6 +41,11 @@
39
41
  let showTokenUsagePopup = $state(false);
40
42
  let showRestoreConfirm = $state(false);
41
43
 
44
+ // Conflict resolution state
45
+ let showConflictModal = $state(false);
46
+ let conflictList = $state<RestoreConflict[]>([]);
47
+ let processingRestore = $state(false);
48
+
42
49
  // Format timestamp
43
50
  const formatTime = (timestamp?: string) => {
44
51
  if (!timestamp) return 'Unknown';
@@ -268,15 +275,8 @@
268
275
  showDebugPopup = false;
269
276
  }
270
277
 
271
- // Handle restore button click
278
+ // Handle restore button click - check conflicts first
272
279
  async function handleRestore() {
273
- showRestoreConfirm = true;
274
- }
275
-
276
- // Confirm restore action
277
- async function confirmRestore() {
278
- showRestoreConfirm = false;
279
-
280
280
  if (!messageId) {
281
281
  addNotification({
282
282
  type: 'error',
@@ -287,16 +287,41 @@
287
287
  return;
288
288
  }
289
289
 
290
+ const currentSessionId = sessionState.currentSession?.id;
291
+ if (!currentSessionId) return;
292
+
290
293
  try {
291
- // Send restore request via WebSocket HTTP
292
- await ws.http('snapshot:restore', {
294
+ const conflictCheck = await snapshotService.checkConflicts(messageId, currentSessionId);
295
+
296
+ if (conflictCheck.hasConflicts) {
297
+ // Show conflict resolution modal
298
+ conflictList = conflictCheck.conflicts;
299
+ showConflictModal = true;
300
+ } else {
301
+ // No conflicts - show simple confirmation
302
+ showRestoreConfirm = true;
303
+ }
304
+ } catch (error) {
305
+ debug.error('chat', 'Error checking conflicts:', error);
306
+ // Fallback to simple confirmation
307
+ showRestoreConfirm = true;
308
+ }
309
+ }
310
+
311
+ // Execute restore with optional conflict resolutions
312
+ async function executeRestore(resolutions?: ConflictResolution) {
313
+ if (!messageId || !sessionState.currentSession?.id) return;
314
+
315
+ processingRestore = true;
316
+
317
+ try {
318
+ await snapshotService.restore(
293
319
  messageId,
294
- sessionId: sessionState.currentSession?.id || ''
295
- });
320
+ sessionState.currentSession.id,
321
+ resolutions
322
+ );
296
323
 
297
- if (sessionState.currentSession?.id) {
298
- await loadMessagesForSession(sessionState.currentSession.id);
299
- }
324
+ await loadMessagesForSession(sessionState.currentSession.id);
300
325
  } catch (error) {
301
326
  debug.error('chat', 'Restore error:', error);
302
327
  addNotification({
@@ -305,9 +330,22 @@
305
330
  message: error instanceof Error ? error.message : 'Unknown error',
306
331
  duration: 5000
307
332
  });
333
+ } finally {
334
+ processingRestore = false;
308
335
  }
309
336
  }
310
337
 
338
+ // Confirm simple restore (no conflicts)
339
+ async function confirmRestore() {
340
+ showRestoreConfirm = false;
341
+ await executeRestore();
342
+ }
343
+
344
+ // Confirm restore with conflict resolutions
345
+ async function confirmConflictRestore(resolutions: ConflictResolution) {
346
+ await executeRestore(resolutions);
347
+ }
348
+
311
349
  // Handle edit button click
312
350
  async function handleEdit() {
313
351
  if (!messageId) {
@@ -473,6 +511,16 @@ This will restore your conversation to this point.`}
473
511
  }}
474
512
  />
475
513
 
514
+ <!-- Conflict Resolution Modal -->
515
+ <ConflictResolutionModal
516
+ bind:isOpen={showConflictModal}
517
+ conflicts={conflictList}
518
+ onConfirm={confirmConflictRestore}
519
+ onClose={() => {
520
+ conflictList = [];
521
+ }}
522
+ />
523
+
476
524
  <style>
477
525
  /* Animate chevron icon when details are opened */
478
526
  :global(details[open] summary .iconify) {