@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.
- package/backend/index.ts +5 -1
- package/backend/lib/chat/stream-manager.ts +4 -1
- package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
- package/backend/lib/database/migrations/index.ts +7 -0
- package/backend/lib/database/queries/session-queries.ts +50 -0
- package/backend/lib/database/queries/snapshot-queries.ts +1 -1
- package/backend/lib/engine/adapters/opencode/server.ts +8 -0
- package/backend/lib/engine/adapters/opencode/stream.ts +175 -1
- package/backend/lib/snapshot/helpers.ts +22 -49
- package/backend/lib/snapshot/snapshot-service.ts +148 -83
- package/backend/ws/chat/stream.ts +13 -0
- package/backend/ws/sessions/crud.ts +34 -2
- package/backend/ws/snapshot/restore.ts +111 -12
- package/backend/ws/snapshot/timeline.ts +56 -29
- package/backend/ws/user/crud.ts +8 -4
- package/bin/clopen.ts +17 -1
- package/frontend/lib/components/chat/input/ChatInput.svelte +1 -2
- package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +2 -2
- package/frontend/lib/components/chat/input/composables/use-chat-actions.svelte.ts +4 -4
- package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -19
- package/frontend/lib/components/checkpoint/TimelineModal.svelte +15 -3
- package/frontend/lib/components/checkpoint/timeline/TimelineNode.svelte +30 -19
- package/frontend/lib/components/checkpoint/timeline/types.ts +4 -0
- package/frontend/lib/components/git/CommitForm.svelte +6 -4
- package/frontend/lib/components/git/GitLog.svelte +26 -12
- package/frontend/lib/components/history/HistoryModal.svelte +1 -1
- package/frontend/lib/components/history/HistoryView.svelte +1 -1
- package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
- package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
- package/frontend/lib/components/terminal/Terminal.svelte +1 -1
- package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
- package/frontend/lib/components/workspace/PanelHeader.svelte +1 -1
- package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
- package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
- package/frontend/lib/stores/core/app.svelte.ts +46 -0
- package/frontend/lib/stores/core/sessions.svelte.ts +39 -4
- package/frontend/lib/stores/ui/update.svelte.ts +0 -12
- package/frontend/lib/stores/ui/workspace.svelte.ts +4 -4
- package/package.json +1 -1
package/backend/ws/user/crud.ts
CHANGED
|
@@ -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
|
-
|
|
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-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
22
|
-
const scrollHeight = textareaElement.scrollHeight
|
|
23
|
-
const
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
textareaElement.
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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-
|
|
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
|
-
|
|
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:
|
|
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="
|
|
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
|
|
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
|
-
<!--
|
|
391
|
+
<!-- Infinite scroll sentinel -->
|
|
373
392
|
{#if hasMore}
|
|
374
|
-
<div class="flex justify-center py-3">
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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-
|
|
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-
|
|
280
|
-
<div class="
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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={
|
|
305
|
-
disabled={isLoading}
|
|
306
|
-
class="
|
|
307
|
-
title="
|
|
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
|
-
|
|
326
|
+
Go
|
|
310
327
|
</button>
|
|
311
|
-
|
|
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
|
-
|
|
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
|
|
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}
|