@myrialabs/clopen 0.2.3 → 0.2.5

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 (53) hide show
  1. package/backend/engine/adapters/claude/stream.ts +107 -0
  2. package/backend/engine/adapters/opencode/message-converter.ts +37 -2
  3. package/backend/engine/adapters/opencode/stream.ts +81 -1
  4. package/backend/engine/types.ts +17 -0
  5. package/backend/git/git-service.ts +2 -1
  6. package/backend/ws/git/commit-message.ts +108 -0
  7. package/backend/ws/git/index.ts +3 -1
  8. package/backend/ws/system/index.ts +7 -1
  9. package/backend/ws/system/operations.ts +28 -2
  10. package/backend/ws/user/crud.ts +6 -3
  11. package/frontend/App.svelte +3 -0
  12. package/frontend/components/auth/SetupPage.svelte +2 -2
  13. package/frontend/components/chat/input/ChatInput.svelte +1 -1
  14. package/frontend/components/chat/message/ChatMessage.svelte +64 -16
  15. package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
  16. package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
  17. package/frontend/components/checkpoint/ConflictResolutionModal.svelte +189 -0
  18. package/frontend/components/checkpoint/TimelineModal.svelte +7 -162
  19. package/frontend/components/common/feedback/RestartRequiredModal.svelte +53 -0
  20. package/frontend/components/common/feedback/UpdateBanner.svelte +17 -6
  21. package/frontend/components/common/media/MediaPreview.svelte +187 -0
  22. package/frontend/components/files/FileViewer.svelte +11 -143
  23. package/frontend/components/git/BranchManager.svelte +143 -155
  24. package/frontend/components/git/CommitForm.svelte +61 -11
  25. package/frontend/components/git/DiffViewer.svelte +50 -130
  26. package/frontend/components/git/FileChangeItem.svelte +22 -0
  27. package/frontend/components/settings/SettingsModal.svelte +1 -1
  28. package/frontend/components/settings/SettingsView.svelte +1 -1
  29. package/frontend/components/settings/engines/AIEnginesSettings.svelte +2 -2
  30. package/frontend/components/settings/general/UpdateSettings.svelte +10 -3
  31. package/frontend/components/settings/git/GitSettings.svelte +392 -0
  32. package/frontend/components/settings/model/EngineModelPicker.svelte +275 -0
  33. package/frontend/components/settings/model/ModelSettings.svelte +172 -289
  34. package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
  35. package/frontend/components/workspace/PanelHeader.svelte +1 -3
  36. package/frontend/components/workspace/WorkspaceLayout.svelte +14 -2
  37. package/frontend/components/workspace/panels/FilesPanel.svelte +76 -1
  38. package/frontend/components/workspace/panels/GitPanel.svelte +84 -33
  39. package/frontend/main.ts +4 -0
  40. package/frontend/stores/core/files.svelte.ts +15 -1
  41. package/frontend/stores/features/settings.svelte.ts +13 -2
  42. package/frontend/stores/ui/settings-modal.svelte.ts +9 -9
  43. package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
  44. package/frontend/stores/ui/update.svelte.ts +45 -4
  45. package/frontend/utils/file-type.ts +68 -0
  46. package/index.html +1 -0
  47. package/package.json +1 -1
  48. package/shared/constants/binary-extensions.ts +40 -0
  49. package/shared/types/git.ts +15 -0
  50. package/shared/types/messaging/tool.ts +1 -0
  51. package/shared/types/stores/settings.ts +12 -0
  52. package/shared/utils/file-type-detection.ts +9 -1
  53. package/static/manifest.json +16 -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
  }
@@ -356,6 +356,35 @@ function normalizeToolInput(claudeToolName: string, raw: OCToolInput): Normalize
356
356
  }
357
357
  }
358
358
 
