@myrialabs/clopen 0.2.2 → 0.2.3

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 (32) hide show
  1. package/.dockerignore +5 -0
  2. package/.env.example +2 -5
  3. package/CONTRIBUTING.md +4 -0
  4. package/README.md +4 -2
  5. package/backend/database/queries/message-queries.ts +42 -0
  6. package/backend/database/utils/connection.ts +5 -5
  7. package/backend/engine/adapters/claude/environment.ts +3 -4
  8. package/backend/engine/adapters/opencode/server.ts +7 -1
  9. package/backend/git/git-executor.ts +2 -1
  10. package/backend/index.ts +10 -10
  11. package/backend/snapshot/blob-store.ts +2 -2
  12. package/backend/utils/env.ts +13 -15
  13. package/backend/utils/index.ts +4 -1
  14. package/backend/utils/paths.ts +11 -0
  15. package/backend/utils/port-utils.ts +19 -6
  16. package/backend/ws/messages/crud.ts +52 -0
  17. package/bin/clopen.ts +15 -15
  18. package/docker-compose.yml +31 -0
  19. package/frontend/components/auth/SetupPage.svelte +43 -11
  20. package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
  21. package/frontend/components/common/feedback/UpdateBanner.svelte +2 -2
  22. package/frontend/components/history/HistoryModal.svelte +30 -78
  23. package/frontend/components/history/HistoryView.svelte +45 -92
  24. package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
  25. package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
  26. package/frontend/components/workspace/panels/GitPanel.svelte +41 -3
  27. package/frontend/stores/features/auth.svelte.ts +28 -0
  28. package/frontend/stores/ui/update.svelte.ts +6 -0
  29. package/package.json +2 -2
  30. package/scripts/dev.ts +3 -2
  31. package/scripts/start.ts +24 -0
  32. package/vite.config.ts +2 -2
@@ -9,12 +9,103 @@
9
9
  import { sessionState } from '$frontend/stores/core/sessions.svelte';
10
10
  import { appState } from '$frontend/stores/core/app.svelte';
11
11
  import Icon from '$frontend/components/common/display/Icon.svelte';
12
- import { fade, fly } from 'svelte/transition';
12
+ import { fly } from 'svelte/transition';
13
13
  import type { TodoWriteToolInput } from '$shared/types/messaging';
14
14
 
15
15
  let isExpanded = $state(true);
16
16
  let isMinimized = $state(false);
17
17
 
