@myrialabs/clopen 0.2.4 → 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.
@@ -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;
@@ -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
  }
@@ -3,6 +3,15 @@
3
3
  import type { IconName } from '$shared/types/ui/icons';
4
4
  import { getFileIcon } from '$frontend/utils/file-icon-mappings';
5
5
  import { formatPath } from '../../shared/utils';
6
+ import { requestRevealFile } from '$frontend/stores/core/files.svelte';
7
+ import { getVisiblePanels, workspaceState } from '$frontend/stores/ui/workspace.svelte';
8
+
9
+ function handleClick() {
10
+ const visiblePanels = getVisiblePanels(workspaceState.layout);
11
+ if (visiblePanels.includes('files')) {
12
+ requestRevealFile(filePath);
13
+ }
14
+ }
6
15
 
7
16
  interface Props {
8
17
  filePath: string;
@@ -18,10 +27,15 @@
18
27
  </script>
19
28
 
20
29
  <div class={box ? "bg-white dark:bg-slate-800 rounded-md border border-slate-200/60 dark:border-slate-700/60 p-3" : ""}>
21
- <div class="flex items-center gap-3 mb-1">
22
- <Icon
23
- name={getFileIcon(displayFileName)}
24
- class="w-6 h-6 {iconColor || ''}"
30
+ <button
31
+ type="button"
32
+ class="flex items-center gap-3 mb-1 w-full text-left hover:opacity-80 transition-opacity cursor-pointer"
33
+ onclick={handleClick}
34
+ title="Reveal in Files panel"
35
+ >
36
+ <Icon
37
+ name={getFileIcon(displayFileName)}
38
+ class="w-6 h-6 {iconColor || ''}"
25
39
  />
26
40
  <div class="flex-1 min-w-0">
27
41
  <h3 class="font-medium text-slate-900 dark:text-slate-100 truncate">
@@ -31,7 +45,7 @@
31
45
  {formatPath(filePath)}
32
46
  </p>
33
47
  </div>
34
- </div>
48
+ </button>
35
49
 
36
50
  {#if badges.length > 0}
37
51
  <div class="flex gap-2 mt-3">
@@ -8,17 +8,14 @@
8
8
  <script lang="ts">
9
9
  import { sessionState } from '$frontend/stores/core/sessions.svelte';
10
10
  import { appState } from '$frontend/stores/core/app.svelte';
11
+ import { todoPanelState, saveTodoPanelState } from '$frontend/stores/ui/todo-panel.svelte';
11
12
  import Icon from '$frontend/components/common/display/Icon.svelte';
12
13
  import { fly } from 'svelte/transition';
13
14
  import type { TodoWriteToolInput } from '$shared/types/messaging';
14
15
 
15
- let isExpanded = $state(true);
16
- let isMinimized = $state(false);
17
-
18
- // Drag & snap state
19
- let posY = $state(80);
16
+ // Drag-only local state (posX is always transient, posY syncs to store on drop)
17
+ let posY = $state(todoPanelState.posY);
20
18
  let posX = $state(0);
21
- let snapSide = $state<'left' | 'right'>('right');
22
19
  let isDragging = $state(false);
23
20
 
24
21
  // Minimized button ref for measuring width at snap time
@@ -28,18 +25,18 @@
28
25
  let _sx = 0, _sy = 0, _mx = 0, _my = 0, _hasDragged = false;
29
26
 
30
27
  function getPanelWidth() {
31
- return isExpanded ? 330 : 230;
28
+ return todoPanelState.isExpanded ? 330 : 230;
32
29
  }
33
30
 
34
31
  // Always use `left` property so CSS can transition in both directions
35
32
  const panelDisplayLeft = $derived(
36
- isDragging ? posX : snapSide === 'right' ? window.innerWidth - getPanelWidth() - 16 : 16
33
+ isDragging ? posX : todoPanelState.snapSide === 'right' ? window.innerWidth - getPanelWidth() - 16 : 16
37
34
  );
38
35
 
39
36
  const minimizedDisplayLeft = $derived(
40
37
  isDragging
41
38
  ? posX
42
- : snapSide === 'right'
39
+ : todoPanelState.snapSide === 'right'
43
40
  ? window.innerWidth - (minimizedBtn?.offsetWidth ?? 90) - 16
44
41
  : 16
45
42
  );
@@ -69,7 +66,9 @@
69
66
  function endDrag(e: PointerEvent) {
70
67
  if (!isDragging) return;
71
68
  isDragging = false;
72
- snapSide = posX + getPanelWidth() / 2 < window.innerWidth / 2 ? 'left' : 'right';
69
+ todoPanelState.snapSide = posX + getPanelWidth() / 2 < window.innerWidth / 2 ? 'left' : 'right';
70
+ todoPanelState.posY = posY;
71
+ saveTodoPanelState();
73
72
  }
74
73
 
75
74
  // --- Minimized button drag (click = restore, drag = move) ---
@@ -103,7 +102,9 @@
103
102
  return;
104
103
  }
105
104
  const el = e.currentTarget as HTMLElement;
106
- snapSide = posX + el.offsetWidth / 2 < window.innerWidth / 2 ? 'left' : 'right';
105
+ todoPanelState.snapSide = posX + el.offsetWidth / 2 < window.innerWidth / 2 ? 'left' : 'right';
106
+ todoPanelState.posY = posY;
107
+ saveTodoPanelState();
107
108
  }
