@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.
- package/.dockerignore +5 -0
- package/.env.example +2 -5
- package/CONTRIBUTING.md +4 -0
- package/README.md +4 -2
- package/backend/database/queries/message-queries.ts +42 -0
- package/backend/database/utils/connection.ts +5 -5
- package/backend/engine/adapters/claude/environment.ts +3 -4
- package/backend/engine/adapters/opencode/server.ts +7 -1
- package/backend/git/git-executor.ts +2 -1
- package/backend/index.ts +10 -10
- package/backend/snapshot/blob-store.ts +2 -2
- package/backend/utils/env.ts +13 -15
- package/backend/utils/index.ts +4 -1
- package/backend/utils/paths.ts +11 -0
- package/backend/utils/port-utils.ts +19 -6
- package/backend/ws/messages/crud.ts +52 -0
- package/bin/clopen.ts +15 -15
- package/docker-compose.yml +31 -0
- package/frontend/components/auth/SetupPage.svelte +43 -11
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +124 -10
- package/frontend/components/common/feedback/UpdateBanner.svelte +2 -2
- package/frontend/components/history/HistoryModal.svelte +30 -78
- package/frontend/components/history/HistoryView.svelte +45 -92
- package/frontend/components/settings/appearance/AppearanceSettings.svelte +2 -2
- package/frontend/components/workspace/panels/FilesPanel.svelte +41 -3
- package/frontend/components/workspace/panels/GitPanel.svelte +41 -3
- package/frontend/stores/features/auth.svelte.ts +28 -0
- package/frontend/stores/ui/update.svelte.ts +6 -0
- package/package.json +2 -2
- package/scripts/dev.ts +3 -2
- package/scripts/start.ts +24 -0
- 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 {
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
120
|
-
style="
|
|
121
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
144
|
+
// Reload session data when new sessions arrive
|
|
203
145
|
$effect(() => {
|
|
204
146
|
if (sessions.length > 0 && !loadingSessionData) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 =
|
|
7
|
-
const FONT_SIZE_MAX =
|
|
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
|
-
? '
|
|
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
|
-
|
|
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
|
-
? '
|
|
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
|
-
|
|
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'
|