18
+ // Drag & snap state
19
+ let posY = $state(80);
20
+ let posX = $state(0);
21
+ let snapSide = $state<'left' | 'right'>('right');
22
+ let isDragging = $state(false);
23
+
24
+ // Minimized button ref for measuring width at snap time
25
+ let minimizedBtn = $state<HTMLButtonElement | null>(null);
26
+
27
+ // Non-reactive drag tracking
28
+ let _sx = 0, _sy = 0, _mx = 0, _my = 0, _hasDragged = false;
29
+
30
+ function getPanelWidth() {
31
+ return isExpanded ? 330 : 230;
32
+ }
33
+
34
+ // Always use `left` property so CSS can transition in both directions
35
+ const panelDisplayLeft = $derived(
36
+ isDragging ? posX : snapSide === 'right' ? window.innerWidth - getPanelWidth() - 16 : 16
37
+ );
38
+
39
+ const minimizedDisplayLeft = $derived(
40
+ isDragging
41
+ ? posX
42
+ : snapSide === 'right'
43
+ ? window.innerWidth - (minimizedBtn?.offsetWidth ?? 90) - 16
44
+ : 16
45
+ );
46
+
47
+ // --- Main panel drag (from header) ---
48
+ function startDrag(e: PointerEvent) {
49
+ if ((e.target as HTMLElement).closest('button')) return;
50
+ isDragging = true;
51
+ // Use actual rendered position for accuracy
52
+ const panel = (e.currentTarget as HTMLElement).parentElement!;
53
+ const rect = panel.getBoundingClientRect();
54
+ _sx = rect.left;
55
+ _sy = rect.top;
56
+ _mx = e.clientX;
57
+ _my = e.clientY;
58
+ posX = _sx;
59
+ posY = _sy;
60
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
61
+ }
62
+
63
+ function onDrag(e: PointerEvent) {
64
+ if (!isDragging) return;
65
+ posX = _sx + e.clientX - _mx;
66
+ posY = Math.max(0, Math.min(window.innerHeight - 56, _sy + e.clientY - _my));
67
+ }
68
+
69
+ function endDrag(e: PointerEvent) {
70
+ if (!isDragging) return;
71
+ isDragging = false;
72
+ snapSide = posX + getPanelWidth() / 2 < window.innerWidth / 2 ? 'left' : 'right';
73
+ }
74
+
75
+ // --- Minimized button drag (click = restore, drag = move) ---
76
+ function startMinimizedDrag(e: PointerEvent) {
77
+ isDragging = true;
78
+ _hasDragged = false;
79
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
80
+ _sx = rect.left;
81
+ _sy = rect.top;
82
+ _mx = e.clientX;
83
+ _my = e.clientY;
84
+ posX = _sx;
85
+ posY = _sy;
86
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
87
+ }
88
+
89
+ function onMinimizedDrag(e: PointerEvent) {
90
+ if (!isDragging) return;
91
+ const dx = e.clientX - _mx;
92
+ const dy = e.clientY - _my;
93
+ if (Math.abs(dx) > 5 || Math.abs(dy) > 5) _hasDragged = true;
94
+ posX = _sx + dx;
95
+ posY = Math.max(0, Math.min(window.innerHeight - 56, _sy + dy));
96
+ }
97
+
98
+ function endMinimizedDrag(e: PointerEvent) {
99
+ if (!isDragging) return;
100
+ isDragging = false;
101
+ if (!_hasDragged) {
102
+ restore();
103
+ return;
104
+ }
105
+ const el = e.currentTarget as HTMLElement;
106
+ snapSide = posX + el.offsetWidth / 2 < window.innerWidth / 2 ? 'left' : 'right';
107
+ }
108
+
18
109
  // Extract the latest TodoWrite data from messages
