@myrialabs/clopen 0.1.8 → 0.1.10

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 (39) hide show
  1. package/backend/index.ts +5 -1
  2. package/backend/lib/chat/stream-manager.ts +4 -1
  3. package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
  4. package/backend/lib/database/migrations/index.ts +7 -0
  5. package/backend/lib/database/queries/session-queries.ts +50 -0
  6. package/backend/lib/database/queries/snapshot-queries.ts +1 -1
  7. package/backend/lib/engine/adapters/opencode/server.ts +8 -0
  8. package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
  9. package/backend/lib/snapshot/helpers.ts +22 -49
  10. package/backend/lib/snapshot/snapshot-service.ts +148 -83
  11. package/backend/ws/chat/stream.ts +13 -0
  12. package/backend/ws/sessions/crud.ts +34 -2
  13. package/backend/ws/snapshot/restore.ts +111 -12
  14. package/backend/ws/snapshot/timeline.ts +56 -29
  15. package/backend/ws/user/crud.ts +8 -4
  16. package/bin/clopen.ts +17 -1
  17. package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
  18. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
  19. package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
  20. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
  21. package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
  22. package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
  23. package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
  24. package/frontend/lib/components/git/CommitForm.svelte +6 -4
  25. package/frontend/lib/components/git/GitLog.svelte +26 -12
  26. package/frontend/lib/components/history/HistoryModal.svelte +1 -1
  27. package/frontend/lib/components/history/HistoryView.svelte +1 -1
  28. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
  29. package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
  30. package/frontend/lib/components/terminal/Terminal.svelte +1 -1
  31. package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
  32. package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
  33. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
  34. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  35. package/frontend/lib/stores/core/app.svelte.ts +46 -0
  36. package/frontend/lib/stores/core/sessions.svelte.ts +39 -4
  37. package/frontend/lib/stores/ui/update.svelte.ts +0 -12
  38. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
  39. package/package.json +1 -1
@@ -108,7 +108,8 @@ export const crudHandler = createRouter()
108
108
  response: t.Object({
109
109
  currentProjectId: t.Union([t.String(), t.Null()]),
110
110
  lastView: t.Union([t.String(), t.Null()]),
111
- settings: t.Union([t.Any(), t.Null()])
111
+ settings: t.Union([t.Any(), t.Null()]),
112
+ unreadSessions: t.Union([t.Any(), t.Null()])
112
113
  })
113
114
  }, async ({ conn }) => {
114
115
  const userId = ws.getUserId(conn);
@@ -116,17 +117,20 @@ export const crudHandler = createRouter()
116
117
  const currentProjectId = getUserState(userId, 'currentProjectId') as string | null;
117
118
  const lastView = getUserState(userId, 'lastView') as string | null;
118
119
  const userSettings = getUserState(userId, 'settings');
120
+ const unreadSessions = getUserState(userId, 'unreadSessions');
119
121
 
120
122
  debug.log('user', `Restored state for ${userId}:`, {
121
123
  currentProjectId,
122
124
  lastView,
123
- hasSettings: !!userSettings
125
+ hasSettings: !!userSettings,
126
+ unreadSessionsCount: unreadSessions ? Object.keys(unreadSessions).length : 0
124
127
  });
125
128
 
126
129
  return {
127
130
  currentProjectId: currentProjectId ?? null,
128
131
  lastView: lastView ?? null,
129
- settings: userSettings ?? null
132
+ settings: userSettings ?? null,
133
+ unreadSessions: unreadSessions ?? null
130
134
  };
131
135
  })
132
136
 
@@ -143,7 +147,7 @@ export const crudHandler = createRouter()
143
147
  const userId = ws.getUserId(conn);
144
148
 
145
149
  // Validate allowed keys to prevent arbitrary data storage
146
- const allowedKeys = ['currentProjectId', 'lastView', 'settings'];
150
+ const allowedKeys = ['currentProjectId', 'lastView', 'settings', 'unreadSessions'];
147
151
  if (!allowedKeys.includes(data.key)) {
148
152
  throw new Error(`Invalid state key: ${data.key}. Allowed: ${allowedKeys.join(', ')}`);
149
153
  }