108
109
 
109
110
  // Extract the latest TodoWrite data from messages
@@ -151,19 +152,22 @@
151
152
  const shouldShow = $derived(latestTodos !== null && latestTodos.length > 0);
152
153
 
153
154
  function toggleExpand() {
154
- if (!isMinimized) {
155
- isExpanded = !isExpanded;
155
+ if (!todoPanelState.isMinimized) {
156
+ todoPanelState.isExpanded = !todoPanelState.isExpanded;
157
+ saveTodoPanelState();
156
158
  }
157
159
  }
158
160
 
159
161
  function minimize() {
160
- isMinimized = true;
161
- isExpanded = false;
162
+ todoPanelState.isMinimized = true;
163
+ todoPanelState.isExpanded = false;
164
+ saveTodoPanelState();
162
165
  }
163
166
 
164
167
  function restore() {
165
- isMinimized = false;
166
- isExpanded = true;
168
+ todoPanelState.isMinimized = false;
169
+ todoPanelState.isExpanded = true;
170
+ saveTodoPanelState();
167
171
  }
168
172
 
169
173
  function getStatusIcon(status: string) {
@@ -194,7 +198,7 @@
194
198
  </script>
195
199
 
196
200
  {#if shouldShow && !appState.isRestoring}
197
- {#if isMinimized}
201
+ {#if todoPanelState.isMinimized}
198
202
  <!-- Minimized state - small floating button, draggable -->
199
203
  <button
200
204
  bind:this={minimizedBtn}
@@ -210,7 +214,7 @@
210
214
  cursor: {isDragging ? 'grabbing' : 'grab'};
211
215
  transition: {isDragging ? 'none' : 'left 0.25s ease, top 0.15s ease'};
212
216
  "
213
- transition:fly={{ x: snapSide === 'right' ? 100 : -100, duration: 200 }}
217
+ transition:fly={{ x: todoPanelState.snapSide === 'right' ? 100 : -100, duration: 200 }}
214
218
  >
215
219
  <Icon name="lucide:list-todo" class="w-5 h-5" />
216
220
  <span class="text-sm font-medium">{progress.completed}/{progress.total}</span>
@@ -222,11 +226,11 @@
222
226
  style="
223
227
  top: {posY}px;
224
228
  left: {panelDisplayLeft}px;
225
- width: {isExpanded ? '330px' : '230px'};
226
- max-height: {isExpanded ? '600px' : '56px'};
229
+ width: {todoPanelState.isExpanded ? '330px' : '230px'};
230
+ max-height: {todoPanelState.isExpanded ? '600px' : '56px'};
227
231
  transition: {isDragging ? 'none' : 'left 0.25s ease, top 0.15s ease, width 0.3s, max-height 0.3s'};
228
232
  "
229
- transition:fly={{ x: snapSide === 'right' ? 100 : -100, duration: 300 }}
233
+ transition:fly={{ x: todoPanelState.snapSide === 'right' ? 100 : -100, duration: 300 }}
230
234
  >
231
235
  <!-- Header (drag handle) -->
232
236
  <div
@@ -244,7 +248,7 @@
244
248
  <span class="text-sm font-semibold text-slate-900 dark:text-slate-100">
245
249
  Task Progress
246
250
  </span>
247
- {#if !isExpanded}
251
+ {#if !todoPanelState.isExpanded}
248
252
  <span class="text-xs text-slate-600 dark:text-slate-400">
249
253
  {progress.completed}/{progress.total} tasks ({progress.percentage}%)
250
254
  </span>
@@ -256,10 +260,10 @@
256
260
  <button
257
261
  onclick={toggleExpand}
258
262
  class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
259
- title={isExpanded ? 'Collapse' : 'Expand'}
263
+ title={todoPanelState.isExpanded ? 'Collapse' : 'Expand'}
260
264
  >
261
265
  <Icon
262
- name={isExpanded ? 'lucide:chevron-up' : 'lucide:chevron-down'}
266
+ name={todoPanelState.isExpanded ? 'lucide:chevron-up' : 'lucide:chevron-down'}
263
267
  class="w-4 h-4 text-slate-600 dark:text-slate-400"
264
268
  />
265
269
  </button>
@@ -273,7 +277,7 @@
273
277
  </div>
274
278
  </div>
275
279
 
276
- {#if isExpanded}
280
+ {#if todoPanelState.isExpanded}
277
281
  <!-- Progress bar -->
278
282
  <div class="px-4 py-3 border-b border-slate-100 dark:border-slate-800">
279
283
  <div class="flex items-center justify-between mb-2">
@@ -0,0 +1,187 @@
1
+ <script lang="ts">
2
+ import { onDestroy, untrack } from 'svelte';
3
+ import Icon from '$frontend/components/common/display/Icon.svelte';
4
+ import LoadingSpinner from '$frontend/components/common/feedback/LoadingSpinner.svelte';
5
+ import { isImageFile, isSvgFile, isPdfFile, isAudioFile, isVideoFile } from '$frontend/utils/file-type';
6
+ import { debug } from '$shared/utils/logger';
7
+ import ws from '$frontend/utils/ws';
8
+
9
+ interface Props {
10
+ /** File name for type detection */
11
+ fileName: string;
12
+ /** Absolute path used to load binary content via ws */
13
+ filePath: string;
14
+ /** Optional text content for SVG inline rendering */
15
+ svgContent?: string;
16
+ }
17
+
18
+ const { fileName, filePath, svgContent }: Props = $props();
19
+
20
+ let blobUrl = $state<string | null>(null);
21
+ let pdfBlobUrl = $state<string | null>(null);
22
+ let mediaBlobUrl = $state<string | null>(null);
23
+ let isLoading = $state(false);
24
+
25
+ function cleanup() {
26
+ if (blobUrl) {
27
+ URL.revokeObjectURL(blobUrl);
28
+ blobUrl = null;
29
+ }
30
+ if (pdfBlobUrl) {
31
+ URL.revokeObjectURL(pdfBlobUrl);
32
+ pdfBlobUrl = null;
33
+ }
34
+ if (mediaBlobUrl) {
35
+ URL.revokeObjectURL(mediaBlobUrl);
36
+ mediaBlobUrl = null;
37
+ }
38
+ }
39
+
40
+ async function loadBinaryContent(path: string, name: string) {
41
+ isLoading = true;
42
+ try {
43
+ const response = await ws.http('files:read-content', { path });
44
+
45
+ if (response.content) {
46
+ const binaryString = atob(response.content);
47
+ const bytes = new Uint8Array(binaryString.length);
48
+ for (let i = 0; i < binaryString.length; i++) {
49
+ bytes[i] = binaryString.charCodeAt(i);
50
+ }
51
+ const blob = new Blob([bytes], { type: response.contentType || 'application/octet-stream' });
52
+
53
+ if (isPdfFile(name)) {
54
+ if (pdfBlobUrl) URL.revokeObjectURL(pdfBlobUrl);
55
+ pdfBlobUrl = URL.createObjectURL(blob);
56
+ } else if (isAudioFile(name) || isVideoFile(name)) {
57
+ if (mediaBlobUrl) URL.revokeObjectURL(mediaBlobUrl);
58
+ mediaBlobUrl = URL.createObjectURL(blob);
59
+ } else {
60
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
61
+ blobUrl = URL.createObjectURL(blob);
62
+ }
63
+ }
64
+ } catch (err) {
65
+ debug.error('file', 'Failed to load binary content:', err);
66
+ } finally {
67
+ isLoading = false;
68
+ }
69
+ }
70
+
71
+ $effect(() => {
72
+ const name = fileName;
73
+ const path = filePath;
74
+ untrack(() => {
75
+ cleanup();
76
+ if (name && path) {
77
+ loadBinaryContent(path, name);
78
+ }
79
+ });
80
+ });
81
+
82
+ onDestroy(cleanup);
83
+ </script>
84
+
85
+ {#if isImageFile(fileName)}
86
+ <div class="flex items-center justify-center h-full p-4 overflow-hidden checkerboard-bg">
87
+ {#if isLoading}
88
+ <LoadingSpinner size="lg" />
89
+ {:else if blobUrl}
90
+ <img
91
+ src={blobUrl}
92
+ alt={fileName}
93
+ class="max-w-full max-h-full object-contain"
94
+ />
95
+ {:else}
96
+ <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
97
+ <Icon name="lucide:image-off" class="w-8 h-8 opacity-40" />
98
+ <span>Failed to load preview</span>
99
+ </div>
100
+ {/if}
101
+ </div>
102
+ {:else if isSvgFile(fileName)}
103
+ <div class="flex items-center justify-center h-full p-4 overflow-auto checkerboard-bg">
104
+ {#if isLoading}
105
+ <LoadingSpinner size="lg" />
106
+ {:else if blobUrl}
107
+ <img
108
+ src={blobUrl}
109
+ alt={fileName}
110
+ class="max-w-full max-h-full object-contain"
111
+ />
112
+ {:else if svgContent}
113
+ <div class="max-w-full max-h-full flex items-center justify-center">
114
+ {@html svgContent}
115
+ </div>
116
+ {:else}
117
+ <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
118
+ <Icon name="lucide:image-off" class="w-8 h-8 opacity-40" />
119
+ <span>Failed to load preview</span>
120
+ </div>
121
+ {/if}
122
+ </div>
123
+ {:else if isPdfFile(fileName)}
124
+ <div class="h-full w-full">
125
+ {#if isLoading}
126
+ <div class="flex items-center justify-center h-full">
127
+ <LoadingSpinner size="lg" />
128
+ </div>
129
+ {:else if pdfBlobUrl}
130
+ <iframe
131
+ src={pdfBlobUrl}
132
+ title={fileName}
133
+ class="w-full h-full border-0"
134
+ ></iframe>
135
+ {:else}
136
+ <div class="flex flex-col items-center justify-center h-full gap-2 text-slate-500 text-xs">
137
+ <Icon name="lucide:file-x" class="w-8 h-8 opacity-40" />
138
+ <span>Failed to load PDF preview</span>
139
+ </div>
140
+ {/if}
141
+ </div>
142
+ {:else if isAudioFile(fileName)}
143
+ <div class="flex flex-col items-center justify-center h-full p-8 checkerboard-bg">
144
+ <Icon name="lucide:music" class="w-16 h-16 text-violet-400 mb-6" />
145
+ <h3 class="text-sm font-semibold text-slate-900 dark:text-slate-100 mb-4">
146
+ {fileName}
147
+ </h3>
148
+ {#if isLoading}
149
+ <LoadingSpinner size="lg" />
150
+ {:else if mediaBlobUrl}
151
+ <audio controls class="w-full max-w-md" src={mediaBlobUrl}>
152
+ Your browser does not support the audio element.
153
+ </audio>
154
+ {:else}
155
+ <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
156
+ <Icon name="lucide:music" class="w-8 h-8 opacity-40" />
157
+ <span>Failed to load audio</span>
158
+ </div>
159
+ {/if}
160
+ </div>
161
+ {:else if isVideoFile(fileName)}
162
+ <div class="flex items-center justify-center h-full p-4 overflow-hidden checkerboard-bg">
163
+ {#if isLoading}
164
+ <LoadingSpinner size="lg" />
165
+ {:else if mediaBlobUrl}
166
+ <!-- svelte-ignore a11y_media_has_caption -->
167
+ <video controls class="max-w-full max-h-full object-contain" src={mediaBlobUrl}>
168
+ Your browser does not support the video element.
169
+ </video>
170
+ {:else}
171
+ <div class="flex flex-col items-center gap-2 text-slate-500 text-xs">
172
+ <Icon name="lucide:video-off" class="w-8 h-8 opacity-40" />
173
+ <span>Failed to load video</span>
174
+ </div>
175
+ {/if}
176
+ </div>
177
+ {/if}
178
+
179
+ <style>
180
+ .checkerboard-bg {
181
+ background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%3E%3Crect%20width%3D%2220%22%20height%3D%2220%22%20fill%3D%22%23f0f0f0%22%2F%3E%3Crect%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23e0e0e0%22%2F%3E%3Crect%20x%3D%2210%22%20y%3D%2210%22%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23e0e0e0%22%2F%3E%3C%2Fsvg%3E');
182
+ }
183
+
184
+ :global(.dark) .checkerboard-bg {
185
+ background-image: url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2220%22%20height%3D%2220%22%3E%3Crect%20width%3D%2220%22%20height%3D%2220%22%20fill%3D%22%23181818%22%2F%3E%3Crect%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23222222%22%2F%3E%3Crect%20x%3D%2210%22%20y%3D%2210%22%20width%3D%2210%22%20height%3D%2210%22%20fill%3D%22%23222222%22%2F%3E%3C%2Fsvg%3E');
186
+ }
187
+ </style>