@myrialabs/clopen 0.1.7 → 0.1.9

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 (28) hide show
  1. package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
  2. package/backend/lib/database/migrations/index.ts +7 -0
  3. package/backend/lib/database/queries/session-queries.ts +37 -0
  4. package/backend/lib/git/git-service.ts +1 -0
  5. package/backend/ws/sessions/crud.ts +34 -2
  6. package/backend/ws/user/crud.ts +8 -4
  7. package/bun.lock +34 -12
  8. package/frontend/lib/components/common/MonacoEditor.svelte +6 -6
  9. package/frontend/lib/components/common/xterm/XTerm.svelte +27 -108
  10. package/frontend/lib/components/common/xterm/terminal-config.ts +2 -2
  11. package/frontend/lib/components/common/xterm/types.ts +1 -0
  12. package/frontend/lib/components/common/xterm/xterm-service.ts +69 -20
  13. package/frontend/lib/components/files/FileTree.svelte +4 -6
  14. package/frontend/lib/components/files/FileViewer.svelte +45 -101
  15. package/frontend/lib/components/git/CommitForm.svelte +1 -1
  16. package/frontend/lib/components/git/GitLog.svelte +141 -101
  17. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
  18. package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
  19. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +3 -3
  20. package/frontend/lib/components/terminal/Terminal.svelte +1 -1
  21. package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
  22. package/frontend/lib/components/workspace/PanelHeader.svelte +639 -623
  23. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
  24. package/frontend/lib/components/workspace/panels/GitPanel.svelte +34 -92
  25. package/frontend/lib/stores/core/app.svelte.ts +46 -0
  26. package/frontend/lib/stores/core/sessions.svelte.ts +24 -3
  27. package/frontend/lib/stores/ui/workspace.svelte.ts +14 -14
  28. package/package.json +8 -6
@@ -14,6 +14,25 @@
14
14
  const { commits, isLoading, hasMore, onLoadMore, onViewCommit }: Props = $props();
15
15
 
16
16
  let selectedHash = $state('');
17
+ let sentinelEl = $state<HTMLDivElement | null>(null);
18
+
19
+ // Infinite scroll: auto load more when sentinel is visible
20
+ $effect(() => {
21
+ const el = sentinelEl;
22
+ if (!el || !hasMore) return;
23
+
24
+ const observer = new IntersectionObserver(
25
+ (entries) => {
26
+ if (entries[0]?.isIntersecting && hasMore && !isLoading) {
27
+ onLoadMore();
28
+ }
29
+ },
30
+ { rootMargin: '100px' }
31
+ );
32
+
33
+ observer.observe(el);
34
+ return () => observer.disconnect();
35
+ });
17
36
 
18
37
  // ========================
19
38
  // Git Graph Computation
@@ -25,14 +44,13 @@
25
44
  lanes: Array<{ col: number; color: string }>;
26
45
  mergeFrom: Array<{ col: number; color: string }>;
27
46
  branchTo: Array<{ col: number; color: string }>;
28
- /** Which lane columns existed in the previous row (for drawing top half of lines) */
47
+ branchToCols: Set<number>;
29
48
  prevLaneCols: Set<number>;
30
- /** Which lane columns will exist in the next row (for drawing bottom half of lines) */
31
49
  nextLaneCols: Set<number>;
32
- /** Whether this node's lane existed in the previous row */
33
50
  nodeHasTop: boolean;
34
- /** Whether this node's lane continues to the next row */
35
51
  nodeHasBottom: boolean;
52
+ /** Max column index used in this row (for per-row graph width) */
53
+ maxCol: number;
36
54
  }
37
55
 
38
56
  const LANE_COLORS = [
@@ -41,10 +59,6 @@
41
59
  ];
42
60
 
43
61
  const graphRows = $derived(computeGraph(commits));
44
- const maxCols = $derived(graphRows.reduce((max, row) => {
45
- const cols = [row.col, ...row.lanes.map(l => l.col), ...row.mergeFrom.map(m => m.col), ...row.branchTo.map(b => b.col)];
46
- return Math.max(max, ...cols);
47
- }, 0) + 1);
48
62
 