19
110
  const latestTodos = $derived.by(() => {
20
111
  if (!sessionState.currentSession || sessionState.messages.length === 0) {
@@ -104,11 +195,22 @@
104
195
 
105
196
  {#if shouldShow && !appState.isRestoring}
106
197
  {#if isMinimized}
107
- <!-- Minimized state - small floating button -->
198
+ <!-- Minimized state - small floating button, draggable -->
108
199
  <button
109
- onclick={restore}
110
- class="fixed top-20 right-4 z-30 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white rounded-full p-3 shadow-lg transition-all duration-200 flex items-center gap-2"
111
- transition:fly={{ x: 100, duration: 200 }}
200
+ bind:this={minimizedBtn}
201
+ onpointerdown={startMinimizedDrag}
202
+ onpointermove={onMinimizedDrag}
203
+ onpointerup={endMinimizedDrag}
204
+ onpointercancel={endMinimizedDrag}
205
+ class="fixed z-30 bg-violet-600 hover:bg-violet-700 dark:bg-violet-500 dark:hover:bg-violet-600 text-white rounded-full p-3 shadow-lg flex items-center gap-2"
206
+ style="
207
+ top: {posY}px;
208
+ left: {minimizedDisplayLeft}px;
209
+ touch-action: none;
210
+ cursor: {isDragging ? 'grabbing' : 'grab'};
211
+ transition: {isDragging ? 'none' : 'left 0.25s ease, top 0.15s ease'};
212
+ "
213
+ transition:fly={{ x: snapSide === 'right' ? 100 : -100, duration: 200 }}
112
214
  >
113
215
  <Icon name="lucide:list-todo" class="w-5 h-5" />
114
216
  <span class="text-sm font-medium">{progress.completed}/{progress.total}</span>
@@ -116,13 +218,25 @@
116
218
  {:else}
117
219
  <!-- Floating panel -->
118
220
  <div
119
- class="fixed top-20 right-4 z-30 bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden transition-all duration-300"
120
- style="width: {isExpanded ? '330px' : '230px'}; max-height: {isExpanded ? '600px' : '56px'}"
121
- transition:fly={{ x: 100, duration: 300 }}
221
+ class="fixed z-30 bg-white dark:bg-slate-900 rounded-xl shadow-lg border border-slate-200 dark:border-slate-700 overflow-hidden"
222
+ style="
223
+ top: {posY}px;
224
+ left: {panelDisplayLeft}px;
225
+ width: {isExpanded ? '330px' : '230px'};
226
+ max-height: {isExpanded ? '600px' : '56px'};
227
+ transition: {isDragging ? 'none' : 'left 0.25s ease, top 0.15s ease, width 0.3s, max-height 0.3s'};
228
+ "
229
+ transition:fly={{ x: snapSide === 'right' ? 100 : -100, duration: 300 }}
122
230
  >
123
- <!-- Header -->
231
+ <!-- Header (drag handle) -->
124
232
  <div
125
233
  class="flex items-center justify-between px-4 py-3 bg-gradient-to-r from-violet-50 to-violet-50 dark:from-slate-800 dark:to-slate-800 border-b border-slate-200 dark:border-slate-700"
234
+ style="touch-action: none; cursor: {isDragging ? 'grabbing' : 'grab'};"
235
+ onpointerdown={startDrag}
236
+ onpointermove={onDrag}
237
+ onpointerup={endDrag}
238
+ onpointercancel={endDrag}
239
+ role="none"
126
240
  >
127
241
  <div class="flex items-center gap-3">
128
242
  <Icon name="lucide:list-todo" class="w-5 h-5 text-violet-600 dark:text-violet-400" />
@@ -246,4 +360,4 @@
246
360
  :global(.dark) div::-webkit-scrollbar-thumb:hover {
247
361
  background: rgb(71 85 105);
248
362
  }
249
- </style>
363
+ </style>
@@ -49,12 +49,12 @@
49
49
  <span>Updated to v{updateState.latestVersion} — restart clopen to apply</span>
50
50
  {:else if updateState.error}
51
51
  <Icon name="lucide:package-x" class="w-4 h-4" />
52
- <span>Update failed</span>
52
+ <span>{updateState.errorType === 'check' ? 'Unable to check for updates' : 'Update failed'}</span>
53
53
  <button
54
54
  onclick={handleRetry}
55
55
  class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
56
56
  >
57
- Retry
57
+ {updateState.errorType === 'check' ? 'Check again' : 'Retry'}
58
58
  </button>
59
59
  <button
60
60
  onclick={handleDismiss}
@@ -5,7 +5,6 @@
5
5
  import { addNotification } from '$frontend/stores/ui/notification.svelte';
6
6
  import ws from '$frontend/utils/ws';
7
7
  import type { ChatSession } from '$shared/types/database/schema';
8
- import type { SDKMessage } from '$shared/types/messaging';
9
8
  import Icon from '$frontend/components/common/display/Icon.svelte';
10
9
  import AvatarBubble from '$frontend/components/common/display/AvatarBubble.svelte';
11
10
  import Modal from '$frontend/components/common/overlay/Modal.svelte';
@@ -55,7 +54,6 @@
55
54
 
56
55
  // Cache for session data to avoid multiple API calls
57
56
  let sessionDataCache = $state<Record<string, {
58
- messages: SDKMessage[];
59
57
  title: string;
60
58
  summary: string;
61
59
  count: number;
@@ -64,87 +62,30 @@
64
62
  }>>({});
65
63
  let loadingSessionData = $state(false);
66
64
 
67
- // Helper to get session data from cache or API
65
+ // Helper to get session data from cache or API (single session fallback)
68
66
  async function getSessionData(sessionId: string) {
69
67
  if (sessionDataCache[sessionId]) {
70
68
  return sessionDataCache[sessionId];
71
69
  }
72
70
 
73
71
  try {
74
- const messages = await ws.http('messages:list', { session_id: sessionId, include_all: true });
75
-
76
- const firstUserMessage = messages.find((m: SDKMessage) => m.type === 'user');
77
- let title = 'New Conversation';
78
- if (firstUserMessage) {
79
- let textContent = '';
80
- if (typeof firstUserMessage.message.content === 'string') {
81
- textContent = firstUserMessage.message.content;
82
- } else if (Array.isArray(firstUserMessage.message.content)) {
83
- const textBlocks = firstUserMessage.message.content.filter((c: any) => c.type === 'text');
84
- textContent = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
85
- }
86
-
87
- if (textContent) {
88
- title = textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '');
89
- }
72
+ const previews = await ws.http('sessions:preview', { session_ids: [sessionId] });
73
+ const preview = previews[0];
74
+ if (preview) {
75
+ sessionDataCache[sessionId] = {
76
+ title: preview.title,
77
+ summary: preview.summary,
78
+ count: preview.count,
79
+ userCount: preview.userCount,
80
+ assistantCount: preview.assistantCount
81
+ };
82
+ return sessionDataCache[sessionId];
90
83
  }
91
-
92
- const assistantMessages = messages.filter((m: SDKMessage) => m.type === 'assistant');
93
- let summary = 'No messages yet';
94
- if (assistantMessages.length > 0) {
95
- const lastMessage = assistantMessages[assistantMessages.length - 1];
96
- const textBlocks = lastMessage.message.content.filter((c: any) => c.type === 'text');
97
- if (textBlocks.length > 0) {
98
- const fullText = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
99
- const cleanText = fullText.replace(/```[\s\S]*?```/g, '').trim();
100
- summary = cleanText.slice(0, 100) + (cleanText.length > 100 ? '...' : '');
101
- }
102
- }
103
-
104
- const userMessages = messages.filter((m: SDKMessage) => {
105
- if (m.type !== 'user') return false;
106
- let textContent = '';
107
- if (typeof m.message.content === 'string') {
108
- textContent = m.message.content;
109
- } else if (Array.isArray(m.message.content)) {
110
- const textBlocks = m.message.content.filter(c => c.type === 'text');
111
- textContent = textBlocks.map(b => 'text' in b ? b.text : '').join(' ');
112
- }
113
- return textContent.trim().length > 0;
114
- });
115
-
116
- const totalBubbles = userMessages.length + assistantMessages.length;
117
-
118
- const data = {
119
- messages,
120
- title,
121
- summary,
122
- count: totalBubbles,
123
- userCount: userMessages.length,
124
- assistantCount: assistantMessages.length
125
- };
126
-
127
- sessionDataCache[sessionId] = data;
128
- debug.log('session', `Loaded session ${sessionId}:`, {
129
- title,
130
- totalMessages: messages.length,
131
- userCount: userMessages.length,
132
- assistantCount: assistantMessages.length,
133
- totalBubbles: totalBubbles,
134
- summary: summary.substring(0, 50)
135
- });
136
- return data;
137
84
  } catch (error) {
138
85
  debug.error('session', 'Error fetching session data:', error);
139
- return {
140
- messages: [],
141
- title: 'New Conversation',
142
- summary: 'No messages yet',
143
- count: 0,
144
- userCount: 0,
145
- assistantCount: 0
146
- };
147
86
  }
87
+
88
+ return { title: 'New Conversation', summary: 'No messages yet', count: 0, userCount: 0, assistantCount: 0 };
148
89
  }
149
90
 
150
91
  function getMessageCount(sessionId: string): number {
@@ -167,12 +108,23 @@
167
108
  loadingSessionData = true;
168
109
  try {
169
110
  // Sort newest first and load top 20 so new sessions are always included
170
- const sortedSessions = [...sessions]
111
+ const sessionIds = [...sessions]
171
112
  .sort((a, b) => new Date(b.started_at).getTime() - new Date(a.started_at).getTime())
172
- .slice(0, 20);
173
- await Promise.all(
174
- sortedSessions.map(session => getSessionData(session.id))
175
- );
113
+ .slice(0, 20)
114
+ .map(s => s.id);
115
+
116
+ if (sessionIds.length === 0) return;
117
+
118
+ const previews = await ws.http('sessions:preview', { session_ids: sessionIds });
119
+ for (const preview of previews) {
120
+ sessionDataCache[preview.session_id] = {
121
+ title: preview.title,
122
+ summary: preview.summary,
123
+ count: preview.count,
124
+ userCount: preview.userCount,
125
+ assistantCount: preview.assistantCount
126
+ };
127
+ }
176
128
  } catch (error) {
177
129
  debug.error('session', 'Error preloading session data:', error);
178
130
  } finally {
@@ -6,7 +6,6 @@
6
6
  import { setCurrentView } from '$frontend/stores/core/app.svelte';
7
7
  import ws from '$frontend/utils/ws';
8
8
  import type { ChatSession, Project } from '$shared/types/database/schema';
9
- import type { SDKMessage } from '$shared/types/messaging';
10
9
  import Input from '../common/form/Input.svelte';
11
10
  import Select from '../common/form/Select.svelte';
12
11
  import Icon from '$frontend/components/common/display/Icon.svelte';
@@ -62,7 +61,6 @@
62
61
 
63
62
  // Cache for session data to avoid multiple API calls
64
63
  const sessionDataCache = $state<Record<string, {
65
- messages: SDKMessage[];
66
64
  title: string;
67
65
  summary: string;
68
66
  count: number;
@@ -71,95 +69,30 @@
71
69
  }>>({});
72
70
  let loadingSessionData = $state(true);
73
71
 
74
- // Helper to get session data from cache or API
72
+ // Helper to get session data from cache or API (single session fallback)
75
73
  async function getSessionData(sessionId: string) {
76
74
  if (sessionDataCache[sessionId]) {
77
75
  return sessionDataCache[sessionId];
78
76
  }
79
77
 
80
78
  try {
81
- // Get messages from current HEAD checkpoint (active branch only)
82
- const messages = await ws.http('messages:list', { session_id: sessionId, include_all: true });
83
-
84
- // Get title from first user message in current HEAD
85
- const firstUserMessage = messages.find((m: SDKMessage) => m.type === 'user');
86
- let title = 'New Conversation';
87
- if (firstUserMessage) {
88
- // Handle content properly - it can be string or array of content blocks
89
- let textContent = '';
90
- if (typeof firstUserMessage.message.content === 'string') {
91
- textContent = firstUserMessage.message.content;
92
- } else if (Array.isArray(firstUserMessage.message.content)) {
93
- // Extract text from content blocks
94
- const textBlocks = firstUserMessage.message.content.filter((c: any) => c.type === 'text');
95
- textContent = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
96
- }
97
-
98
- if (textContent) {
99
- title = textContent.slice(0, 60) + (textContent.length > 60 ? '...' : '');
100
- }
101
- }
102
-
103
- // Get summary from last assistant message in current HEAD checkpoint
104
- const assistantMessages = messages.filter((m: SDKMessage) => m.type === 'assistant');
105
- let summary = 'No messages yet';
106
- if (assistantMessages.length > 0) {
107
- const lastMessage = assistantMessages[assistantMessages.length - 1];
108
- const textBlocks = lastMessage.message.content.filter((c: any) => c.type === 'text');
109
- if (textBlocks.length > 0) {
110
- const fullText = textBlocks.map((b: any) => 'text' in b ? b.text : '').join(' ');
111
- const cleanText = fullText.replace(/```[\s\S]*?```/g, '').trim();
112
- summary = cleanText.slice(0, 150) + (cleanText.length > 150 ? '...' : '');
113
- }
79
+ const previews = await ws.http('sessions:preview', { session_ids: [sessionId] });
80
+ const preview = previews[0];
81
+ if (preview) {
82
+ sessionDataCache[sessionId] = {
83
+ title: preview.title,
84
+ summary: preview.summary,
85
+ count: preview.count,
86
+ userCount: preview.userCount,
87
+ assistantCount: preview.assistantCount
88
+ };
89
+ return sessionDataCache[sessionId];
114
90
  }
115
-
116
- // Count user and assistant messages in current HEAD checkpoint
117
- // Filter out empty user messages (same as ChatInterface.svelte timeline logic)
118
- const userMessages = messages.filter((m: SDKMessage) => {
119
- if (m.type !== 'user') return false;
120
- // Extract text content
121
- let textContent = '';
122
- if (typeof m.message.content === 'string') {
123
- textContent = m.message.content;
124
- } else if (Array.isArray(m.message.content)) {
125
- const textBlocks = m.message.content.filter(c => c.type === 'text');
126
- textContent = textBlocks.map(b => 'text' in b ? b.text : '').join(' ');
127
- }
128
- return textContent.trim().length > 0;
129
- });
130
-
131
- const totalBubbles = userMessages.length + assistantMessages.length; // Total message bubbles in chat
132
-
133
- const data = {
134
- messages,
135
- title,
136
- summary,
137
- count: totalBubbles, // Total bubbles (user + assistant with non-empty content)
138
- userCount: userMessages.length, // Number of chat sessions/exchanges (non-empty user messages)
139
- assistantCount: assistantMessages.length
140
- };
141
-
142
- sessionDataCache[sessionId] = data;
143
- debug.log('session', `Loaded session ${sessionId}:`, {
144
- title,
145
- totalMessages: messages.length,
146
- userCount: userMessages.length,
147
- assistantCount: assistantMessages.length,
148
- totalBubbles: totalBubbles,
149
- summary: summary.substring(0, 50)
150
- });
151
- return data;
152
91
  } catch (error) {
153
92
  debug.error('session', 'Error fetching session data:', error);
154
- return {
155
- messages: [],
156
- title: 'New Conversation',
157
- summary: 'No messages yet',
158
- count: 0,
159
- userCount: 0,
160
- assistantCount: 0
161
- };
162
93
  }
94
+
95
+ return { title: 'New Conversation', summary: 'No messages yet', count: 0, userCount: 0, assistantCount: 0 };
163
96
  }
164
97
 
165
98
  // Helper functions that use cached data
@@ -179,14 +112,23 @@
179
112
  return sessionDataCache[sessionId]?.summary || 'No messages yet';
180
113
  }
181
114
 
182
- // Preload session data for visible sessions
115
+ // Preload session data for visible sessions using a single bulk request
183
116
  async function preloadSessionData() {
184
117
  loadingSessionData = true;
185
118
  try {
186
- // Load all sessions in parallel for better performance
187
- await Promise.all(
188
- sessions.slice(0, 20).map(session => getSessionData(session.id))
189
- );
119
+ const sessionIds = sessions.slice(0, 20).map(s => s.id);
120
+ if (sessionIds.length === 0) return;
121
+
122
+ const previews = await ws.http('sessions:preview', { session_ids: sessionIds });
123
+ for (const preview of previews) {
124
+ sessionDataCache[preview.session_id] = {
125
+ title: preview.title,
126
+ summary: preview.summary,
127
+ count: preview.count,
128
+ userCount: preview.userCount,
129
+ assistantCount: preview.assistantCount
130
+ };
131
+ }
190
132
  } catch (error) {
191
133
  debug.error('session', 'Error preloading session data:', error);
192
134
  } finally {
@@ -199,14 +141,25 @@
199
141
  preloadSessionData();
200
142
  });
201
143
 
202
- // Reload session data when sessions change
144
+ // Reload session data when new sessions arrive
203
145
  $effect(() => {
204
146
  if (sessions.length > 0 && !loadingSessionData) {
205
- // Check if there are sessions without cached data
206
- const uncachedSessions = sessions.filter(s => !sessionDataCache[s.id]);
207
- if (uncachedSessions.length > 0) {
208
- debug.log('session', `Found ${uncachedSessions.length} uncached sessions, loading...`);
209
- preloadSessionData();
147
+ const uncachedIds = sessions.filter(s => !sessionDataCache[s.id]).map(s => s.id);
148
+ if (uncachedIds.length > 0) {
149
+ debug.log('session', `Found ${uncachedIds.length} uncached sessions, loading...`);
150
+ ws.http('sessions:preview', { session_ids: uncachedIds }).then((previews: any[]) => {
151
+ for (const preview of previews) {
152
+ sessionDataCache[preview.session_id] = {
153
+ title: preview.title,
154
+ summary: preview.summary,
155
+ count: preview.count,
156
+ userCount: preview.userCount,
157
+ assistantCount: preview.assistantCount
158
+ };
159
+ }
160
+ }).catch((err: unknown) => {
161
+ debug.error('session', 'Error loading uncached sessions:', err);
162
+ });
210
163
  }
211
164
  }
212
165
  });
@@ -3,8 +3,8 @@
3
3
  import { settings, updateSettings, applyFontSize } from '$frontend/stores/features/settings.svelte';
4
4
  import Icon from '../../common/display/Icon.svelte';
5
5
 
6
- const FONT_SIZE_MIN = 10;
7
- const FONT_SIZE_MAX = 20;
6
+ const FONT_SIZE_MIN = 8;
7
+ const FONT_SIZE_MAX = 24;
8
8
 
9
9
  function handleFontSizeChange(e: Event) {
10
10
  const value = Number((e.target as HTMLInputElement).value);
@@ -139,6 +139,8 @@
139
139
  // Container width detection for 2-column layout
140
140
  let containerRef = $state<HTMLDivElement | null>(null);
141
141
  let containerWidth = $state(0);
142
+ let leftPanelWidth = $state(288); // default w-72
143
+ let isResizing = $state(false);
142
144
  const TWO_COLUMN_THRESHOLD = $derived(Math.round(600 * (settings.fontSize / 13)));
143
145
 
144
146
  // FileTree ref
@@ -1101,6 +1103,26 @@
1101
1103
  }
1102
1104
  });
1103
1105
 
1106
+ function startColumnResize(e: MouseEvent) {
1107
+ isResizing = true;
1108
+ const startX = e.clientX;
1109
+ const startWidth = leftPanelWidth;
1110
+
1111
+ function onMouseMove(e: MouseEvent) {
1112
+ const delta = e.clientX - startX;
1113
+ leftPanelWidth = Math.max(120, Math.min(startWidth + delta, containerWidth - 120));
1114
+ }
1115
+
1116
+ function onMouseUp() {
1117
+ isResizing = false;
1118
+ window.removeEventListener('mousemove', onMouseMove);
1119
+ window.removeEventListener('mouseup', onMouseUp);
1120
+ }
1121
+
1122
+ window.addEventListener('mousemove', onMouseMove);
1123
+ window.addEventListener('mouseup', onMouseUp);
1124
+ }
1125
+
1104
1126
  // Monitor container width for responsive layout
1105
1127
  onMount(() => {
1106
1128
  if (containerRef && typeof ResizeObserver !== 'undefined') {
@@ -1182,12 +1204,13 @@
1182
1204
  {:else}
1183
1205
  <div class="flex-1 overflow-hidden">
1184
1206
  <!-- Unified layout: always render both Tree and Viewer to preserve internal state -->
1185
- <div class="h-full flex">
1207
+ <div class="h-full flex" class:select-none={isResizing} class:cursor-col-resize={isResizing}>
1186
1208
  <!-- Tree panel: always rendered, hidden via CSS in 1-column viewer mode -->
1187
1209
  <div
1188
1210
  class={isTwoColumnMode
1189
- ? 'w-72 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700'
1211
+ ? 'flex-shrink-0 h-full overflow-hidden'
1190
1212
  : (viewMode === 'tree' ? 'w-full h-full overflow-hidden' : 'hidden')}
1213
+ style={isTwoColumnMode ? `width: ${leftPanelWidth}px` : undefined}
1191
1214
  >
1192
1215
  <div class="h-full overflow-auto" bind:this={treeScrollContainer}>
1193
1216
  <FileTree
@@ -1210,7 +1233,22 @@
1210
1233
  </div>
1211
1234
  </div>
1212
1235
 
1213
- <!-- Editor panel: always rendered, hidden via CSS in 1-column tree mode -->
1236
+ {#if isTwoColumnMode}
1237
+ <!-- Column resize handle -->
1238
+ <div
1239
+ class="relative flex-shrink-0 h-full w-px cursor-col-resize group"
1240
+ role="separator"
1241
+ aria-orientation="vertical"
1242
+ onmousedown={startColumnResize}
1243
+ >
1244
+ <!-- Invisible extended hit area (6px each side) -->
1245
+ <div class="absolute inset-y-0 -left-1.5 -right-1.5 cursor-col-resize z-10"></div>
1246
+ <!-- Visual line: 1px default, expands to 4px on hover -->
1247
+ <div class="absolute inset-y-0 left-1/2 -translate-x-1/2 w-px group-hover:w-1 bg-slate-200 dark:bg-slate-700 group-hover:bg-blue-400 dark:group-hover:bg-blue-500 transition-all duration-150"></div>
1248
+ </div>
1249
+ {/if}
1250
+
1251
+ <!-- Editor panel: always rendered, hidden via CSS in 1-column tree mode -->
1214
1252
  <div
1215
1253
  class={isTwoColumnMode
1216
1254
  ? 'flex-1 h-full overflow-hidden flex flex-col'
@@ -129,6 +129,8 @@
129
129
  // Container width for responsive layout (same threshold as Files: 800)
130
130
  let containerRef = $state<HTMLDivElement | null>(null);
131
131
  let containerWidth = $state(0);
132
+ let leftPanelWidth = $state(288); // default w-72
133
+ let isResizing = $state(false);
132
134
  const TWO_COLUMN_THRESHOLD = $derived(Math.round(600 * (settings.fontSize / 13)));
133
135
  const isTwoColumnMode = $derived(containerWidth >= TWO_COLUMN_THRESHOLD);
134
136
 
@@ -1025,6 +1027,26 @@
1025
1027
  return () => unsub();
1026
1028
  });
1027
1029
 
1030
+ function startColumnResize(e: MouseEvent) {
1031
+ isResizing = true;
1032
+ const startX = e.clientX;
1033
+ const startWidth = leftPanelWidth;
1034
+
1035
+ function onMouseMove(e: MouseEvent) {
1036
+ const delta = e.clientX - startX;
1037
+ leftPanelWidth = Math.max(120, Math.min(startWidth + delta, containerWidth - 120));
1038
+ }
1039
+
1040
+ function onMouseUp() {
1041
+ isResizing = false;
1042
+ window.removeEventListener('mousemove', onMouseMove);
1043
+ window.removeEventListener('mouseup', onMouseUp);
1044
+ }
1045
+
1046
+ window.addEventListener('mousemove', onMouseMove);
1047
+ window.addEventListener('mouseup', onMouseUp);
1048
+ }
1049
+
1028
1050
  // Monitor container width
1029
1051
  onMount(() => {
1030
1052
  let resizeObserver: ResizeObserver | null = null;
@@ -1442,18 +1464,34 @@
1442
1464
  {:else}
1443
1465
  <div class="flex-1 overflow-hidden">
1444
1466
  <!-- Unified layout: always render both panels to preserve state (like Files panel) -->
1445
- <div class="h-full flex">
1467
+ <div class="h-full flex" class:select-none={isResizing} class:cursor-col-resize={isResizing}>
1446
1468
  <!-- Left panel: Changes list -->
1447
1469
  <div
1448
1470
  class={isTwoColumnMode
1449
- ? 'w-72 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700 flex flex-col'
1471
+ ? 'flex-shrink-0 h-full overflow-hidden flex flex-col'
1450
1472
  : (viewMode === 'list' ? 'w-full h-full overflow-hidden flex flex-col' : 'hidden')}
1473
+ style={isTwoColumnMode ? `width: ${leftPanelWidth}px` : undefined}
1451
1474
  >
1452
1475
  {@render viewTabBar()}
1453
1476
  {@render changesList()}
1454
1477
  </div>
1455
1478
 
1456
- <!-- Right panel: Diff viewer -->
1479
+ {#if isTwoColumnMode}
1480
+ <!-- Column resize handle -->
1481
+ <div
1482
+ class="relative flex-shrink-0 h-full w-px cursor-col-resize group"
1483
+ role="separator"
1484
+ aria-orientation="vertical"
1485
+ onmousedown={startColumnResize}
1486
+ >
1487
+ <!-- Invisible extended hit area (6px each side) -->
1488
+ <div class="absolute inset-y-0 -left-1.5 -right-1.5 cursor-col-resize z-10"></div>
1489
+ <!-- Visual line: 1px default, expands to 4px on hover -->
1490
+ <div class="absolute inset-y-0 left-1/2 -translate-x-1/2 w-px group-hover:w-1 bg-slate-200 dark:bg-slate-700 group-hover:bg-blue-400 dark:group-hover:bg-blue-500 transition-all duration-150"></div>
1491
+ </div>
1492
+ {/if}
1493
+
1494
+ <!-- Right panel: Diff viewer -->
1457
1495
  <div
1458
1496
  class={isTwoColumnMode
1459
1497
  ? 'flex-1 h-full overflow-hidden flex flex-col'