package/bin/clopen.ts CHANGED
@@ -368,7 +368,23 @@ async function main() {
368
368
 
369
369
  // Show version if requested
370
370
  if (options.version) {
371
- console.log(getVersion());
371
+ const currentVersion = getVersion();
372
+ console.log(`v${currentVersion}`);
373
+
374
+ try {
375
+ const response = await fetch('https://registry.npmjs.org/@myrialabs/clopen/latest');
376
+ if (response.ok) {
377
+ const data = await response.json() as { version: string };
378
+ if (isNewerVersion(currentVersion, data.version)) {
379
+ console.log(`\x1b[33mUpdate available: v${data.version}\x1b[0m — run \x1b[36mclopen update\x1b[0m to update`);
380
+ } else {
381
+ console.log('\x1b[32m(latest)\x1b[0m');
382
+ }
383
+ }
384
+ } catch {
385
+ // Silent fail — network unavailable
386
+ }
387
+
372
388
  process.exit(0);
373
389
  }
374
390
 
@@ -407,7 +407,7 @@
407
407
  class="
408
408
  relative z-10 flex items-end gap-3 lg:gap-4 overflow-hidden bg-white dark:bg-slate-900
409
409
  border border-slate-200 dark:border-slate-700 rounded-xl transition-all duration-200
410
- focus-within:ring-2 focus-within:ring-violet-500 {fileHandling.isDragging && 'ring-2 ring-violet-500'}"
410
+ focus-within:ring-1 focus-within:ring-violet-500 {fileHandling.isDragging && 'ring-1 ring-violet-500'}"
411
411
  role="region"
412
412
  aria-label="Message input with file drop zone"
413
413
  ondragover={fileHandling.handleDragOver}
@@ -428,7 +428,6 @@
428
428
  placeholder={chatPlaceholder}
429
429
  class="flex-1 w-full px-4 pt-2 pb-4 border-0 bg-transparent resize-none focus:outline-none text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 text-base leading-relaxed disabled:opacity-50 disabled:cursor-not-allowed"
430
430
  rows="1"
431
- style="max-height: 22.5rem; overflow-y: hidden;"
432
431
  disabled={isInputDisabled}
433
432
  oninput={handleTextareaInput}
434
433
  onkeydown={handleKeyDown}
@@ -122,9 +122,9 @@
122
122
  // Model Picker (existing logic)
123
123
  // ════════════════════════════════════════════
124
124
 
125
- // Track whether a chat has started (any user message in current session)
125
+ // Track whether a chat has started (any user message in current session, or session has history e.g. restored to initial)
126
126
  const hasStartedChat = $derived(
127
- sessionState.messages.some(m => m.type === 'user')
127
+ sessionState.messages.some(m => m.type === 'user') || sessionState.hasMessageHistory
128
128
  );
129
129
 
130
130
  // Engine lock: once chat starts, the engine is locked for this session.
@@ -45,10 +45,10 @@ export function useChatActions(params: ChatActionsParams) {
45
45
  // If in edit mode, restore to parent of edited message first
46
46
  if (editModeState.isEditing) {
47
47
  try {
48
- // Restore to parent of edited message (if exists)
49
- const restoreTargetId = editModeState.parentMessageId || editModeState.messageId;
50
-
51
- if (restoreTargetId && sessionState.currentSession?.id) {
48
+ if (sessionState.currentSession?.id) {
49
+ // Restore to parent of edited message (state before the edited message)
50
+ // When parentMessageId is null (editing first message), restore to initial state
51
+ const restoreTargetId = editModeState.parentMessageId || '__initial__';
52
52
  await snapshotService.restore(restoreTargetId, sessionState.currentSession.id);
53
53
  }
54
54
 
@@ -7,33 +7,25 @@ export function useTextareaResize() {
7
7
  messageText: string
8
8
  ) {
9
9
  if (textareaElement) {
10
- // Reset height to auto first to get accurate scrollHeight
10
+ // Hide overflow during measurement to prevent scrollbar from affecting width
11
+ textareaElement.style.overflowY = 'hidden';
12
+ // Reset height to auto to get accurate scrollHeight
11
13
  textareaElement.style.height = 'auto';
12
14
 
13
15
  // If content is empty or only whitespace, keep at minimum height
14
16
  if (!messageText || !messageText.trim()) {
15
- // Force single line height
16
- textareaElement.style.height = 'auto';
17
- textareaElement.style.overflowY = 'hidden';
18
17
  return;
19
18
  }
20
19
 
21
- // Calculate required height based on content
22
- const scrollHeight = textareaElement.scrollHeight + 7;
23
- const lineHeight = parseInt(getComputedStyle(textareaElement).lineHeight) || 24;
24
- const paddingTop = parseInt(getComputedStyle(textareaElement).paddingTop) || 0;
25
- const paddingBottom = parseInt(getComputedStyle(textareaElement).paddingBottom) || 0;
26
- const minHeight = lineHeight + paddingTop + paddingBottom;
20
+ // Measure content height and cap at max
21
+ const scrollHeight = textareaElement.scrollHeight;
22
+ const newHeight = Math.min(scrollHeight, MAX_HEIGHT_PX);
23
+ textareaElement.style.height = newHeight + 'px';
27
24
 
28
- const newHeight = Math.max(minHeight, scrollHeight);
29
-
30
- if (newHeight >= MAX_HEIGHT_PX) {
31
- textareaElement.style.height = '22.5rem';
32
- textareaElement.style.overflowY = 'auto';
33
- } else {
34
- textareaElement.style.height = newHeight / 16 + 'rem';
35
- textareaElement.style.overflowY = 'hidden';
36
- }
25
+ // Check actual overflow AFTER setting height to handle edge cases
26
+ // where collapsed measurement differs from rendered content height
27
+ textareaElement.style.overflowY =
28
+ textareaElement.scrollHeight > textareaElement.clientHeight ? 'auto' : 'hidden';
37
29
  }
38
30
  }
39
31
 
@@ -13,6 +13,7 @@
13
13
  import { snapshotService } from '$frontend/lib/services/snapshot/snapshot.service';
14
14
  import type { RestoreConflict, ConflictResolution } from '$frontend/lib/services/snapshot/snapshot.service';
15
15
  import type { TimelineResponse, GraphNode, GraphEdge, VersionGroup, AnimationState } from './timeline/types';
16
+ import ws from '$frontend/lib/utils/ws';
16
17
 
17
18
  let {
18
19
  isOpen = $bindable(false),
@@ -107,6 +108,19 @@
107
108
  }
108
109
  });
109
110
 
111
+ // Reload timeline when a snapshot is captured (stats become available after stream ends)
112
+ $effect(() => {
113
+ if (!isOpen) return;
114
+
115
+ const unsub = ws.on('snapshot:captured', (data: { chatSessionId: string }) => {
116
+ if (data.chatSessionId === sessionId && !processingAction && !animationState.isAnimating) {
117
+ loadTimeline();
118
+ }
119
+ });
120
+
121
+ return unsub;
122
+ });
123
+
110
124
  // Scroll to bottom on initial load
111
125
  $effect(() => {
112
126
  if (timelineData && graphNodes.length > 0 && scrollContainer && !hasScrolledToBottom) {
@@ -445,9 +459,7 @@
445
459
  type="warning"
446
460
  title="Restore Checkpoint"
447
461
  message={pendingNode
448
- ? `Are you sure you want to restore to this checkpoint?
449
- "${getTruncatedMessage(pendingNode.checkpoint.messageText)}"
450
- This will restore your files to this point within this session.`
462
+ ? `Are you sure you want to restore to this checkpoint?\n"${getTruncatedMessage(pendingNode.checkpoint.messageText)}"\nThis will restore your files to this point within this session.`
451
463
  : ''}
452
464
  confirmText="Restore"
453
465
  cancelText="Cancel"
@@ -21,6 +21,7 @@
21
21
 
22
22
  const pos = $derived(getInterpolatedPosition(node, animationState));
23
23
  const nodeClass = $derived(getInterpolatedNodeClass(node));
24
+ const isInitial = $derived(!!node.checkpoint.isInitial);
24
25
  </script>
25
26
 
26
27
  <!-- Node group -->
@@ -33,6 +34,7 @@
33
34
  aria-label={`${node.type === 'main' ? 'Checkpoint' : 'Version'} - ${node.checkpoint.messageText}`}
34
35
  >
35
36
  <title>{node.checkpoint.messageText}</title>
37
+
36
38
  <!-- Node circle -->
37
39
  <circle
38
40
  cx={pos.x}
@@ -84,25 +86,34 @@
84
86
  height={SIZE.labelHeight - 8}
85
87
  >
86
88
  <div class="flex flex-col h-full justify-center pointer-events-none">
87
- <!-- Timestamp and file stats in one line -->
88
- <div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 leading-tight">
89
- <span>{formatTime(node.checkpoint.timestamp)}</span>
90
- <span class="w-px h-3 bg-slate-300 dark:bg-slate-600"></span>
91
- <span class="flex items-center gap-0.5">
92
- <Icon name="lucide:file-text" class="w-2.5 h-2.5" />
93
- {node.checkpoint.filesChanged ?? 0}
94
- </span>
95
- <span class="text-green-600 dark:text-green-400">
96
- +{node.checkpoint.insertions ?? 0}
97
- </span>
98
- <span class="text-red-600 dark:text-red-400">
99
- -{node.checkpoint.deletions ?? 0}
100
- </span>
101
- </div>
102
- <!-- Message text below timestamp with auto truncation -->
103
- <div class="text-sm text-slate-900 dark:text-slate-100 leading-tight truncate mt-0.5">
104
- {node.checkpoint.messageText}
105
- </div>
89
+ {#if isInitial}
90
+ <div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 leading-tight">
91
+ <span>{formatTime(node.checkpoint.timestamp)}</span>
92
+ </div>
93
+ <div class="text-sm text-slate-900 dark:text-slate-100 leading-tight truncate mt-0.5">
94
+ Session Start
95
+ </div>
96
+ {:else}
97
+ <!-- Timestamp and file stats in one line -->
98
+ <div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400 leading-tight">
99
+ <span>{formatTime(node.checkpoint.timestamp)}</span>
100
+ <span class="w-px h-3 bg-slate-300 dark:bg-slate-600"></span>
101
+ <span class="flex items-center gap-0.5">
102
+ <Icon name="lucide:file-text" class="w-2.5 h-2.5" />
103
+ {node.checkpoint.filesChanged ?? 0}
104
+ </span>
105
+ <span class="text-green-600 dark:text-green-400">
106
+ +{node.checkpoint.insertions ?? 0}
107
+ </span>
108
+ <span class="text-red-600 dark:text-red-400">
109
+ -{node.checkpoint.deletions ?? 0}
110
+ </span>
111
+ </div>
112
+ <!-- Message text below timestamp with auto truncation -->
113
+ <div class="text-sm text-slate-900 dark:text-slate-100 leading-tight truncate mt-0.5">
114
+ {node.checkpoint.messageText}
115
+ </div>
116
+ {/if}
106
117
  </div>
107
118
  </foreignObject>
108
119
  </g>
@@ -2,6 +2,9 @@
2
2
  * Timeline data structures and type definitions
3
3
  */
4
4
 
5
+ /** Sentinel ID for the "initial state" node (before any chat messages) */
6
+ export const INITIAL_NODE_ID = '__initial__';
7
+
5
8
  export interface CheckpointNode {
6
9
  id: string;
7
10
  messageId: string;
@@ -13,6 +16,7 @@ export interface CheckpointNode {
13
16
  isOrphaned: boolean;
14
17
  isCurrent: boolean;
15
18
  hasSnapshot: boolean;
19
+ isInitial?: boolean; // true for the "initial state" node
16
20
  senderName?: string | null;
17
21
  // File change statistics (git-like)
18
22
  filesChanged?: number;
@@ -30,11 +30,13 @@
30
30
  if (!textareaEl) return;
31
31
  // Reset to single line to measure content
32
32
  textareaEl.style.height = 'auto';
33
- // Line height is ~20px for text-xs, so 5 lines max = 100px
33
+ // Line height is ~20px for text-sm, so 5 lines max = 100px
34
34
  const lineHeight = 20;
35
35
  const maxHeight = lineHeight * 5;
36
36
  const scrollHeight = textareaEl.scrollHeight;
37
- textareaEl.style.height = Math.min(scrollHeight, maxHeight) + 'px';
37
+ const newHeight = Math.min(scrollHeight, maxHeight);
38
+ textareaEl.style.height = newHeight + 'px';
39
+ textareaEl.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
38
40
  }
39
41
 
40
42
  function handleInput() {
@@ -48,9 +50,9 @@
48
50
  bind:this={textareaEl}
49
51
  bind:value={commitMessage}
50
52
  placeholder="Commit message..."
51
- class="w-full px-2.5 py-2 text-sm bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-600 resize-none outline-none focus:border-violet-500/40 focus:ring-1 focus:ring-violet-500/20 transition-colors overflow-hidden"
53
+ class="w-full px-2.5 py-2 text-sm bg-white dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 rounded-md text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-600 resize-none outline-none focus:ring-1 focus:ring-violet-500 transition-colors"
52
54
  rows="1"
53
- style="height: 27px"
55
+ style="overflow-y: hidden;"
54
56
  onkeydown={handleKeydown}
55
57
  oninput={handleInput}
56
58
  disabled={isCommitting}
@@ -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
@@ -325,7 +344,7 @@
325
344
  <div class="flex-1 min-w-0 px-1.5 py-0.5 flex flex-col justify-center overflow-hidden">
326
345
  <!-- Line 1: Message + Date -->
327
346
  <div class="flex items-center gap-2">
328
- <p class="flex-1 min-w-0 text-sm text-slate-900 dark:text-slate-100 leading-tight truncate">
347
+ <p class="flex-1 min-w-0 text-sm text-slate-900 dark:text-slate-100 leading-tight truncate" title={commit.message}>
329
348
  {commit.message}
330
349
  </p>
331
350
  <span class="text-3xs text-slate-400 shrink-0">{formatDate(commit.date)}</span>
@@ -349,7 +368,7 @@
349
368
  <div class="flex items-center gap-1 mt-px overflow-hidden">
350
369
  {#each commit.refs.slice(0, MAX_VISIBLE_REFS) as ref}
351
370
  <span
352
- class="text-3xs px-1 py-px rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0 truncate max-w-28"
371
+ class="text-3xs px-1 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0 truncate max-w-28"
353
372
  title={ref}
354
373
  >
355
374
  {truncateRef(ref)}
@@ -369,17 +388,12 @@
369
388
  </div>
370
389
  {/each}
371
390
 
372
- <!-- Load more -->
391
+ <!-- Infinite scroll sentinel -->
373
392
  {#if hasMore}
374
- <div class="flex justify-center py-3">
375
- <button
376
- type="button"
377
- 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"
378
- onclick={onLoadMore}
379
- disabled={isLoading}
380
- >
381
- {isLoading ? 'Loading...' : 'Load More'}
382
- </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}
383
397
  </div>
384
398
  {/if}
385
399
  </div>
@@ -71,7 +71,7 @@
71
71
  }
72
72
 
73
73
  try {
74
- const messages = await ws.http('messages:list', { session_id: sessionId });
74
+ const messages = await ws.http('messages:list', { session_id: sessionId, include_all: true });
75
75
 
76
76
  const firstUserMessage = messages.find((m: SDKMessage) => m.type === 'user');
77
77
  let title = 'New Conversation';
@@ -79,7 +79,7 @@
79
79
 
80
80
  try {
81
81
  // Get messages from current HEAD checkpoint (active branch only)
82
- const messages = await ws.http('messages:list', { session_id: sessionId });
82
+ const messages = await ws.http('messages:list', { session_id: sessionId, include_all: true });
83
83
 
84
84
  // Get title from first user message in current HEAD
85
85
  const firstUserMessage = messages.find((m: SDKMessage) => m.type === 'user');
@@ -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}
@@ -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}