359
+ // ============================================================
360
+ // Tool Error Detection
361
+ // ============================================================
362
+
363
+ /**
364
+ * Common error prefixes in tool output content.
365
+ * OpenCode SDK may mark a tool as 'completed' even when the output is an error
366
+ * (e.g. "Error: File not found"). These patterns detect such cases.
367
+ */
368
+ const ERROR_CONTENT_PATTERNS = [
369
+ /^Error:\s/i,
370
+ /^ENOENT:\s/i,
371
+ /^EPERM:\s/i,
372
+ /^EACCES:\s/i,
373
+ /^Command failed/i,
374
+ /^Permission denied/i,
375
+ ];
376
+
377
+ /**
378
+ * Determine if a tool result should be marked as is_error.
379
+ * Returns true when the tool part status is 'error', OR when the output
380
+ * content matches a known error pattern (for tools that complete with error output).
381
+ */
382
+ function isToolError(status: string, content: string): boolean {
383
+ if (status === 'error') return true;
384
+ if (!content || status !== 'completed') return false;
385
+ return ERROR_CONTENT_PATTERNS.some(pattern => pattern.test(content));
386
+ }
387
+
359
388
  // ============================================================
360
389
  // Stop Reason Mapping
361
390
  // ============================================================
@@ -483,16 +512,19 @@ export function convertAssistantMessages(
483
512
  };
484
513
 
485
514
  if (toolPart.state.status === 'completed') {
515
+ const output = toolPart.state.output || '';
486
516
  block.$result = {
487
517
  type: 'tool_result',
488
518
  tool_use_id: block.id,
489
- content: toolPart.state.output || '',
519
+ content: output,
520
+ ...(isToolError('completed', output) && { is_error: true }),
490
521
  };
491
522
  } else if (toolPart.state.status === 'error') {
492
523
  block.$result = {
493
524
  type: 'tool_result',
494
525
  tool_use_id: block.id,
495
526
  content: toolPart.state.error || 'Tool execution failed',
527
+ is_error: true,
496
528
  };
497
529
  }
498
530
 
@@ -854,6 +886,8 @@ export function convertToolResultOnly(
854
886
  content = '';
855
887
  }
856
888
 
889
+ const hasError = isToolError(toolPart.state.status, content);
890
+
857
891
  return {
858
892
  type: 'user',
859
893
  uuid: crypto.randomUUID(),
@@ -864,7 +898,8 @@ export function convertToolResultOnly(
864
898
  content: [{
865
899
  type: 'tool_result',
866
900
  tool_use_id: toolUseId,
867
- content
901
+ content,
902
+ ...(hasError && { is_error: true }),
868
903
  }]
869
904
  }
870
905
  } as unknown as SDKMessage;
@@ -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 };
@@ -109,7 +109,8 @@ export const crudHandler = createRouter()
109
109
  currentProjectId: t.Union([t.String(), t.Null()]),
110
110
  lastView: t.Union([t.String(), t.Null()]),
111
111
  settings: t.Union([t.Any(), t.Null()]),
112
- unreadSessions: t.Union([t.Any(), t.Null()])
112
+ unreadSessions: t.Union([t.Any(), t.Null()]),
113
+ todoPanelState: t.Union([t.Any(), t.Null()])
113
114
  })
114
115
  }, async ({ conn }) => {
115
116
  const userId = ws.getUserId(conn);
@@ -118,6 +119,7 @@ export const crudHandler = createRouter()
118
119
  const lastView = getUserState(userId, 'lastView') as string | null;
119
120
  const userSettings = getUserState(userId, 'settings');
120
121
  const unreadSessions = getUserState(userId, 'unreadSessions');
122
+ const todoPanelState = getUserState(userId, 'todoPanelState');
121
123
 
122
124
  debug.log('user', `Restored state for ${userId}:`, {
123
125
  currentProjectId,
@@ -130,7 +132,8 @@ export const crudHandler = createRouter()
130
132
  currentProjectId: currentProjectId ?? null,
131
133
  lastView: lastView ?? null,
132
134
  settings: userSettings ?? null,
133
- unreadSessions: unreadSessions ?? null
135
+ unreadSessions: unreadSessions ?? null,
136
+ todoPanelState: todoPanelState ?? null
134
137
  };
135
138
  })
136
139
 
@@ -147,7 +150,7 @@ export const crudHandler = createRouter()
147
150
  const userId = ws.getUserId(conn);
148
151
 
149
152
  // Validate allowed keys to prevent arbitrary data storage
150
- const allowedKeys = ['currentProjectId', 'lastView', 'settings', 'unreadSessions'];
153
+ const allowedKeys = ['currentProjectId', 'lastView', 'settings', 'unreadSessions', 'todoPanelState'];
151
154
  if (!allowedKeys.includes(data.key)) {
152
155
  throw new Error(`Invalid state key: ${data.key}. Allowed: ${allowedKeys.join(', ')}`);
153
156
  }
@@ -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.';