@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
@@ -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);
@@ -12,6 +12,15 @@ import { createRouter } from '$shared/utils/ws-server';
12
12
  import { messageQueries } from '../../database/queries';
13
13
  import { formatDatabaseMessage } from '$shared/utils/message-formatter';
14
14
 
15
+ function extractTextContent(content: unknown): string {
16
+ if (typeof content === 'string') return content;
17
+ if (!Array.isArray(content)) return '';
18
+ return content
19
+ .filter((c: any) => c.type === 'text')
20
+ .map((b: any) => b.text || '')
21
+ .join(' ');
22
+ }
23
+
15
24
  export const crudHandler = createRouter()
16
25
  // List messages
17
26
  .http('messages:list', {
@@ -33,6 +42,49 @@ export const crudHandler = createRouter()
33
42
  }
34
43
  })
35
44
 
45
+ // Bulk session preview — returns title, summary, and message counts for multiple sessions
46
+ // without loading all messages. Used by the Sessions/History modal.
47
+ .http('sessions:preview', {
48
+ data: t.Object({
49
+ session_ids: t.Array(t.String())
50
+ }),
51
+ response: t.Array(t.Any())
52
+ }, ({ data }) => {
53
+ return data.session_ids.map(sessionId => {
54
+ const preview = messageQueries.getSessionPreview(sessionId);
55
+
56
+ // Title from first user message
57
+ let title = 'New Conversation';
58
+ if (preview.firstUserMessage) {
59
+ const sdk = JSON.parse(preview.firstUserMessage.sdk_message);
60
+ const textContent = extractTextContent(sdk.message?.content).trim();
61
+ if (textContent) {
62
+ title = textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '');
63
+ }
64
+ }
65
+
66
+ // Summary from last assistant message
67
+ let summary = 'No messages yet';
68
+ if (preview.lastAssistantMessage) {
69
+ const sdk = JSON.parse(preview.lastAssistantMessage.sdk_message);
70
+ const rawText = extractTextContent(sdk.message?.content);
71
+ const cleanText = rawText.replace(/```[\s\S]*?```/g, '').trim();
72
+ if (cleanText) {
73
+ summary = cleanText.slice(0, 100) + (cleanText.length > 100 ? '...' : '');
74
+ }
75
+ }
76
+
77
+ return {
78
+ session_id: sessionId,
79
+ title,
80
+ summary,
81
+ userCount: preview.userCount,
82
+ assistantCount: preview.assistantCount,
83
+ count: preview.userCount + preview.assistantCount
84
+ };
85
+ });
86
+ })
87
+
36
88
  // Get message by ID
