claude-remote 0.5.2 → 0.6.0
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/lib/cli.js +1 -0
- package/lib/http-server.js +5 -0
- package/lib/state.js +1 -0
- package/package.json +3 -2
- package/server.js +6 -0
- package/web/index.html +346 -0
- package/web/main.js +68 -0
- package/web/modules/chat-cache.js +118 -0
- package/web/modules/confirm.js +25 -0
- package/web/modules/constants.js +59 -0
- package/web/modules/debug.js +81 -0
- package/web/modules/dir-picker.js +128 -0
- package/web/modules/hub.js +619 -0
- package/web/modules/image-upload.js +290 -0
- package/web/modules/input.js +279 -0
- package/web/modules/interactions.js +304 -0
- package/web/modules/keyboard.js +78 -0
- package/web/modules/model-picker.js +47 -0
- package/web/modules/permissions.js +94 -0
- package/web/modules/renderer.js +863 -0
- package/web/modules/sessions.js +108 -0
- package/web/modules/settings.js +74 -0
- package/web/modules/state.js +59 -0
- package/web/modules/toast.js +68 -0
- package/web/modules/todo.js +292 -0
- package/web/modules/utils.js +102 -0
- package/web/modules/waiting.js +93 -0
- package/web/modules/websocket.js +483 -0
- package/web/styles.css +1722 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Utilities
|
|
3
|
+
// ============================================================
|
|
4
|
+
export const $ = id => document.getElementById(id);
|
|
5
|
+
|
|
6
|
+
export function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
|
7
|
+
export function trunc(s, n) { return (!s || s.length <= n) ? s : s.substring(0, n) + '...'; }
|
|
8
|
+
export function stripImageTags(s) { return (s || '').replace(/\[Image:\s*source:\s*[^\]]*\]/g, '').trim(); }
|
|
9
|
+
|
|
10
|
+
export function formatUrlForDisplay(url, includeScheme) {
|
|
11
|
+
const auth = url.username ? `${url.username}${url.password ? `:${url.password}` : ''}@` : '';
|
|
12
|
+
const base = `${auth}${url.host}`;
|
|
13
|
+
const path = url.pathname === '/' ? '' : url.pathname;
|
|
14
|
+
const prefix = includeScheme ? `${url.protocol}//` : '';
|
|
15
|
+
return `${prefix}${base}${path}${url.search}${url.hash}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseServerAddress(input) {
|
|
19
|
+
const raw = input.trim();
|
|
20
|
+
if (!raw) return { ok: false, error: 'Please enter a server address' };
|
|
21
|
+
|
|
22
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw);
|
|
23
|
+
let url;
|
|
24
|
+
try {
|
|
25
|
+
url = new URL(hasScheme ? raw : `ws://${raw}`);
|
|
26
|
+
} catch {
|
|
27
|
+
return { ok: false, error: 'Invalid address' };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const protocol = url.protocol.toLowerCase();
|
|
31
|
+
let wsProtocol;
|
|
32
|
+
if (protocol === 'ws:') wsProtocol = 'ws:';
|
|
33
|
+
else if (protocol === 'wss:') wsProtocol = 'wss:';
|
|
34
|
+
else if (protocol === 'http:') wsProtocol = 'ws:';
|
|
35
|
+
else if (protocol === 'https:') wsProtocol = 'wss:';
|
|
36
|
+
else return { ok: false, error: 'Use ws://, wss://, http://, https://, or host:port' };
|
|
37
|
+
|
|
38
|
+
if (!url.hostname) return { ok: false, error: 'Invalid address' };
|
|
39
|
+
if (!hasScheme && !url.port) url.port = '3100';
|
|
40
|
+
|
|
41
|
+
const wsUrl = new URL(url.toString());
|
|
42
|
+
wsUrl.protocol = wsProtocol;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
displayAddr: formatUrlForDisplay(url, hasScheme),
|
|
47
|
+
wsUrl: wsUrl.toString(),
|
|
48
|
+
cacheAddr: wsUrl.toString(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function shortenPath(p) {
|
|
53
|
+
if (!p) return '';
|
|
54
|
+
const parts = p.replace(/\\/g, '/').replace(/\/$/, '').split('/');
|
|
55
|
+
if (parts.length <= 2) return parts.join('/');
|
|
56
|
+
return parts.slice(-2).join('/');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatModel(m) {
|
|
60
|
+
if (!m) return '';
|
|
61
|
+
return m.replace(/^claude-/, '').replace(/-(\d)/g, ' $1').replace(/-/g, ' ')
|
|
62
|
+
.replace(/\b\w/g, c => c.toUpperCase());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function formatTokens(n) {
|
|
66
|
+
if (!n) return '';
|
|
67
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'k tokens';
|
|
68
|
+
return n + ' tokens';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatElapsed(ms) {
|
|
72
|
+
const s = Math.floor(ms / 1000);
|
|
73
|
+
if (s < 60) return s + 's';
|
|
74
|
+
const m = Math.floor(s / 60);
|
|
75
|
+
const rem = s % 60;
|
|
76
|
+
return m + 'm' + (rem > 0 ? rem + 's' : '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function timeAgo(ts) {
|
|
80
|
+
if (!ts) return 'Never';
|
|
81
|
+
const diff = Date.now() - ts;
|
|
82
|
+
const sec = Math.floor(diff / 1000);
|
|
83
|
+
if (sec < 60) return 'just now';
|
|
84
|
+
const min = Math.floor(sec / 60);
|
|
85
|
+
if (min < 60) return min + 'm ago';
|
|
86
|
+
const hr = Math.floor(min / 60);
|
|
87
|
+
if (hr < 24) return hr + 'h ago';
|
|
88
|
+
const day = Math.floor(hr / 24);
|
|
89
|
+
return day + 'd ago';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function normalizePathForCompare(p) {
|
|
93
|
+
return String(p || '').replace(/[\\/]+$/, '').toLowerCase();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function makeUploadId() {
|
|
97
|
+
return `upl_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function generateServerId() {
|
|
101
|
+
return 's_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
|
|
102
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Waiting / Working indicator + Scroll
|
|
3
|
+
// ============================================================
|
|
4
|
+
import { $ } from './utils.js';
|
|
5
|
+
import { formatElapsed } from './utils.js';
|
|
6
|
+
import { S } from './state.js';
|
|
7
|
+
import { debugLog } from './debug.js';
|
|
8
|
+
import { setSendButtonMode, updateSendBtn } from './input.js';
|
|
9
|
+
|
|
10
|
+
const $msgs = $('messages');
|
|
11
|
+
const $chat = $('chat-area');
|
|
12
|
+
const $input = $('input');
|
|
13
|
+
const INPUT_PLACEHOLDER_DEFAULT = 'Reply...';
|
|
14
|
+
const INPUT_PLACEHOLDER_WAITING = 'AI 思考中…';
|
|
15
|
+
|
|
16
|
+
export function scrollEnd() {
|
|
17
|
+
keepWorkingAtBottom();
|
|
18
|
+
if (S.isAtBottom) requestAnimationFrame(() => { $chat.scrollTop = $chat.scrollHeight; });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function updateScrollBtn() {
|
|
22
|
+
$('btn-scroll').classList.toggle('visible', !S.isAtBottom);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function setWaiting(on, reason = '') {
|
|
26
|
+
debugLog(on ? 'waiting_on' : 'waiting_off', {
|
|
27
|
+
reason,
|
|
28
|
+
waitingBefore: S.waiting,
|
|
29
|
+
sessionId: S.sessionId || null,
|
|
30
|
+
lastSeq: S.lastSeq,
|
|
31
|
+
replaying: S.replaying,
|
|
32
|
+
});
|
|
33
|
+
S.waiting = on;
|
|
34
|
+
if (on) {
|
|
35
|
+
S.waitStartedAt = Date.now();
|
|
36
|
+
} else {
|
|
37
|
+
S.waitStartedAt = 0;
|
|
38
|
+
}
|
|
39
|
+
$input.disabled = on;
|
|
40
|
+
$input.placeholder = on ? INPUT_PLACEHOLDER_WAITING : INPUT_PLACEHOLDER_DEFAULT;
|
|
41
|
+
$('input-area').classList.toggle('waiting', on);
|
|
42
|
+
|
|
43
|
+
if (on) {
|
|
44
|
+
setSendButtonMode('stop');
|
|
45
|
+
removeWorkingIndicator();
|
|
46
|
+
const el = document.createElement('div');
|
|
47
|
+
el.className = 'working-indicator';
|
|
48
|
+
el.innerHTML = '<div class="working-spinner"></div><span class="working-text">Thinking</span>';
|
|
49
|
+
$msgs.appendChild(el);
|
|
50
|
+
S.workingEl = el;
|
|
51
|
+
scrollEnd();
|
|
52
|
+
} else {
|
|
53
|
+
setSendButtonMode('send');
|
|
54
|
+
showElapsedTime();
|
|
55
|
+
removeWorkingIndicator();
|
|
56
|
+
}
|
|
57
|
+
updateSendBtn();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function switchToWorking() {
|
|
61
|
+
if (S.workingEl) {
|
|
62
|
+
const txt = S.workingEl.querySelector('.working-text');
|
|
63
|
+
if (txt) txt.textContent = 'Working';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function keepWorkingAtBottom() {
|
|
68
|
+
if (S.workingEl && S.workingEl.parentNode) {
|
|
69
|
+
$msgs.appendChild(S.workingEl);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function showElapsedTime() {
|
|
74
|
+
if (!S.waitStartedAt) return;
|
|
75
|
+
const elapsed = Date.now() - S.waitStartedAt;
|
|
76
|
+
if (elapsed < 1000) return;
|
|
77
|
+
const el = document.createElement('div');
|
|
78
|
+
el.className = 'elapsed-time';
|
|
79
|
+
el.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span>${formatElapsed(elapsed)}</span>`;
|
|
80
|
+
$msgs.appendChild(el);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function removeWorkingIndicator() {
|
|
84
|
+
if (S.workingEl && S.workingEl.parentNode) S.workingEl.remove();
|
|
85
|
+
S.workingEl = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function initWaiting() {
|
|
89
|
+
$chat.addEventListener('scroll', () => {
|
|
90
|
+
S.isAtBottom = ($chat.scrollHeight - $chat.scrollTop - $chat.clientHeight) < 60;
|
|
91
|
+
updateScrollBtn();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// WebSocket connection management
|
|
3
|
+
// ============================================================
|
|
4
|
+
import {
|
|
5
|
+
WS_CLOSE_AUTH_FAILED, WS_CLOSE_AUTH_TIMEOUT,
|
|
6
|
+
WS_CLOSE_REASON_AUTH_FAILED, WS_CLOSE_REASON_AUTH_TIMEOUT,
|
|
7
|
+
FOREGROUND_PROBE_TIMEOUT_MS, FOREGROUND_RECOVER_DEBOUNCE_MS,
|
|
8
|
+
} from './constants.js';
|
|
9
|
+
import { $ } from './utils.js';
|
|
10
|
+
import { S, serverWsUrl, serverToken, pendingImage, approvalMode } from './state.js';
|
|
11
|
+
import { debugLog, wsReadyStateName, CLIENT_INSTANCE_ID, flushPendingDebugLogs } from './debug.js';
|
|
12
|
+
import { showToast } from './toast.js';
|
|
13
|
+
import {
|
|
14
|
+
hideHubConnectOverlay, renderHubCards, showApp, showConnectScreen,
|
|
15
|
+
saveServer, getSavedServers, openEditServerDialog, showHubConnectOverlay,
|
|
16
|
+
} from './hub.js';
|
|
17
|
+
import { serverAddr } from './state.js';
|
|
18
|
+
import { processEvent, syncConfirmedModel, updateHeaderInfo, cacheTurnState, applyTurnState, clearConversationUi, restoreSessionCache, hasOptimisticBubble, rebuildRuntimeStateFromDom, scheduleSessionCacheSave, flushSessionCacheSave } from './renderer.js';
|
|
19
|
+
import { setWaiting } from './waiting.js';
|
|
20
|
+
import { showPermission, dismissPermissionById, clearPermissions } from './permissions.js';
|
|
21
|
+
import { handleUploadStatus, updateImagePreviewUi } from './image-upload.js';
|
|
22
|
+
import { renderSessionList } from './sessions.js';
|
|
23
|
+
import { renderDirBrowser, updateSettingsCwd } from './dir-picker.js';
|
|
24
|
+
import { presentNextPendingInteraction } from './interactions.js';
|
|
25
|
+
|
|
26
|
+
export function isAuthReadyMessage(msg) {
|
|
27
|
+
return !!msg && (
|
|
28
|
+
msg.type === 'auth_ok' ||
|
|
29
|
+
msg.type === 'status' ||
|
|
30
|
+
msg.type === 'transcript_ready' ||
|
|
31
|
+
msg.type === 'replay_done' ||
|
|
32
|
+
msg.type === 'turn_state'
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function finalizeAuthenticatedConnection() {
|
|
37
|
+
if (S.authenticated) return;
|
|
38
|
+
S.authenticated = true;
|
|
39
|
+
setStatus('connected');
|
|
40
|
+
setConnBanner(false);
|
|
41
|
+
if ($('app').classList.contains('hidden')) {
|
|
42
|
+
showApp();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
hideHubConnectOverlay();
|
|
46
|
+
renderHubCards();
|
|
47
|
+
saveServer(serverAddr);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isCloseEvent(event, code, reason) {
|
|
51
|
+
return !!event && event.code === code && (!reason || event.reason === reason);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clearForegroundProbe(reason = '') {
|
|
55
|
+
if (S.foregroundProbeTimer) {
|
|
56
|
+
clearTimeout(S.foregroundProbeTimer);
|
|
57
|
+
S.foregroundProbeTimer = null;
|
|
58
|
+
}
|
|
59
|
+
if (S.foregroundProbeId) {
|
|
60
|
+
debugLog('foreground_probe_clear', {
|
|
61
|
+
reason,
|
|
62
|
+
probeId: S.foregroundProbeId,
|
|
63
|
+
wsState: wsReadyStateName(S.ws),
|
|
64
|
+
waiting: S.waiting,
|
|
65
|
+
sessionId: S.sessionId || null,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
S.foregroundProbeId = '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function reconnectFromForeground(reason) {
|
|
72
|
+
debugLog('foreground_reconnect', {
|
|
73
|
+
reason,
|
|
74
|
+
wsState: wsReadyStateName(S.ws),
|
|
75
|
+
waiting: S.waiting,
|
|
76
|
+
sessionId: S.sessionId || null,
|
|
77
|
+
hidden: typeof document !== 'undefined' ? !!document.hidden : null,
|
|
78
|
+
});
|
|
79
|
+
clearForegroundProbe(reason);
|
|
80
|
+
if (S.reconnectTimer) {
|
|
81
|
+
clearTimeout(S.reconnectTimer);
|
|
82
|
+
S.reconnectTimer = null;
|
|
83
|
+
}
|
|
84
|
+
if (S.ws && S.ws.readyState !== WebSocket.CLOSED) {
|
|
85
|
+
try { S.ws.close(); } catch {}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!$('app').classList.contains('hidden')) connect();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function startForegroundProbe(trigger) {
|
|
92
|
+
if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return;
|
|
93
|
+
if (S.foregroundProbeId) return;
|
|
94
|
+
|
|
95
|
+
const probeId = `fg_${++S.foregroundProbeSeq}_${Date.now().toString(36)}`;
|
|
96
|
+
S.foregroundProbeId = probeId;
|
|
97
|
+
debugLog('foreground_probe_send', {
|
|
98
|
+
trigger,
|
|
99
|
+
probeId,
|
|
100
|
+
sessionId: S.sessionId || null,
|
|
101
|
+
lastSeq: S.lastSeq,
|
|
102
|
+
waiting: S.waiting,
|
|
103
|
+
wsState: wsReadyStateName(S.ws),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
S.foregroundProbeTimer = setTimeout(() => {
|
|
107
|
+
if (S.foregroundProbeId !== probeId) return;
|
|
108
|
+
debugLog('foreground_probe_timeout', {
|
|
109
|
+
trigger,
|
|
110
|
+
probeId,
|
|
111
|
+
sessionId: S.sessionId || null,
|
|
112
|
+
lastSeq: S.lastSeq,
|
|
113
|
+
waiting: S.waiting,
|
|
114
|
+
wsState: wsReadyStateName(S.ws),
|
|
115
|
+
});
|
|
116
|
+
reconnectFromForeground('foreground_probe_timeout');
|
|
117
|
+
}, FOREGROUND_PROBE_TIMEOUT_MS);
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
S.ws.send(JSON.stringify({
|
|
121
|
+
type: 'foreground_probe',
|
|
122
|
+
probeId,
|
|
123
|
+
sessionId: S.sessionId || null,
|
|
124
|
+
lastSeq: S.lastSeq,
|
|
125
|
+
}));
|
|
126
|
+
} catch {
|
|
127
|
+
reconnectFromForeground('foreground_probe_send_failed');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function recoverConnectionOnForeground(trigger) {
|
|
132
|
+
if (typeof document !== 'undefined' && document.hidden) return;
|
|
133
|
+
if ($('app').classList.contains('hidden')) return;
|
|
134
|
+
|
|
135
|
+
const now = Date.now();
|
|
136
|
+
if (now - S.lastForegroundRecoverAt < FOREGROUND_RECOVER_DEBOUNCE_MS) return;
|
|
137
|
+
S.lastForegroundRecoverAt = now;
|
|
138
|
+
|
|
139
|
+
debugLog('foreground_recover_check', {
|
|
140
|
+
trigger,
|
|
141
|
+
wsState: wsReadyStateName(S.ws),
|
|
142
|
+
waiting: S.waiting,
|
|
143
|
+
sessionId: S.sessionId || null,
|
|
144
|
+
lastSeq: S.lastSeq,
|
|
145
|
+
reconnectScheduled: !!S.reconnectTimer,
|
|
146
|
+
lastMessageAgoMs: S.lastMessageAt ? (now - S.lastMessageAt) : null,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (!S.ws || S.ws.readyState === WebSocket.CLOSED) {
|
|
150
|
+
if (S.reconnectTimer) {
|
|
151
|
+
clearTimeout(S.reconnectTimer);
|
|
152
|
+
S.reconnectTimer = null;
|
|
153
|
+
}
|
|
154
|
+
connect();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (S.ws.readyState === WebSocket.CONNECTING || S.ws.readyState === WebSocket.CLOSING) return;
|
|
159
|
+
startForegroundProbe(trigger);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function syncSessionState(sessionId, serverLastSeq) {
|
|
163
|
+
const syncToken = ++S.sessionSyncToken;
|
|
164
|
+
const prevSessionId = S.sessionId;
|
|
165
|
+
const nextSessionId = sessionId || '';
|
|
166
|
+
const sessionChanged = nextSessionId !== prevSessionId;
|
|
167
|
+
debugLog('sync_session_start', {
|
|
168
|
+
syncToken,
|
|
169
|
+
prevSessionId: prevSessionId || null,
|
|
170
|
+
nextSessionId: nextSessionId || null,
|
|
171
|
+
sessionChanged,
|
|
172
|
+
serverLastSeq,
|
|
173
|
+
waiting: S.waiting,
|
|
174
|
+
lastSeq: S.lastSeq,
|
|
175
|
+
wsState: wsReadyStateName(S.ws),
|
|
176
|
+
hidden: typeof document !== 'undefined' ? !!document.hidden : null,
|
|
177
|
+
online: typeof navigator !== 'undefined' && 'onLine' in navigator ? !!navigator.onLine : null,
|
|
178
|
+
});
|
|
179
|
+
S.replaying = true;
|
|
180
|
+
|
|
181
|
+
if (sessionChanged) {
|
|
182
|
+
S.sessionId = nextSessionId;
|
|
183
|
+
S.model = '';
|
|
184
|
+
const shouldKeepOptimisticUi = !prevSessionId && hasOptimisticBubble();
|
|
185
|
+
if (shouldKeepOptimisticUi) {
|
|
186
|
+
debugLog('sync_session_keep_optimistic', {
|
|
187
|
+
syncToken,
|
|
188
|
+
nextSessionId: nextSessionId || null,
|
|
189
|
+
});
|
|
190
|
+
rebuildRuntimeStateFromDom();
|
|
191
|
+
updateHeaderInfo();
|
|
192
|
+
scheduleSessionCacheSave();
|
|
193
|
+
} else {
|
|
194
|
+
debugLog('sync_session_clear_ui', {
|
|
195
|
+
syncToken,
|
|
196
|
+
nextSessionId: nextSessionId || null,
|
|
197
|
+
});
|
|
198
|
+
clearConversationUi();
|
|
199
|
+
}
|
|
200
|
+
if (nextSessionId && !shouldKeepOptimisticUi) {
|
|
201
|
+
const restored = await restoreSessionCache(nextSessionId);
|
|
202
|
+
if (!restored) updateHeaderInfo();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (syncToken !== S.sessionSyncToken) return;
|
|
207
|
+
|
|
208
|
+
if (!S.ws || S.ws.readyState !== WebSocket.OPEN) return;
|
|
209
|
+
if (S.resumeRequestedFor === nextSessionId) return;
|
|
210
|
+
|
|
211
|
+
S.resumeRequestedFor = nextSessionId;
|
|
212
|
+
debugLog('sync_session_resume_request', {
|
|
213
|
+
syncToken,
|
|
214
|
+
sessionId: nextSessionId || null,
|
|
215
|
+
lastSeq: nextSessionId ? S.lastSeq : 0,
|
|
216
|
+
serverLastSeq: Number.isInteger(serverLastSeq) ? serverLastSeq : null,
|
|
217
|
+
wsState: wsReadyStateName(S.ws),
|
|
218
|
+
hidden: typeof document !== 'undefined' ? !!document.hidden : null,
|
|
219
|
+
online: typeof navigator !== 'undefined' && 'onLine' in navigator ? !!navigator.onLine : null,
|
|
220
|
+
});
|
|
221
|
+
S.ws.send(JSON.stringify({
|
|
222
|
+
type: 'resume',
|
|
223
|
+
sessionId: nextSessionId || null,
|
|
224
|
+
lastSeq: nextSessionId ? S.lastSeq : 0,
|
|
225
|
+
serverLastSeq: Number.isInteger(serverLastSeq) ? serverLastSeq : null,
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function setStatus(s) {
|
|
230
|
+
$('status-dot').className = 'status-dot ' + s;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function setConnBanner(show, reconnecting) {
|
|
234
|
+
const el = $('conn-banner');
|
|
235
|
+
el.classList.toggle('visible', show);
|
|
236
|
+
el.classList.toggle('reconnecting', !!reconnecting);
|
|
237
|
+
$('conn-text').textContent = reconnecting ? 'Reconnecting...' : 'Disconnected';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function connect() {
|
|
241
|
+
let ws;
|
|
242
|
+
let connectErrorShown = false;
|
|
243
|
+
const isCurrentSocket = () => S.ws === ws;
|
|
244
|
+
const failConnect = (message) => {
|
|
245
|
+
if (connectErrorShown) return;
|
|
246
|
+
connectErrorShown = true;
|
|
247
|
+
hideHubConnectOverlay();
|
|
248
|
+
renderHubCards();
|
|
249
|
+
showToast(message);
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
ws = new WebSocket(serverWsUrl);
|
|
253
|
+
} catch (e) {
|
|
254
|
+
failConnect('Invalid server address');
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
S.ws = ws;
|
|
258
|
+
S.authenticated = false;
|
|
259
|
+
S.resumeRequestedFor = '';
|
|
260
|
+
S.replaying = true;
|
|
261
|
+
debugLog('ws_connect_start', {
|
|
262
|
+
serverWsUrl,
|
|
263
|
+
sessionId: S.sessionId || null,
|
|
264
|
+
hidden: typeof document !== 'undefined' ? !!document.hidden : null,
|
|
265
|
+
online: typeof navigator !== 'undefined' && 'onLine' in navigator ? !!navigator.onLine : null,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
const connectTimeout = setTimeout(() => {
|
|
269
|
+
if (!isCurrentSocket()) return;
|
|
270
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
271
|
+
ws.close();
|
|
272
|
+
failConnect('Connection timed out');
|
|
273
|
+
}
|
|
274
|
+
}, 8000);
|
|
275
|
+
|
|
276
|
+
ws.onopen = () => {
|
|
277
|
+
clearTimeout(connectTimeout);
|
|
278
|
+
if (!isCurrentSocket()) {
|
|
279
|
+
try { ws.close(); } catch {}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
clearForegroundProbe('ws_open');
|
|
283
|
+
S.lastMessageAt = Date.now();
|
|
284
|
+
S.intentionalDisconnect = false;
|
|
285
|
+
S.skipNextCloseHandling = false;
|
|
286
|
+
setStatus('starting');
|
|
287
|
+
ws.send(JSON.stringify({
|
|
288
|
+
type: 'hello',
|
|
289
|
+
clientInstanceId: CLIENT_INSTANCE_ID,
|
|
290
|
+
token: serverToken || '',
|
|
291
|
+
page: location.pathname || '/',
|
|
292
|
+
userAgent: navigator.userAgent || '',
|
|
293
|
+
}));
|
|
294
|
+
debugLog('ws_open', {
|
|
295
|
+
sessionId: S.sessionId || null,
|
|
296
|
+
waiting: S.waiting,
|
|
297
|
+
replaying: S.replaying,
|
|
298
|
+
wsState: wsReadyStateName(ws),
|
|
299
|
+
hidden: typeof document !== 'undefined' ? !!document.hidden : null,
|
|
300
|
+
online: typeof navigator !== 'undefined' && 'onLine' in navigator ? !!navigator.onLine : null,
|
|
301
|
+
});
|
|
302
|
+
flushPendingDebugLogs();
|
|
303
|
+
ws.send(JSON.stringify({ type: 'set_approval_mode', mode: approvalMode }));
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
ws.onmessage = async e => {
|
|
307
|
+
if (!isCurrentSocket()) return;
|
|
308
|
+
let m;
|
|
309
|
+
try { m = JSON.parse(e.data); } catch { return; }
|
|
310
|
+
S.lastMessageAt = Date.now();
|
|
311
|
+
if (m.type === 'auth_ok' || m.type === 'status' || m.type === 'transcript_ready' || m.type === 'replay_done' ||
|
|
312
|
+
m.type === 'turn_state' || m.type === 'pty_exit') {
|
|
313
|
+
debugLog('ws_message', {
|
|
314
|
+
type: m.type,
|
|
315
|
+
sessionId: 'sessionId' in m ? (m.sessionId ?? null) : null,
|
|
316
|
+
lastSeq: Number.isInteger(m.lastSeq) ? m.lastSeq : null,
|
|
317
|
+
waiting: S.waiting,
|
|
318
|
+
replaying: S.replaying,
|
|
319
|
+
wsState: wsReadyStateName(ws),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
if (!S.authenticated && isAuthReadyMessage(m)) {
|
|
324
|
+
finalizeAuthenticatedConnection();
|
|
325
|
+
}
|
|
326
|
+
if (!S.authenticated) return;
|
|
327
|
+
if (m.type === 'auth_ok') return;
|
|
328
|
+
if (m.type === 'pty_output') { /* ignored */ }
|
|
329
|
+
else if (m.type === 'log_event') processEvent(m.event, m.seq);
|
|
330
|
+
else if (m.type === 'image_upload_status') handleUploadStatus(m);
|
|
331
|
+
else if (m.type === 'foreground_probe_ack') {
|
|
332
|
+
if (!m.probeId || m.probeId !== S.foregroundProbeId) {
|
|
333
|
+
debugLog('foreground_probe_ack_ignored', {
|
|
334
|
+
probeId: m.probeId || '',
|
|
335
|
+
expectedProbeId: S.foregroundProbeId || '',
|
|
336
|
+
sessionId: 'sessionId' in m ? (m.sessionId ?? null) : null,
|
|
337
|
+
lastSeq: Number.isInteger(m.lastSeq) ? m.lastSeq : null,
|
|
338
|
+
wsState: wsReadyStateName(ws),
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
clearForegroundProbe('ack');
|
|
343
|
+
debugLog('foreground_probe_ack', {
|
|
344
|
+
probeId: m.probeId || '',
|
|
345
|
+
sessionId: 'sessionId' in m ? (m.sessionId ?? null) : null,
|
|
346
|
+
lastSeq: Number.isInteger(m.lastSeq) ? m.lastSeq : null,
|
|
347
|
+
wsState: wsReadyStateName(ws),
|
|
348
|
+
waiting: S.waiting,
|
|
349
|
+
});
|
|
350
|
+
if (m.cwd) { S.cwd = m.cwd; updateHeaderInfo(); }
|
|
351
|
+
if ('sessionId' in m) {
|
|
352
|
+
S.resumeRequestedFor = '';
|
|
353
|
+
await syncSessionState(m.sessionId, m.lastSeq);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
else if (m.type === 'transcript_ready') {
|
|
357
|
+
setStatus('connected');
|
|
358
|
+
await syncSessionState(m.sessionId, m.lastSeq);
|
|
359
|
+
}
|
|
360
|
+
else if (m.type === 'replay_done') {
|
|
361
|
+
if (m.sessionId !== undefined && m.sessionId !== null) S.sessionId = m.sessionId;
|
|
362
|
+
if (Number.isInteger(m.lastSeq) && m.lastSeq > S.lastSeq) S.lastSeq = m.lastSeq;
|
|
363
|
+
S.replaying = false;
|
|
364
|
+
if (S.pendingTurnState) applyTurnState(S.pendingTurnState, 'replay_done');
|
|
365
|
+
presentNextPendingInteraction();
|
|
366
|
+
scheduleSessionCacheSave();
|
|
367
|
+
}
|
|
368
|
+
else if (m.type === 'status') {
|
|
369
|
+
setStatus(m.status === 'running' ? 'connected' : 'starting');
|
|
370
|
+
if (m.cwd) { S.cwd = m.cwd; updateHeaderInfo(); }
|
|
371
|
+
if ('sessionId' in m) await syncSessionState(m.sessionId, m.lastSeq);
|
|
372
|
+
}
|
|
373
|
+
else if (m.type === 'turn_state') {
|
|
374
|
+
if (S.replaying) cacheTurnState(m);
|
|
375
|
+
else applyTurnState(m, 'turn_state');
|
|
376
|
+
}
|
|
377
|
+
else if (m.type === 'pty_exit') { setStatus('disconnected'); if (S.waiting) setWaiting(false, 'pty_exit'); }
|
|
378
|
+
else if (m.type === 'permission_request') showPermission(m);
|
|
379
|
+
else if (m.type === 'permission_resolved') dismissPermissionById(m.id);
|
|
380
|
+
else if (m.type === 'clear_permissions') clearPermissions();
|
|
381
|
+
else if (m.type === 'sessions') {
|
|
382
|
+
renderSessionList(m.sessions || []);
|
|
383
|
+
updateSettingsCwd();
|
|
384
|
+
}
|
|
385
|
+
else if (m.type === 'dir_list') {
|
|
386
|
+
renderDirBrowser(m);
|
|
387
|
+
}
|
|
388
|
+
else if (m.type === 'cwd_changed') {
|
|
389
|
+
S.cwd = m.cwd;
|
|
390
|
+
updateHeaderInfo();
|
|
391
|
+
if ('sessionId' in m) await syncSessionState(m.sessionId, m.lastSeq);
|
|
392
|
+
updateSettingsCwd();
|
|
393
|
+
}
|
|
394
|
+
else if (m.type === 'cwd_change_error') {
|
|
395
|
+
showToast(m.error || 'Failed to change folder');
|
|
396
|
+
if (m.cwd) {
|
|
397
|
+
$('settings-cwd-input').value = m.cwd;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.error('[ws.onmessage]', err);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
ws.onclose = (event) => {
|
|
406
|
+
clearTimeout(connectTimeout);
|
|
407
|
+
if (!isCurrentSocket()) return;
|
|
408
|
+
clearForegroundProbe('ws_close');
|
|
409
|
+
S.authenticated = false;
|
|
410
|
+
hideHubConnectOverlay();
|
|
411
|
+
renderHubCards();
|
|
412
|
+
setStatus('disconnected');
|
|
413
|
+
S.resumeRequestedFor = '';
|
|
414
|
+
S.pendingTurnState = null;
|
|
415
|
+
debugLog('ws_close', {
|
|
416
|
+
sessionId: S.sessionId || null,
|
|
417
|
+
waiting: S.waiting,
|
|
418
|
+
replaying: S.replaying,
|
|
419
|
+
intentionalDisconnect: S.intentionalDisconnect,
|
|
420
|
+
code: event && typeof event.code === 'number' ? event.code : null,
|
|
421
|
+
reason: event && typeof event.reason === 'string' ? event.reason : '',
|
|
422
|
+
wasClean: event && typeof event.wasClean === 'boolean' ? event.wasClean : null,
|
|
423
|
+
wsState: wsReadyStateName(ws),
|
|
424
|
+
hidden: typeof document !== 'undefined' ? !!document.hidden : null,
|
|
425
|
+
online: typeof navigator !== 'undefined' && 'onLine' in navigator ? !!navigator.onLine : null,
|
|
426
|
+
});
|
|
427
|
+
for (const [uploadId, waiter] of S.uploadWaiters) {
|
|
428
|
+
waiter.reject(new Error('Connection lost'));
|
|
429
|
+
S.uploadWaiters.delete(uploadId);
|
|
430
|
+
}
|
|
431
|
+
const currentImage = pendingImage;
|
|
432
|
+
if (currentImage && currentImage.status !== 'submitted') {
|
|
433
|
+
currentImage.status = 'failed';
|
|
434
|
+
updateImagePreviewUi();
|
|
435
|
+
if (S.waiting) setWaiting(false, 'ws_close_pending_image');
|
|
436
|
+
}
|
|
437
|
+
if (S.skipNextCloseHandling) {
|
|
438
|
+
S.skipNextCloseHandling = false;
|
|
439
|
+
S.intentionalDisconnect = false;
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (S.intentionalDisconnect) return;
|
|
443
|
+
|
|
444
|
+
if (isCloseEvent(event, WS_CLOSE_AUTH_FAILED, WS_CLOSE_REASON_AUTH_FAILED)) {
|
|
445
|
+
showToast('Authentication failed — check your Token');
|
|
446
|
+
hideHubConnectOverlay();
|
|
447
|
+
renderHubCards();
|
|
448
|
+
const servers = getSavedServers();
|
|
449
|
+
const current = servers.find(x => x.wsUrl === serverWsUrl);
|
|
450
|
+
if (current) openEditServerDialog(current.id);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (isCloseEvent(event, WS_CLOSE_AUTH_TIMEOUT, WS_CLOSE_REASON_AUTH_TIMEOUT) && $('app').classList.contains('hidden')) {
|
|
455
|
+
failConnect('Handshake timed out - check client/server compatibility and try again');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!$('app').classList.contains('hidden')) {
|
|
460
|
+
setConnBanner(true, true);
|
|
461
|
+
S.reconnectTimer = setTimeout(connect, 2000);
|
|
462
|
+
} else {
|
|
463
|
+
failConnect('Connection failed - check the address and server');
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
ws.onerror = () => {
|
|
468
|
+
if (!isCurrentSocket()) return;
|
|
469
|
+
debugLog('ws_error', {
|
|
470
|
+
sessionId: S.sessionId || null,
|
|
471
|
+
waiting: S.waiting,
|
|
472
|
+
replaying: S.replaying,
|
|
473
|
+
wsState: wsReadyStateName(ws),
|
|
474
|
+
hidden: typeof document !== 'undefined' ? !!document.hidden : null,
|
|
475
|
+
online: typeof navigator !== 'undefined' && 'onLine' in navigator ? !!navigator.onLine : null,
|
|
476
|
+
});
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function tryConnect() {
|
|
481
|
+
if (!serverWsUrl) return;
|
|
482
|
+
connect();
|
|
483
|
+
}
|