49
63
  function computeGraph(commits: GitCommit[]): GraphRow[] {
50
64
  if (commits.length === 0) return [];
@@ -53,6 +67,7 @@
53
67
  const laneColorMap = new Map<number, string>();
54
68
  let colorIdx = 0;
55
69
  const rows: GraphRow[] = [];
70
+ const processedSet = new Set<string>();
56
71
 
57
72
  function getColor(col: number): string {
58
73
  if (!laneColorMap.has(col)) {
@@ -70,7 +85,6 @@
70
85
  return s;
71
86
  }
72
87
 
73
- // Track previous row's active lane columns
74
88
  let prevActiveCols = new Set<number>();
75
89
 
76
90
  for (const commit of commits) {
@@ -81,11 +95,13 @@
81
95
 
82
96
  let col: number;
83
97
  const mergeFrom: Array<{ col: number; color: string }> = [];
98
+ const mergeFromCols = new Set<number>();
84
99
 
85
100
  if (myLanes.length > 0) {
86
101
  col = myLanes[0];
87
102
  for (let i = 1; i < myLanes.length; i++) {
88
103
  mergeFrom.push({ col: myLanes[i], color: getColor(myLanes[i]) });
104
+ mergeFromCols.add(myLanes[i]);
89
105
  lanes[myLanes[i]] = null;
90
106
  }
91
107
  } else {
@@ -97,17 +113,26 @@
97
113
  getColor(col);
98
114
  const nodeHasTop = prevActiveCols.has(col);
99
115
 
100
- // Snapshot current active lanes (before parent assignment)
101
116
  const currentPrevCols = new Set(prevActiveCols);
102
117
 
103
118
  const branchTo: Array<{ col: number; color: string }> = [];
119
+ const branchToCols = new Set<number>();
104
120
  if (commit.parents.length > 0) {
105
- lanes[col] = commit.parents[0];
121
+ // First parent: skip if already processed (non-topo edge case)
122
+ if (processedSet.has(commit.parents[0])) {
123
+ lanes[col] = null;
124
+ } else {
125
+ lanes[col] = commit.parents[0];
126
+ }
106
127
 
107
128
  for (let p = 1; p < commit.parents.length; p++) {
129
+ // Skip parents that were already processed
130
+ if (processedSet.has(commit.parents[p])) continue;
131
+
108
132
  const existingIdx = lanes.indexOf(commit.parents[p]);
109
133
  if (existingIdx >= 0 && existingIdx !== col) {
110
134
  branchTo.push({ col: existingIdx, color: getColor(existingIdx) });
135
+ branchToCols.add(existingIdx);
111
136
  } else {
112
137
  let newCol = -1;
113
138
  for (let i = 0; i < lanes.length; i++) {
@@ -119,6 +144,7 @@
119
144
  }
120
145
  lanes[newCol] = commit.parents[p];
121
146
  branchTo.push({ col: newCol, color: getColor(newCol) });
147
+ branchToCols.add(newCol);
122
148
  }
123
149
  }
124
150
  } else {
@@ -134,18 +160,27 @@
134
160
 
135
161
  const nextActiveCols = getActiveCols();
136
162
 
163
+ // Calculate max column used in this row
164
+ let rowMaxCol = col;
165
+ for (const lane of activeLanes) rowMaxCol = Math.max(rowMaxCol, lane.col);
166
+ for (const m of mergeFrom) rowMaxCol = Math.max(rowMaxCol, m.col);
167
+ for (const b of branchTo) rowMaxCol = Math.max(rowMaxCol, b.col);
168
+
137
169
  rows.push({
138
170
  col,
139
171
  nodeColor: getColor(col),
140
172
  lanes: activeLanes,
141
173
  mergeFrom,
142
174
  branchTo,
175
+ branchToCols,
143
176
  prevLaneCols: currentPrevCols,
144
177
  nextLaneCols: nextActiveCols,
145
178
  nodeHasTop,
146
- nodeHasBottom: nextActiveCols.has(col)
179
+ nodeHasBottom: nextActiveCols.has(col),
180
+ maxCol: rowMaxCol
147
181
  });
148
182
 
183
+ processedSet.add(commit.hash);
149
184
  prevActiveCols = nextActiveCols;
150
185
 
151
186
  while (lanes.length > 0 && lanes[lanes.length - 1] === null) {
@@ -160,9 +195,18 @@
160
195
  // Helpers
161
196
  // ========================
162
197
 
163
- const LANE_WIDTH = 16;
164
- const ROW_HEIGHT = 40;
165
- const NODE_R = 4;
198
+ const LANE_WIDTH = 10;
199
+ const ROW_HEIGHT = 48;
200
+ const NODE_R = 3;
201
+ const GRAPH_PAD = 3;
202
+ const LINE_W = '1.5';
203
+ const MAX_VISIBLE_REFS = 3;
204
+ const MAX_REF_LENGTH = 22;
205
+
206
+ function truncateRef(ref: string): string {
207
+ if (ref.length <= MAX_REF_LENGTH) return ref;
208
+ return ref.substring(0, MAX_REF_LENGTH - 1) + '\u2026';
209
+ }
166
210
 
167
211
  function formatDate(dateStr: string): string {
168
212
  const date = new Date(dateStr);
@@ -194,7 +238,7 @@
194
238
  }
195
239
  </script>
196
240
 
197
- <div class="h-full flex flex-col">
241
+ <div class="flex-1 min-h-0 flex flex-col">
198
242
  {#if isLoading && commits.length === 0}
199
243
  <div class="flex-1 flex items-center justify-center">
200
244
  <div class="w-5 h-5 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
@@ -205,15 +249,16 @@
205
249
  <span>No commits yet</span>
206
250
  </div>
207
251
  {:else}
208
- <div class="flex-1 overflow-y-auto">
252
+ <div class="flex-1 overflow-y-auto overflow-x-hidden pt-2">
209
253
  {#each commits as commit, idx (commit.hash)}
210
254
  {@const graph = graphRows[idx]}
211
- {@const graphWidth = Math.max(maxCols, 1) * LANE_WIDTH + 8}
255
+ {@const graphWidth = (graph ? graph.maxCol + 1 : 1) * LANE_WIDTH + GRAPH_PAD * 2}
212
256
  <div
213
257
  class="group flex items-stretch w-full text-left cursor-pointer transition-colors
214
258
  {selectedHash === commit.hash
215
259
  ? 'bg-violet-50 dark:bg-violet-900/10'
216
260
  : 'hover:bg-slate-50 dark:hover:bg-slate-800/40'}"
261
+ style="height: {ROW_HEIGHT}px;"
217
262
  role="button"
218
263
  tabindex="0"
219
264
  onclick={() => handleViewCommit(commit.hash)}
@@ -221,139 +266,134 @@
221
266
  >
222
267
  <!-- Git Graph Column -->
223
268
  {#if graph}
224
- <div class="shrink-0 relative" style="width: {graphWidth}px; min-height: {ROW_HEIGHT}px;">
225
- <svg class="absolute inset-0 w-full h-full" style="overflow: visible;">
226
- <!-- Vertical lines for active lanes (pass-through) -->
269
+ <div class="shrink-0 relative" style="width: {graphWidth}px;">
270
+ <svg class="absolute inset-0 w-full h-full">
271
+ <!-- Vertical lines for pass-through lanes (skip node col and branchTo cols) -->
227
272
  {#each graph.lanes as lane}
228
- {@const lx = lane.col * LANE_WIDTH + LANE_WIDTH / 2 + 4}
229
- {@const hasTop = graph.prevLaneCols.has(lane.col)}
230
- {@const hasBottom = true}
231
- <line
232
- x1={lx} y1={hasTop ? 0 : ROW_HEIGHT / 2}
233
- x2={lx} y2={hasBottom ? ROW_HEIGHT : ROW_HEIGHT / 2}
234
- stroke={lane.color}
235
- stroke-width="2"
236
- opacity="0.4"
237
- />
273
+ {#if lane.col !== graph.col && !graph.branchToCols.has(lane.col)}
274
+ {@const lx = lane.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD}
275
+ {@const hasTop = graph.prevLaneCols.has(lane.col)}
276
+ <line
277
+ x1={lx} y1={hasTop ? 0 : ROW_HEIGHT / 2}
278
+ x2={lx} y2={ROW_HEIGHT}
279
+ stroke={lane.color}
280
+ stroke-width={LINE_W}
281
+ />
282
+ {/if}
283
+ {/each}
284
+
285
+ <!-- Top-half lines for branchTo cols that existed in previous row (skip if also mergeFrom) -->
286
+ {#each graph.branchTo as branch}
287
+ {#if graph.prevLaneCols.has(branch.col) && !graph.mergeFrom.some(m => m.col === branch.col)}
288
+ {@const bx = branch.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD}
289
+ <line
290
+ x1={bx} y1={0}
291
+ x2={bx} y2={ROW_HEIGHT / 2}
292
+ stroke={branch.color}
293
+ stroke-width={LINE_W}
294
+ />
295
+ {/if}
238
296
  {/each}
239
297
 
240
- <!-- Merge lines (from other lanes into this commit's node) -->
298
+ <!-- Merge curves (from other lanes into this node) -->
241
299
  {#each graph.mergeFrom as merge}
242
- {@const mx = merge.col * LANE_WIDTH + LANE_WIDTH / 2 + 4}
243
- {@const nx = graph.col * LANE_WIDTH + LANE_WIDTH / 2 + 4}
300
+ {@const mx = merge.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD}
301
+ {@const nx = graph.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD}
244
302
  <path
245
303
  d="M {mx} 0 C {mx} {ROW_HEIGHT * 0.4}, {nx} {ROW_HEIGHT * 0.3}, {nx} {ROW_HEIGHT / 2}"
246
304
  fill="none"
247
305
  stroke={merge.color}
248
- stroke-width="2"
249
- opacity="0.5"
306
+ stroke-width={LINE_W}
250
307
  />
251
308
  {/each}
252
309
 
253
- <!-- Branch lines (from this node to new lanes) -->
310
+ <!-- Branch curves (from this node to new lanes) -->
254
311
  {#each graph.branchTo as branch}
255
- {@const bx = branch.col * LANE_WIDTH + LANE_WIDTH / 2 + 4}
256
- {@const nx = graph.col * LANE_WIDTH + LANE_WIDTH / 2 + 4}
312
+ {@const bx = branch.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD}
313
+ {@const nx = graph.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD}
257
314
  <path
258
315
  d="M {nx} {ROW_HEIGHT / 2} C {nx} {ROW_HEIGHT * 0.7}, {bx} {ROW_HEIGHT * 0.6}, {bx} {ROW_HEIGHT}"
259
316
  fill="none"
260
317
  stroke={branch.color}
261
- stroke-width="2"
262
- opacity="0.5"
318
+ stroke-width={LINE_W}
263
319
  />
264
320
  {/each}
265
321
 
266
322
  <!-- Main vertical line through this node's lane -->
267
323
  <line
268
- x1={graph.col * LANE_WIDTH + LANE_WIDTH / 2 + 4} y1={graph.nodeHasTop ? 0 : ROW_HEIGHT / 2}
269
- x2={graph.col * LANE_WIDTH + LANE_WIDTH / 2 + 4} y2={graph.nodeHasBottom ? ROW_HEIGHT : ROW_HEIGHT / 2}
324
+ x1={graph.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD} y1={graph.nodeHasTop ? 0 : ROW_HEIGHT / 2}
325
+ x2={graph.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD} y2={graph.nodeHasBottom ? ROW_HEIGHT : ROW_HEIGHT / 2}
270
326
  stroke={graph.nodeColor}
271
- stroke-width="2"
272
- opacity="0.4"
327
+ stroke-width={LINE_W}
273
328
  />
274
329
 
275
330
  <!-- Node circle -->
276
331
  <circle
277
- cx={graph.col * LANE_WIDTH + LANE_WIDTH / 2 + 4}
332
+ cx={graph.col * LANE_WIDTH + LANE_WIDTH / 2 + GRAPH_PAD}
278
333
  cy={ROW_HEIGHT / 2}
279
- r={commit.parents.length > 1 ? NODE_R + 1 : NODE_R}
334
+ r={NODE_R}
280
335
  fill={graph.nodeColor}
281
336
  stroke="white"
282
- stroke-width="2"
337
+ stroke-width="1.5"
283
338
  />
284
- {#if commit.parents.length > 1}
285
- <circle
286
- cx={graph.col * LANE_WIDTH + LANE_WIDTH / 2 + 4}
287
- cy={ROW_HEIGHT / 2}
288
- r={NODE_R + 3}
289
- fill="none"
290
- stroke={graph.nodeColor}
291
- stroke-width="1.5"
292
- opacity="0.5"
293
- />
294
- {/if}
295
339
  </svg>
296
340
  </div>
297
341
  {/if}
298
342
 
299
- <!-- Commit info -->
300
- <div class="flex-1 min-w-0 flex items-center gap-2 px-2 py-1.5">
301
- <div class="flex-1 min-w-0">
302
- <p class="text-sm text-slate-900 dark:text-slate-100 leading-snug truncate">
343
+ <!-- Commit info (3-line layout) -->
344
+ <div class="flex-1 min-w-0 px-1.5 py-0.5 flex flex-col justify-center overflow-hidden">
345
+ <!-- Line 1: Message + Date -->
346
+ <div class="flex items-center gap-2">
347
+ <p class="flex-1 min-w-0 text-sm text-slate-900 dark:text-slate-100 leading-tight truncate" title={commit.message}>
303
348
  {commit.message}
304
349
  </p>
305
- <div class="flex items-center gap-2 mt-0.5">
306
- <span class="text-xs font-mono text-violet-600 dark:text-violet-400">
307
- {commit.hashShort}
308
- </span>
309
- <span class="text-xs text-slate-500 truncate">{commit.author}</span>
310
- {#if commit.refs && commit.refs.length > 0}
311
- {#each commit.refs as ref}
312
- <span class="text-3xs px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 truncate shrink-0">
313
- {ref}
314
- </span>
315
- {/each}
316
- {/if}
317
- </div>
350
+ <span class="text-3xs text-slate-400 shrink-0">{formatDate(commit.date)}</span>
318
351
  </div>
319
352
 
320
- <!-- Actions -->
321
- <div class="items-center gap-0.5 shrink-0 hidden group-hover:flex">
353
+ <!-- Line 2: Hash + Author -->
354
+ <div class="flex items-center gap-1.5 mt-px">
322
355
  <button
323
356
  type="button"
324
- class="flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:bg-violet-500/10 hover:text-violet-600 transition-colors bg-transparent border-none cursor-pointer"
357
+ class="text-xs font-mono text-violet-600 dark:text-violet-400 hover:text-violet-800 dark:hover:text-violet-300 bg-transparent border-none cursor-pointer p-0 shrink-0 transition-colors"
325
358
  onclick={(e) => copyCommitHash(commit.hash, e)}
326
- title="Copy full commit hash"
327
- >
328
- <Icon name="lucide:copy" class="w-3.5 h-3.5" />
329
- </button>
330
- <button
331
- type="button"
332
- class="flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:bg-violet-500/10 hover:text-violet-600 transition-colors bg-transparent border-none cursor-pointer"
333
- onclick={(e) => { e.stopPropagation(); copyCommitHash(commit.hashShort, e); }}
334
- title="Copy short hash"
359
+ title="Copy commit hash"
335
360
  >
336
- <Icon name="lucide:hash" class="w-3.5 h-3.5" />
361
+ {commit.hashShort}
337
362
  </button>
363
+ <span class="text-xs text-slate-500 truncate">{commit.author}</span>
338
364
  </div>
339
365
 
340
- <!-- Date -->
341
- <span class="text-xs text-slate-400 shrink-0">{formatDate(commit.date)}</span>
366
+ <!-- Line 3: Refs -->
367
+ {#if commit.refs && commit.refs.length > 0}
368
+ <div class="flex items-center gap-1 mt-px overflow-hidden">
369
+ {#each commit.refs.slice(0, MAX_VISIBLE_REFS) as ref}
370
+ <span
371
+ class="text-3xs px-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0 truncate max-w-28"
372
+ title={ref}
373
+ >
374
+ {truncateRef(ref)}
375
+ </span>
376
+ {/each}
377
+ {#if commit.refs.length > MAX_VISIBLE_REFS}
378
+ <span
379
+ class="text-3xs px-1 py-px rounded bg-slate-500/10 text-slate-500 shrink-0 cursor-default"
380
+ title={commit.refs.slice(MAX_VISIBLE_REFS).join(', ')}
381
+ >
382
+ +{commit.refs.length - MAX_VISIBLE_REFS}
383
+ </span>
384
+ {/if}
385
+ </div>
386
+ {/if}
342
387
  </div>
343
388
  </div>
344
389
  {/each}
345
390
 
346
- <!-- Load more -->
391
+ <!-- Infinite scroll sentinel -->
347
392
  {#if hasMore}
348
- <div class="flex justify-center py-3">
349
- <button
350
- type="button"
351
- class="px-4 py-1.5 text-xs font-medium text-violet-600 bg-violet-500/10 rounded-md hover:bg-violet-500/20 transition-colors cursor-pointer border-none"
352
- onclick={onLoadMore}
353
- disabled={isLoading}
354
- >
355
- {isLoading ? 'Loading...' : 'Load More'}
356
- </button>
393
+ <div bind:this={sentinelEl} class="flex justify-center py-3">
394
+ {#if isLoading}
395
+ <div class="w-4 h-4 border-2 border-slate-200 dark:border-slate-700 border-t-violet-600 rounded-full animate-spin"></div>
396
+ {/if}
357
397
  </div>
358
398
  {/if}
359
399
  </div>
@@ -214,26 +214,21 @@
214
214
  </script>
215
215
 
216
216
  <!-- Preview Toolbar -->
217
- <div class="relative px-3 py-2.5 bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
218
- <!-- Tabs bar -->
217
+ <div class="relative bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
218
+ <!-- Tabs bar (Git-style underline tabs) — separated with its own border-bottom -->
219
219
  {#if tabs.length > 0}
220
- <div class="flex items-center gap-1.5 overflow-x-auto mb-1.5">
221
- <!-- Tabs -->
220
+ <div class="relative flex items-center overflow-x-auto border-b border-slate-200 dark:border-slate-700">
222
221
  {#each tabs as tab}
223
- <div
224
- class="group relative flex items-center gap-2 pl-3 pr-2 py-1.5 border border-slate-200 dark:border-slate-700 rounded-lg transition-all duration-200 min-w-0 max-w-xs cursor-pointer
225
- {tab.id === activeTabId
226
- ? 'bg-slate-100 dark:bg-slate-700'
227
- : 'bg-white dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700'}"
222
+ {@const isActive = tab.id === activeTabId}
223
+ <button
224
+ type="button"
225
+ class="group relative flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
226
+ {isActive
227
+ ? 'text-violet-600 dark:text-violet-400'
228
+ : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
228
229
  onclick={() => onSwitchTab(tab.id)}
229
230
  role="tab"
230
231
  tabindex="0"
231
- onkeydown={(e) => {
232
- if (e.key === 'Enter' || e.key === ' ') {
233
- e.preventDefault();
234
- onSwitchTab(tab.id);
235
- }
236
- }}
237
232
  >
238
233
  {#if tab.id === mcpControlledTabId}
239
234
  <Icon name="lucide:bot" class="w-3 h-3 flex-shrink-0 text-amber-500" />
@@ -242,81 +237,95 @@
242
237
  {:else}
243
238
  <Icon name="lucide:globe" class="w-3 h-3 flex-shrink-0" />
244
239
  {/if}
245
- <span class="text-xs font-medium truncate max-w-37.5" title={tab.url}>
240
+ <span class="truncate max-w-28" title={tab.url}>
246
241
  {tab.title || 'New Tab'}
247
242
  </span>
248
243
  {#if tab.id === mcpControlledTabId}
249
- <span title="MCP Controlled" class="flex">
250
- <Icon name="lucide:lock" class="w-3 h-3 flex-shrink-0 text-amber-500" />
244
+ <span title="MCP Controlled" class="flex"><Icon name="lucide:lock" class="w-3 h-3 flex-shrink-0 text-amber-500" /></span>
245
+ {/if}
246
+ <!-- Close button -->
247
+ {#if tab.id !== mcpControlledTabId}
248
+ <span
249
+ role="button"
250
+ tabindex="0"
251
+ onclick={(e) => {
252
+ e.stopPropagation();
253
+ onCloseTab(tab.id);
254
+ }}
255
+ onkeydown={(e) => {
256
+ if (e.key === 'Enter' || e.key === ' ') {
257
+ e.stopPropagation();
258
+ onCloseTab(tab.id);
259
+ }
260
+ }}
261
+ class="flex items-center justify-center w-4 h-4 rounded hover:bg-slate-200 dark:hover:bg-slate-700 transition-all duration-200 flex-shrink-0"
262
+ title="Close tab"
263
+ >
264
+ <Icon name="lucide:x" class="w-2.5 h-2.5" />
251
265
  </span>
252
266
  {/if}
253
- <button
254
- onclick={(e) => {
255
- e.stopPropagation();
256
- onCloseTab(tab.id);
257
- }}
258
- class="flex hover:bg-slate-300 dark:hover:bg-slate-600 rounded p-0.5 transition-all duration-200 {tab.id === mcpControlledTabId ? 'hidden' : ''}"
259
- title="Close tab"
260
- disabled={tab.id === mcpControlledTabId}
261
- >
262
- <Icon name="lucide:x" class="w-3 h-3" />
263
- </button>
264
- </div>
267
+ {#if isActive}
268
+ <span class="absolute bottom-0 inset-x-0 h-px bg-violet-600 dark:bg-violet-400"></span>
269
+ {/if}
270
+ </button>
265
271
  {/each}
266
-
272
+
267
273
  <!-- New tab button -->
268
274
  <button
275
+ type="button"
269
276
  onclick={() => onNewTab()}
270
- class="flex items-center justify-center w-5 h-5 rounded-md hover:bg-slate-200 dark:hover:bg-slate-700 transition-all duration-200"
277
+ class="flex items-center justify-center w-6 h-6 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-200/60 dark:hover:bg-slate-700/60 transition-all duration-200 flex-shrink-0 ml-1"
271
278
  title="Open new tab"
272
279
  >
273
280
  <Icon name="lucide:plus" class="w-3 h-3" />
274
281
  </button>
275
282
  </div>
276
283
  {/if}
277
-
278
- <!-- Main toolbar header -->
279
- <div class="px-1 py-0.5 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
280
- <div class="flex items-center justify-between gap-4">
281
- <!-- Left section: URL navigation -->
282
- <div class="flex items-center gap-3 flex-1 min-w-0">
283
- <!-- URL input with integrated controls -->
284
- <input
285
- type="text"
286
- bind:value={urlInput}
287
- onkeydown={handleUrlKeydown}
288
- oninput={handleUrlInput}
289
- onfocus={() => isUserTyping = true}
290
- onblur={() => isUserTyping = false}
291
- placeholder="Enter URL to preview..."
292
- class="flex-1 pl-3 py-2.5 text-sm bg-transparent border-0 focus:outline-none min-w-0 text-ellipsis"
293
- />
294
- <div class="flex items-center gap-1 px-2">
295
- {#if url}
296
- <button
297
- onclick={handleOpenInExternalBrowser}
298
- class="flex p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200"
299
- title="Open in external browser"
300
- >
301
- <Icon name="lucide:external-link" class="w-4 h-4" />
302
- </button>
284
+
285
+ <!-- Main toolbar header (URL bar) -->
286
+ <div class="px-2.5 py-1.5">
287
+ <div class="px-1 py-0.5 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
288
+ <div class="flex items-center justify-between gap-3">
289
+ <!-- Left section: URL navigation -->
290
+ <div class="flex items-center gap-2 flex-1 min-w-0">
291
+ <!-- URL input with integrated controls -->
292
+ <input
293
+ type="text"
294
+ bind:value={urlInput}
295
+ onkeydown={handleUrlKeydown}
296
+ oninput={handleUrlInput}
297
+ onfocus={() => isUserTyping = true}
298
+ onblur={() => isUserTyping = false}
299
+ placeholder="Enter URL to preview..."
300
+ class="flex-1 pl-3 py-2 text-sm bg-transparent border-0 focus:outline-none min-w-0 text-ellipsis"
301
+ />
302
+ <div class="flex items-center gap-1 px-1.5">
303
+ {#if url}
304
+ <button
305
+ onclick={handleOpenInExternalBrowser}
306
+ class="flex p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200"
307
+ title="Open in external browser"
308
+ >
309
+ <Icon name="lucide:external-link" class="w-4 h-4" />
310
+ </button>
311
+ <button
312
+ onclick={handleRefresh}
313
+ disabled={isLoading}
314
+ class="flex p-1.5 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 disabled:opacity-50"
315
+ title="Refresh current page"
316
+ >
317
+ <Icon name={isLoading ? 'lucide:loader-circle' : 'lucide:refresh-cw'} class="w-4 h-4 transition-transform duration-200 {isLoading ? 'animate-spin' : 'hover:rotate-180'}" />
318
+ </button>
319
+ {/if}
303
320
  <button
304
- onclick={handleRefresh}
305
- disabled={isLoading}
306
- class="flex p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 disabled:opacity-50"
307
- title="Refresh current page"
321
+ onclick={handleGoClick}
322
+ disabled={!urlInput.trim() || isLoading}
323
+ class="ml-0.5 px-3.5 py-1 text-xs font-medium rounded-md bg-violet-500 hover:bg-violet-600 disabled:bg-slate-300 disabled:dark:bg-slate-600 text-white transition-all duration-200 disabled:opacity-50"
324
+ title="Navigate to URL"
308
325
  >
309
- <Icon name={isLoading ? 'lucide:loader-circle' : 'lucide:refresh-cw'} class="w-4 h-4 transition-transform duration-200 {isLoading ? 'animate-spin' : 'hover:rotate-180'}" />
326
+ Go
310
327
  </button>
311
- {/if}
312
- <button
313
- onclick={handleGoClick}
314
- disabled={!urlInput.trim() || isLoading}
315
- class="ml-1 px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 hover:bg-violet-600 disabled:bg-slate-300 disabled:dark:bg-slate-600 text-white transition-all duration-200 disabled:opacity-50"
316
- title="Navigate to URL"
317
- >
318
- Go
319
- </button>
328
+ </div>
320
329
  </div>
321
330
  </div>
322
331
  </div>
@@ -17,7 +17,6 @@
17
17
  import UserSettings from './user/UserSettings.svelte';
18
18
  import NotificationSettings from './notifications/NotificationSettings.svelte';
19
19
  import GeneralSettings from './general/GeneralSettings.svelte';
20
- import pkg from '../../../../package.json';
21
20
 
22
21
  // Responsive state
23
22
  let isMobileMenuOpen = $state(false);
@@ -179,13 +178,7 @@
179
178
  {/each}
180
179
  </nav>
181
180
 
182
- <footer class="p-4 border-t border-slate-200 dark:border-slate-800">
183
- <div class="flex items-center gap-2 text-xs text-slate-600 dark:text-slate-500">
184
- <Icon name="lucide:info" class="w-4 h-4" />
185
- <span>Clopen v{pkg.version}</span>
186
- </div>
187
- </footer>
188
- </aside>
181
+ </aside>
189
182
 
190
183
  <!-- Mobile Menu Overlay -->
191
184
  {#if isMobile && isMobileMenuOpen}
@@ -7,7 +7,7 @@
7
7
  import { isDarkMode } from '$frontend/lib/utils/theme';
8
8
  import { ENGINES } from '$shared/constants/engines';
9
9
  import { claudeAccountsStore, type ClaudeAccountItem as ClaudeCodeAccountItem } from '$frontend/lib/stores/features/claude-accounts.svelte';
10
- import type { Terminal } from 'xterm';
10
+ import type { Terminal } from '@xterm/xterm';
11
11
  import type { FitAddon } from '@xterm/addon-fit';
12
12
 
13
13
  const claudeCodeEngine = ENGINES.find(e => e.type === 'claude-code')!;
@@ -99,11 +99,11 @@
99
99
  if (!browser || !debugTermContainer || debugTerminal) return;
100
100
 
101
101
  const [{ Terminal }, { FitAddon }] = await Promise.all([
102
- import('xterm'),
102
+ import('@xterm/xterm'),
103
103
  import('@xterm/addon-fit')
104
104
  ]);
105
105
 
106
- await import('xterm/css/xterm.css');
106
+ await import('@xterm/xterm/css/xterm.css');
107
107
 
108
108
  debugTerminal = new Terminal({
109
109
  theme: {
@@ -258,7 +258,7 @@
258
258
  aria-label="Terminal application">
259
259
 
260
260
  <!-- Terminal Header with Tabs -->
261
- <div class="flex-shrink-0 px-3 py-2.5 bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
261
+ <div class="flex-shrink-0 bg-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
262
262
  <!-- Terminal Tabs -->
263
263
  <TerminalTabs
264
264
  sessions={terminalStore.sessions}