37
89
  .http('messages:get', {
38
90
  data: t.Object({
@@ -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 };
package/bin/clopen.ts CHANGED
@@ -23,6 +23,7 @@ if (typeof globalThis.Bun === 'undefined') {
23
23
 
24
24
  import { existsSync, copyFileSync, readFileSync, writeFileSync } from 'fs';
25
25
  import { join } from 'path';
26
+ import { loadEnvFile } from '../backend/utils/env';
26
27
 
27
28
  // CLI Options interface
28
29
  interface CLIOptions {
@@ -88,7 +89,7 @@ const MAX_PORT = 65535;
88
89
 
89
90
  function showHelp() {
90
91
  console.log(`
91
- Clopen - Modern web UI for Claude Code
92
+ Clopen - All-in-one web workspace for Claude Code & OpenCode
92
93
 
93
94
  USAGE:
94
95
  clopen [OPTIONS]
@@ -106,7 +107,7 @@ OPTIONS:
106
107
 
107
108
  EXAMPLES:
108
109
  clopen # Start with default settings (port ${DEFAULT_PORT})
109
- clopen --port 9150 # Start on port 9150
110
+ clopen --port 9145 # Start on port 9145
110
111
  clopen --host 0.0.0.0 # Bind to all network interfaces
111
112
  clopen update # Update to the latest version
112
113
  clopen reset-pat # Regenerate admin login token
@@ -262,8 +263,8 @@ async function recoverAdminToken() {
262
263
  console.log(`\x1b[36mClopen\x1b[0m v${version} — Admin Token Recovery\n`);
263
264
 
264
265
  // Initialize database (import dynamically to avoid loading full backend)
265
- const { initializeDatabase } = await import('../backend/lib/database/index');
266
- const { listUsers, regeneratePAT } = await import('../backend/lib/auth/auth-service');
266
+ const { initializeDatabase } = await import('../backend/database/index');
267
+ const { listUsers, regeneratePAT } = await import('../backend/auth/auth-service');
267
268
 
268
269
  await initializeDatabase();
269
270
 
@@ -367,21 +368,20 @@ async function startServer(options: CLIOptions) {
367
368
  updateLoading('Starting server...');
368
369
  await delay();
369
370
 
370
- // Run server as subprocess to ensure it uses local node_modules
371
- const serverPath = join(__dirname, 'backend/index.ts');
371
+ // Delegate to scripts/start.ts handles port resolution (IPv4 + IPv6
372
+ // zombie detection) and starts backend in a single consistent path.
373
+ const startScript = join(__dirname, 'scripts/start.ts');
372
374
 
373
375
  stopLoading();
374
376
 
375
- // Prepare environment variables
376
- const env = { ...process.env };
377
- if (options.port) {
378
- env.PORT = options.port.toString();
379
- }
380
- if (options.host) {
381
- env.HOST = options.host;
382
- }
377
+ // Overlay clopen's own .env on top of process.env to override any
378
+ // pollution from a .env file in the directory where `clopen` was invoked.
379
+ // CLI args take highest priority on top of that.
380
+ const env = { ...process.env, ...loadEnvFile(ENV_FILE) };
381
+ if (options.port) env.PORT = options.port.toString();
382
+ if (options.host) env.HOST = options.host;
383
383
 
384
- const serverProc = Bun.spawn(['bun', serverPath], {
384
+ const serverProc = Bun.spawn(['bun', startScript], {
385
385
  cwd: __dirname,
386
386
  stdout: 'inherit',
387
387
  stderr: 'inherit',
@@ -0,0 +1,31 @@
1
+ services:
2
+ clopen:
3
+ build:
4
+ context: .
5
+ dockerfile_inline: |
6
+ FROM oven/bun:latest
7
+ USER root
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ git \
10
+ curl \
11
+ chromium \
12
+ && rm -rf /var/lib/apt/lists/*
13
+ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
14
+ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
15
+ RUN curl -fsSL https://claude.ai/install.sh | bash
16
+ ENV PATH="/root/.claude/local:$PATH"
17
+ RUN curl -fsSL https://opencode.ai/install | bash
18
+ RUN bun add -g @myrialabs/clopen
19
+ EXPOSE 9141
20
+ CMD ["clopen", "--host", "0.0.0.0"]
21
+ restart: unless-stopped
22
+ ports:
23
+ - "${PORT:-9141}:9141"
24
+ volumes:
25
+ - clopen_data:/root/.clopen
26
+ - ${PROJECTS_DIR:-./projects}:/root/projects
27
+ environment:
28
+ - NODE_ENV=production
29
+
30
+ volumes:
31
+ clopen_data:
@@ -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
 
@@ -100,14 +100,41 @@
100
100
  authModeLoading = true;
101
101
 
102
102
  try {
103
- if (selectedAuthMode === 'none') {
104
- await authStore.setupNoAuth();
105
- completedSteps.add('auth-mode');
106
- completedSteps.add('admin-account');
107
- completedSteps = new Set(completedSteps);
108
- currentStep = 'engines';
103
+ if (!isExistingUser) {
104
+ // Fresh setup — existing behavior
105
+ if (selectedAuthMode === 'none') {
106
+ await authStore.setupNoAuth();
107
+ completedSteps.add('auth-mode');
108
+ completedSteps.add('admin-account');
109
+ completedSteps = new Set(completedSteps);
110
+ currentStep = 'engines';
111
+ } else {
112
+ goToNextStep();
113
+ }
109
114
  } else {
110
- goToNextStep();
115
+ // Returning user (wizard shown again after refresh) — apply selected mode
116
+ const previousMode = authStore.authMode;
117
+ if (selectedAuthMode === 'none' && previousMode !== 'none') {
118
+ // with-auth → no-auth: update mode, skip admin-account
119
+ await authStore.switchToNoAuth();
120
+ completedSteps.add('auth-mode');
121
+ completedSteps.add('admin-account');
122
+ completedSteps = new Set(completedSteps);
123
+ currentStep = 'engines';
124
+ } else if (selectedAuthMode === 'required' && previousMode !== 'required') {
125
+ // no-auth → with-auth: update mode, regenerate PAT, go to admin-account
126
+ await authStore.switchToWithAuth();
127
+ goToNextStep();
128
+ } else if (selectedAuthMode === 'none') {
129
+ // Same mode (none) — skip admin-account, go to engines
130
+ completedSteps.add('auth-mode');
131
+ completedSteps.add('admin-account');
132
+ completedSteps = new Set(completedSteps);
133
+ currentStep = 'engines';
134
+ } else {
135
+ // Same mode (required) — advance to admin-account
136
+ goToNextStep();
137
+ }
111
138
  }
112
139
  } catch (err) {
113
140
  authModeError = err instanceof Error ? err.message : 'Setup failed';
@@ -133,11 +160,16 @@
133
160
  adminLoading = true;
134
161
  try {
135
162
  if (isExistingUser) {
136
- // Existing user — just update name if changed and proceed
163
+ // Existing user — update name if changed
137
164
  if (adminName.trim() !== existingUserName) {
138
165
  await authStore.updateName(adminName.trim());
139
166
  }
140
- goToNextStep();
167
+ // If a PAT was just generated (e.g. switched from no-auth to with-auth), show it
168
+ if (authStore.personalAccessToken) {
169
+ showPAT = true;
170
+ } else {
171
+ goToNextStep();
172
+ }
141
173
  } else {
142
174
  await authStore.setup(adminName.trim());
143
175
  showPAT = true;
@@ -324,8 +356,8 @@
324
356
  });
325
357
 
326
358
  // ─── Step 4: Preferences ───
327
- const FONT_SIZE_MIN = 10;
328
- const FONT_SIZE_MAX = 20;
359
+ const FONT_SIZE_MIN = 8;
360
+ const FONT_SIZE_MAX = 24;
329
361
 
330
362
  function handleFontSizeChange(e: Event) {
331
363
  const value = Number((e.target as HTMLInputElement).value);
@@ -570,7 +602,7 @@
570
602
  {:else if currentStep === 'engines'}
571
603
  <div class="space-y-4">
572
604
  <div class="text-center">
573
- <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>
574
606
  <p class="text-sm text-slate-500 dark:text-slate-400">
575
607
  Check your AI engine installations.
576
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) {