claude-remote 0.5.2 → 0.6.1
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/bin/claude-remote.js +1 -1
- package/hooks/bridge-session-start.js +32 -32
- package/lib/cli.js +1 -0
- package/lib/http-server.js +60 -22
- package/lib/interactive-questions.js +183 -0
- package/lib/logger.js +172 -138
- package/lib/state.js +8 -6
- package/lib/ws-server.js +132 -96
- package/package.json +3 -2
- package/server.js +23 -16
- package/web/index.html +383 -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 +423 -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 +101 -0
- package/web/modules/state.js +61 -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 +486 -0
- package/web/styles.css +1959 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Session Drawer
|
|
3
|
+
// ============================================================
|
|
4
|
+
import { $, esc, trunc } from './utils.js';
|
|
5
|
+
import { S } from './state.js';
|
|
6
|
+
import { showConfirm } from './confirm.js';
|
|
7
|
+
|
|
8
|
+
export let sessionListCache = [];
|
|
9
|
+
|
|
10
|
+
function getSessionTitle(session) {
|
|
11
|
+
return session.customTitle || session.summary || session.firstPrompt || 'Untitled';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getSessionModifiedMs(session) {
|
|
15
|
+
const value = session.lastModified ?? session.modified ?? null;
|
|
16
|
+
if (!value) return 0;
|
|
17
|
+
if (typeof value === 'number') return value;
|
|
18
|
+
const numeric = Number(value);
|
|
19
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
20
|
+
const parsed = Date.parse(value);
|
|
21
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getSessionCwd(session) {
|
|
25
|
+
return session.projectPath || session.cwd || '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatRelativeTime(session) {
|
|
29
|
+
const ts = getSessionModifiedMs(session);
|
|
30
|
+
if (!ts) return '';
|
|
31
|
+
const diff = Math.max(0, Date.now() - ts);
|
|
32
|
+
const mins = Math.floor(diff / 60000);
|
|
33
|
+
if (mins < 1) return 'just now';
|
|
34
|
+
if (mins < 60) return `${mins}m ago`;
|
|
35
|
+
const hours = Math.floor(mins / 60);
|
|
36
|
+
if (hours < 24) return `${hours}h ago`;
|
|
37
|
+
const days = Math.floor(hours / 24);
|
|
38
|
+
if (days < 7) return `${days}d ago`;
|
|
39
|
+
const d = new Date(ts);
|
|
40
|
+
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function openSessionDrawer() {
|
|
44
|
+
$('session-overlay').classList.add('visible');
|
|
45
|
+
requestSessionList();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function closeSessionDrawer() {
|
|
49
|
+
$('session-overlay').classList.remove('visible');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requestSessionList() {
|
|
53
|
+
if (S.ws && S.ws.readyState === WebSocket.OPEN) {
|
|
54
|
+
$('session-list').innerHTML = '<div class="drawer-loading">Loading...</div>';
|
|
55
|
+
S.ws.send(JSON.stringify({ type: 'list_sessions' }));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function renderSessionList(sessions) {
|
|
60
|
+
sessionListCache = sessions;
|
|
61
|
+
const $list = $('session-list');
|
|
62
|
+
$list.innerHTML = '';
|
|
63
|
+
if (!sessions.length) {
|
|
64
|
+
$list.innerHTML = '<div class="drawer-loading">No sessions found</div>';
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const s of sessions) {
|
|
68
|
+
const isActive = s.sessionId === S.sessionId;
|
|
69
|
+
const el = document.createElement('div');
|
|
70
|
+
el.className = 'session-item' + (isActive ? ' active' : '');
|
|
71
|
+
el.innerHTML =
|
|
72
|
+
`<div class="session-summary">${esc(getSessionTitle(s))}</div>` +
|
|
73
|
+
`<div class="session-meta">` +
|
|
74
|
+
`<span>${formatRelativeTime(s)}</span>` +
|
|
75
|
+
(s.gitBranch ? `<span class="session-branch">${esc(s.gitBranch)}</span>` : '') +
|
|
76
|
+
`</div>`;
|
|
77
|
+
if (!isActive) {
|
|
78
|
+
el.addEventListener('click', () => confirmSwitchSession(s));
|
|
79
|
+
}
|
|
80
|
+
$list.appendChild(el);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function confirmSwitchSession(session) {
|
|
85
|
+
const label = trunc(getSessionTitle(session), 40);
|
|
86
|
+
const ok = await showConfirm(`切换到会话 "${label}"?\n当前对话进度不会丢失。`);
|
|
87
|
+
if (!ok) return;
|
|
88
|
+
closeSessionDrawer();
|
|
89
|
+
if (S.ws && S.ws.readyState === WebSocket.OPEN) {
|
|
90
|
+
S.ws.send(JSON.stringify({ type: 'switch_session', sessionId: session.sessionId }));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function initSessions() {
|
|
95
|
+
$('btn-sessions').addEventListener('click', openSessionDrawer);
|
|
96
|
+
$('session-drawer-close').addEventListener('click', closeSessionDrawer);
|
|
97
|
+
$('session-overlay').addEventListener('click', e => {
|
|
98
|
+
if (e.target === $('session-overlay')) closeSessionDrawer();
|
|
99
|
+
});
|
|
100
|
+
$('btn-new-session').addEventListener('click', async () => {
|
|
101
|
+
const ok = await showConfirm('新建会话?当前对话进度不会丢失。');
|
|
102
|
+
if (!ok) return;
|
|
103
|
+
closeSessionDrawer();
|
|
104
|
+
if (S.ws && S.ws.readyState === WebSocket.OPEN) {
|
|
105
|
+
S.ws.send(JSON.stringify({ type: 'chat', text: '/clear' }));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Settings
|
|
3
|
+
// ============================================================
|
|
4
|
+
import { $ } from './utils.js';
|
|
5
|
+
import { S, approvalMode, setApprovalModeValue, themeMode, setThemeModeValue } from './state.js';
|
|
6
|
+
import { showConfirm } from './confirm.js';
|
|
7
|
+
import { updateSettingsCwd } from './dir-picker.js';
|
|
8
|
+
|
|
9
|
+
function updateApprovalActive() {
|
|
10
|
+
document.querySelectorAll('#approval-options .settings-opt').forEach(el => {
|
|
11
|
+
el.classList.toggle('active', el.dataset.mode === approvalMode);
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function updateThemeActive() {
|
|
16
|
+
document.querySelectorAll('#theme-options .settings-opt').forEach(el => {
|
|
17
|
+
el.classList.toggle('active', el.dataset.themeMode === themeMode);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setApprovalMode(mode) {
|
|
22
|
+
setApprovalModeValue(mode);
|
|
23
|
+
localStorage.setItem('approvalMode', mode);
|
|
24
|
+
updateApprovalActive();
|
|
25
|
+
if (S.ws && S.ws.readyState === WebSocket.OPEN) {
|
|
26
|
+
S.ws.send(JSON.stringify({ type: 'set_approval_mode', mode }));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function applyTheme(mode) {
|
|
31
|
+
setThemeModeValue(mode);
|
|
32
|
+
localStorage.setItem('theme', mode);
|
|
33
|
+
if (mode === 'light' || mode === 'dark') {
|
|
34
|
+
document.documentElement.setAttribute('data-theme', mode);
|
|
35
|
+
} else {
|
|
36
|
+
document.documentElement.removeAttribute('data-theme');
|
|
37
|
+
}
|
|
38
|
+
updateThemeActive();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function initSettingsValues() {
|
|
42
|
+
const approvalRadio = document.querySelector(`input[name="approval-mode"][value="${approvalMode}"]`);
|
|
43
|
+
if (approvalRadio) approvalRadio.checked = true;
|
|
44
|
+
updateApprovalActive();
|
|
45
|
+
|
|
46
|
+
const themeRadio = document.querySelector(`input[name="theme-mode"][value="${themeMode}"]`);
|
|
47
|
+
if (themeRadio) themeRadio.checked = true;
|
|
48
|
+
updateThemeActive();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function openSettings() {
|
|
52
|
+
initSettingsValues();
|
|
53
|
+
updateSettingsCwd();
|
|
54
|
+
$('settings-overlay').classList.add('visible');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function closeSettings() {
|
|
58
|
+
$('settings-overlay').classList.remove('visible');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export { closeSettings };
|
|
62
|
+
|
|
63
|
+
export function initSettings() {
|
|
64
|
+
$('btn-settings').addEventListener('click', openSettings);
|
|
65
|
+
$('settings-close').addEventListener('click', closeSettings);
|
|
66
|
+
$('settings-overlay').addEventListener('click', e => {
|
|
67
|
+
if (e.target === $('settings-overlay')) closeSettings();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
document.querySelectorAll('input[name="approval-mode"]').forEach(radio => {
|
|
71
|
+
radio.addEventListener('change', async (e) => {
|
|
72
|
+
const mode = e.target.value;
|
|
73
|
+
if (mode === 'all') {
|
|
74
|
+
const ok = await showConfirm(
|
|
75
|
+
'全部自动审批将允许所有命令(包括 Bash、系统命令)无需确认直接执行,这可能存在风险。确定要开启吗?'
|
|
76
|
+
);
|
|
77
|
+
if (!ok) {
|
|
78
|
+
const prev = document.querySelector(`input[name="approval-mode"][value="${approvalMode}"]`);
|
|
79
|
+
if (prev) prev.checked = true;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
} else if (mode === 'partial') {
|
|
83
|
+
const ok = await showConfirm(
|
|
84
|
+
'部分自动审批将自动放行 Read、Write、Edit、Glob、Grep 命令,无需手动确认。确定要开启吗?'
|
|
85
|
+
);
|
|
86
|
+
if (!ok) {
|
|
87
|
+
const prev = document.querySelector(`input[name="approval-mode"][value="${approvalMode}"]`);
|
|
88
|
+
if (prev) prev.checked = true;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
setApprovalMode(mode);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
document.querySelectorAll('input[name="theme-mode"]').forEach(radio => {
|
|
97
|
+
radio.addEventListener('change', (e) => {
|
|
98
|
+
applyTheme(e.target.value);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// App State
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
export const S = {
|
|
6
|
+
ws: null,
|
|
7
|
+
authenticated: false,
|
|
8
|
+
sessionId: '',
|
|
9
|
+
lastSeq: 0,
|
|
10
|
+
lastMessageAt: 0,
|
|
11
|
+
seenUuids: new Set(),
|
|
12
|
+
messageMap: new Map(),
|
|
13
|
+
toolMap: new Map(),
|
|
14
|
+
currentGroup: null,
|
|
15
|
+
currentGroupCount: 0,
|
|
16
|
+
isAtBottom: true,
|
|
17
|
+
waiting: false,
|
|
18
|
+
workingEl: null,
|
|
19
|
+
cwd: '',
|
|
20
|
+
model: '',
|
|
21
|
+
pendingPerms: [],
|
|
22
|
+
waitStartedAt: 0,
|
|
23
|
+
replaying: true,
|
|
24
|
+
turnStateVersion: 0,
|
|
25
|
+
pendingTurnState: null,
|
|
26
|
+
pendingPlanContent: '',
|
|
27
|
+
reconnectTimer: null,
|
|
28
|
+
intentionalDisconnect: false,
|
|
29
|
+
skipNextCloseHandling: false,
|
|
30
|
+
resumeRequestedFor: '',
|
|
31
|
+
cacheSaveTimer: null,
|
|
32
|
+
sessionSyncToken: 0,
|
|
33
|
+
uploadWaiters: new Map(),
|
|
34
|
+
foregroundProbeSeq: 0,
|
|
35
|
+
foregroundProbeId: '',
|
|
36
|
+
foregroundProbeTimer: null,
|
|
37
|
+
lastForegroundRecoverAt: 0,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const dirBrowserState = {
|
|
41
|
+
cwd: '',
|
|
42
|
+
parent: null,
|
|
43
|
+
roots: [],
|
|
44
|
+
entries: [],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export let serverAddr = '';
|
|
48
|
+
export let serverWsUrl = '';
|
|
49
|
+
export let serverCacheAddr = '';
|
|
50
|
+
export let serverToken = '';
|
|
51
|
+
export let pendingImage = null;
|
|
52
|
+
export let approvalMode = localStorage.getItem('approvalMode') || 'default';
|
|
53
|
+
export let themeMode = localStorage.getItem('theme') || 'system';
|
|
54
|
+
|
|
55
|
+
export function setServerAddr(v) { serverAddr = v; }
|
|
56
|
+
export function setServerWsUrl(v) { serverWsUrl = v; }
|
|
57
|
+
export function setServerCacheAddr(v) { serverCacheAddr = v; }
|
|
58
|
+
export function setServerToken(v) { serverToken = v; }
|
|
59
|
+
export function setPendingImage(v) { pendingImage = v; }
|
|
60
|
+
export function setApprovalModeValue(v) { approvalMode = v; }
|
|
61
|
+
export function setThemeModeValue(v) { themeMode = v; }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Toast Messages
|
|
3
|
+
// ============================================================
|
|
4
|
+
import { $ } from './utils.js';
|
|
5
|
+
|
|
6
|
+
function localizeToastText(text) {
|
|
7
|
+
const raw = String(text || '').trim();
|
|
8
|
+
if (!raw) return '';
|
|
9
|
+
if (/[\u4e00-\u9fff]/.test(raw)) return raw;
|
|
10
|
+
|
|
11
|
+
const directMap = new Map([
|
|
12
|
+
['Server not found', '未找到服务器记录'],
|
|
13
|
+
['Invalid server address', '服务器地址无效'],
|
|
14
|
+
['Clearing conversation...', '正在清空当前对话…'],
|
|
15
|
+
['Fetching token costs...', '正在获取 Token 费用信息…'],
|
|
16
|
+
['Loading help...', '正在加载帮助信息…'],
|
|
17
|
+
['Please select an image file', '请选择图片文件'],
|
|
18
|
+
['Image too large (max 4MB)', '图片过大\n最大支持 4MB'],
|
|
19
|
+
['Connection unavailable', '连接不可用\n请先确认已连接到服务器'],
|
|
20
|
+
['Image upload failed', '图片上传失败'],
|
|
21
|
+
['Image upload failed. Re-select the image and try again.', '图片上传失败\n请重新选择图片后再试'],
|
|
22
|
+
['Image submit failed', '图片发送失败'],
|
|
23
|
+
['Failed to change folder', '切换文件夹失败'],
|
|
24
|
+
['Connection lost', '连接已断开'],
|
|
25
|
+
['Handshake timed out - check client/server compatibility and try again', '连接握手超时\n请检查客户端与服务端版本是否兼容'],
|
|
26
|
+
['Linux image paste requires xclip or wl-copy on the server. Install one and try again.', '服务端缺少图片剪贴板工具\n请安装 xclip 或 wl-copy 后重试'],
|
|
27
|
+
['Upload not ready', '图片尚未上传完成\n请稍后再试'],
|
|
28
|
+
['Upload session not found', '上传会话不存在\n请重新选择图片'],
|
|
29
|
+
['Upload owner mismatch', '上传会话无效\n请重新选择图片'],
|
|
30
|
+
['Missing uploadId', '上传请求无效\n请重新选择图片'],
|
|
31
|
+
['Missing chunk payload', '图片分片数据缺失\n请重新上传'],
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
if (directMap.has(raw)) return directMap.get(raw);
|
|
35
|
+
if (raw.startsWith('Linux image paste requires a graphical session.')) {
|
|
36
|
+
return 'Linux 服务端缺少图形会话环境变量\n请在 pm2/systemd 中设置 DISPLAY 或 WAYLAND_DISPLAY 后重试';
|
|
37
|
+
}
|
|
38
|
+
if (raw.startsWith('Now using ')) {
|
|
39
|
+
return `已切换模型\n${raw.slice('Now using '.length)}`;
|
|
40
|
+
}
|
|
41
|
+
if (raw.startsWith('Switching to ')) {
|
|
42
|
+
return `正在切换模型\n${raw.slice('Switching to '.length).replace(/\.\.\.$/, '')}`;
|
|
43
|
+
}
|
|
44
|
+
if (raw.startsWith('Authentication failed')) {
|
|
45
|
+
return '鉴权失败\n请检查 Token 是否正确';
|
|
46
|
+
}
|
|
47
|
+
if (raw.startsWith('Unexpected chunk index')) {
|
|
48
|
+
return '图片分片顺序异常\n请重新上传';
|
|
49
|
+
}
|
|
50
|
+
if (raw.startsWith('Upload incomplete')) {
|
|
51
|
+
return '图片上传不完整\n请重新上传';
|
|
52
|
+
}
|
|
53
|
+
if (raw.startsWith('Image upload failed:')) {
|
|
54
|
+
return `图片上传失败\n${raw.slice('Image upload failed:'.length).trim()}`;
|
|
55
|
+
}
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function showToast(text) {
|
|
60
|
+
const message = localizeToastText(text);
|
|
61
|
+
if (!message) return;
|
|
62
|
+
const el = document.createElement('div');
|
|
63
|
+
el.className = 'toast';
|
|
64
|
+
el.setAttribute('role', 'alert');
|
|
65
|
+
el.textContent = message;
|
|
66
|
+
$('toast-container').appendChild(el);
|
|
67
|
+
setTimeout(() => el.remove(), 3600);
|
|
68
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Todo Panel
|
|
3
|
+
// ============================================================
|
|
4
|
+
import { TODO_AUTO_CLEAR_DELAY_MS } from './constants.js';
|
|
5
|
+
import { $, esc } from './utils.js';
|
|
6
|
+
import { S } from './state.js';
|
|
7
|
+
import { scrollEnd } from './waiting.js';
|
|
8
|
+
import { scheduleSessionCacheSave } from './renderer.js';
|
|
9
|
+
|
|
10
|
+
export const todoState = {
|
|
11
|
+
tasks: new Map(),
|
|
12
|
+
pendingCreates: new Map(),
|
|
13
|
+
panelOpen: false,
|
|
14
|
+
autoOpenedForBatch: false,
|
|
15
|
+
clearTimer: null,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function cancelTodoAutoClear() {
|
|
19
|
+
if (!todoState.clearTimer) return;
|
|
20
|
+
clearTimeout(todoState.clearTimer);
|
|
21
|
+
todoState.clearTimer = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function clearTodoBatch() {
|
|
25
|
+
cancelTodoAutoClear();
|
|
26
|
+
todoState.tasks.clear();
|
|
27
|
+
todoState.pendingCreates.clear();
|
|
28
|
+
todoState.panelOpen = false;
|
|
29
|
+
todoState.autoOpenedForBatch = false;
|
|
30
|
+
$('todo-panel').classList.remove('has-tasks', 'open');
|
|
31
|
+
$('todo-list').innerHTML = '';
|
|
32
|
+
$('todo-summary').textContent = '';
|
|
33
|
+
$('todo-progress-bar').style.width = '0%';
|
|
34
|
+
$('todo-progress-bar').classList.remove('all-done');
|
|
35
|
+
$('todo-badge').textContent = '0';
|
|
36
|
+
$('todo-badge').classList.remove('done');
|
|
37
|
+
scheduleSessionCacheSave();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function syncTodoPanelLifecycle(tasks) {
|
|
41
|
+
const hasTasks = tasks.length > 0;
|
|
42
|
+
const hasPendingCreates = todoState.pendingCreates.size > 0;
|
|
43
|
+
const hasOpenTasks = tasks.some(([, task]) => (task.status || 'pending') !== 'completed');
|
|
44
|
+
|
|
45
|
+
if (!hasTasks) {
|
|
46
|
+
cancelTodoAutoClear();
|
|
47
|
+
todoState.autoOpenedForBatch = false;
|
|
48
|
+
todoState.panelOpen = false;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!todoState.autoOpenedForBatch) {
|
|
53
|
+
todoState.autoOpenedForBatch = true;
|
|
54
|
+
todoState.panelOpen = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (hasOpenTasks || hasPendingCreates) {
|
|
58
|
+
cancelTodoAutoClear();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!todoState.clearTimer) {
|
|
63
|
+
todoState.clearTimer = setTimeout(() => {
|
|
64
|
+
todoState.clearTimer = null;
|
|
65
|
+
const latestTasks = Array.from(todoState.tasks.values());
|
|
66
|
+
const latestHasPendingCreates = todoState.pendingCreates.size > 0;
|
|
67
|
+
const latestHasOpenTasks = latestTasks.some(task => (task.status || 'pending') !== 'completed');
|
|
68
|
+
if (!latestHasPendingCreates && latestTasks.length > 0 && !latestHasOpenTasks) {
|
|
69
|
+
clearTodoBatch();
|
|
70
|
+
}
|
|
71
|
+
}, TODO_AUTO_CLEAR_DELAY_MS);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function handleTodoToolUse(b) {
|
|
76
|
+
const { name, id, input } = b;
|
|
77
|
+
if (name === 'TaskCreate') {
|
|
78
|
+
const hasOpenTasks = Array.from(todoState.tasks.values()).some(t => t.status !== 'completed');
|
|
79
|
+
if (todoState.tasks.size > 0 && !hasOpenTasks) {
|
|
80
|
+
clearTodoBatch();
|
|
81
|
+
}
|
|
82
|
+
todoState.pendingCreates.set(id, input);
|
|
83
|
+
} else if (name === 'TaskUpdate' && input.taskId) {
|
|
84
|
+
const task = todoState.tasks.get(input.taskId);
|
|
85
|
+
if (task) {
|
|
86
|
+
if (input.status) task.status = input.status;
|
|
87
|
+
if (input.subject) task.subject = input.subject;
|
|
88
|
+
if (input.description) task.description = input.description;
|
|
89
|
+
if (input.activeForm) task.activeForm = input.activeForm;
|
|
90
|
+
renderTodoPanel();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function handleTodoToolResult(b, evt) {
|
|
96
|
+
const { tool_use_id, content } = b;
|
|
97
|
+
const text = typeof content === 'string' ? content :
|
|
98
|
+
Array.isArray(content) ? content.map(c => c.text || '').join('') : '';
|
|
99
|
+
const toolUseResult = (evt && typeof evt.toolUseResult === 'object' && evt.toolUseResult) ? evt.toolUseResult : null;
|
|
100
|
+
|
|
101
|
+
const createInput = todoState.pendingCreates.get(tool_use_id);
|
|
102
|
+
if (createInput) {
|
|
103
|
+
todoState.pendingCreates.delete(tool_use_id);
|
|
104
|
+
const metaTaskId = toolUseResult?.task?.id;
|
|
105
|
+
const m = text.match(/Task #(\d+) created/i);
|
|
106
|
+
const taskId = metaTaskId ? String(metaTaskId) : (m ? m[1] : '');
|
|
107
|
+
if (taskId) {
|
|
108
|
+
const metaSubject = toolUseResult?.task?.subject;
|
|
109
|
+
todoState.tasks.set(taskId, {
|
|
110
|
+
subject: metaSubject || createInput.subject || '',
|
|
111
|
+
description: createInput.description || '',
|
|
112
|
+
status: 'pending',
|
|
113
|
+
activeForm: createInput.activeForm || '',
|
|
114
|
+
blockedBy: [],
|
|
115
|
+
blocks: [],
|
|
116
|
+
});
|
|
117
|
+
renderTodoPanel();
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (toolUseResult?.taskId) {
|
|
123
|
+
const taskId = String(toolUseResult.taskId);
|
|
124
|
+
let task = todoState.tasks.get(taskId);
|
|
125
|
+
if (!task) {
|
|
126
|
+
task = {
|
|
127
|
+
subject: `Task #${taskId}`,
|
|
128
|
+
description: '',
|
|
129
|
+
status: 'pending',
|
|
130
|
+
activeForm: '',
|
|
131
|
+
blockedBy: [],
|
|
132
|
+
blocks: [],
|
|
133
|
+
};
|
|
134
|
+
todoState.tasks.set(taskId, task);
|
|
135
|
+
}
|
|
136
|
+
if (toolUseResult.statusChange?.to) {
|
|
137
|
+
task.status = toolUseResult.statusChange.to;
|
|
138
|
+
}
|
|
139
|
+
renderTodoPanel();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (text.includes('Task #') && (text.includes('[pending]') || text.includes('[in_progress]') || text.includes('[completed]'))) {
|
|
144
|
+
const lines = text.split('\n');
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
const tm = line.match(/#(\d+)\.\s*\[(\w+)]\s*(.*)/);
|
|
147
|
+
if (tm) {
|
|
148
|
+
const [, taskId, status, subject] = tm;
|
|
149
|
+
const existing = todoState.tasks.get(taskId);
|
|
150
|
+
if (existing) {
|
|
151
|
+
existing.status = status;
|
|
152
|
+
if (subject.trim()) existing.subject = subject.trim();
|
|
153
|
+
} else {
|
|
154
|
+
todoState.tasks.set(taskId, {
|
|
155
|
+
subject: subject.trim(),
|
|
156
|
+
description: '',
|
|
157
|
+
status,
|
|
158
|
+
activeForm: '',
|
|
159
|
+
blockedBy: [],
|
|
160
|
+
blocks: [],
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
renderTodoPanel();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const um = text.match(/Updated task #(\d+)/i);
|
|
170
|
+
if (um) {
|
|
171
|
+
renderTodoPanel();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function isTodoTool(name) {
|
|
176
|
+
return name === 'TaskCreate' || name === 'TaskUpdate' || name === 'TaskList' || name === 'TaskGet';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function renderTodoPanel() {
|
|
180
|
+
const panel = $('todo-panel');
|
|
181
|
+
const list = $('todo-list');
|
|
182
|
+
const tasks = Array.from(todoState.tasks.entries()).sort(([aId], [bId]) => {
|
|
183
|
+
const aNum = Number.parseInt(aId, 10);
|
|
184
|
+
const bNum = Number.parseInt(bId, 10);
|
|
185
|
+
const aOk = Number.isFinite(aNum);
|
|
186
|
+
const bOk = Number.isFinite(bNum);
|
|
187
|
+
if (aOk && bOk) return aNum - bNum;
|
|
188
|
+
if (aOk) return -1;
|
|
189
|
+
if (bOk) return 1;
|
|
190
|
+
return String(aId).localeCompare(String(bId), undefined, { numeric: true });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (tasks.length === 0) {
|
|
194
|
+
panel.classList.remove('has-tasks', 'open');
|
|
195
|
+
list.innerHTML = '';
|
|
196
|
+
$('todo-summary').textContent = '';
|
|
197
|
+
$('todo-progress-bar').style.width = '0%';
|
|
198
|
+
$('todo-progress-bar').classList.remove('all-done');
|
|
199
|
+
$('todo-badge').textContent = '0';
|
|
200
|
+
$('todo-badge').classList.remove('done');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
syncTodoPanelLifecycle(tasks);
|
|
205
|
+
panel.classList.add('has-tasks');
|
|
206
|
+
panel.classList.toggle('open', todoState.panelOpen);
|
|
207
|
+
|
|
208
|
+
const total = tasks.length;
|
|
209
|
+
const completed = tasks.filter(([, t]) => t.status === 'completed').length;
|
|
210
|
+
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
|
211
|
+
|
|
212
|
+
const badge = $('todo-badge');
|
|
213
|
+
const remaining = total - completed;
|
|
214
|
+
badge.textContent = remaining > 0 ? remaining : '\u2713';
|
|
215
|
+
badge.classList.toggle('done', remaining === 0);
|
|
216
|
+
|
|
217
|
+
const bar = $('todo-progress-bar');
|
|
218
|
+
bar.style.width = pct + '%';
|
|
219
|
+
bar.classList.toggle('all-done', pct === 100);
|
|
220
|
+
|
|
221
|
+
$('todo-summary').textContent = `${completed}/${total}`;
|
|
222
|
+
|
|
223
|
+
const STATUS_ICON = {
|
|
224
|
+
pending: '\u25CB',
|
|
225
|
+
in_progress: '\u25D4',
|
|
226
|
+
completed: '\u2713',
|
|
227
|
+
};
|
|
228
|
+
const STATUS_LABEL = {
|
|
229
|
+
pending: 'Pending',
|
|
230
|
+
in_progress: 'Running',
|
|
231
|
+
completed: 'Done',
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
list.innerHTML = tasks.map(([id, t]) => {
|
|
235
|
+
const status = t.status || 'pending';
|
|
236
|
+
const showActive = status === 'in_progress' && t.activeForm;
|
|
237
|
+
return `<div class="todo-item ${status}">
|
|
238
|
+
<div class="todo-icon">${STATUS_ICON[status] || '\u25CB'}</div>
|
|
239
|
+
<div class="todo-body">
|
|
240
|
+
<div class="todo-subject">${esc(t.subject || 'Task #' + id)}</div>
|
|
241
|
+
${showActive ? `<div class="todo-active-form">${esc(t.activeForm)}</div>` : ''}
|
|
242
|
+
</div>
|
|
243
|
+
<span class="todo-status-tag">${STATUS_LABEL[status] || status}</span>
|
|
244
|
+
</div>`;
|
|
245
|
+
}).join('');
|
|
246
|
+
|
|
247
|
+
scrollEnd();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function toggleTodoPanel() {
|
|
251
|
+
const panel = $('todo-panel');
|
|
252
|
+
todoState.panelOpen = !todoState.panelOpen;
|
|
253
|
+
panel.classList.toggle('open', todoState.panelOpen);
|
|
254
|
+
scheduleSessionCacheSave();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Make toggleTodoPanel available globally for inline onclick in HTML
|
|
258
|
+
window.toggleTodoPanel = toggleTodoPanel;
|
|
259
|
+
|
|
260
|
+
export function resetTodoState() {
|
|
261
|
+
cancelTodoAutoClear();
|
|
262
|
+
todoState.tasks.clear();
|
|
263
|
+
todoState.pendingCreates.clear();
|
|
264
|
+
todoState.panelOpen = false;
|
|
265
|
+
todoState.autoOpenedForBatch = false;
|
|
266
|
+
$('todo-panel').classList.remove('has-tasks', 'open');
|
|
267
|
+
$('todo-list').innerHTML = '';
|
|
268
|
+
$('todo-summary').textContent = '';
|
|
269
|
+
$('todo-progress-bar').style.width = '0%';
|
|
270
|
+
$('todo-progress-bar').classList.remove('all-done');
|
|
271
|
+
$('todo-badge').textContent = '0';
|
|
272
|
+
$('todo-badge').classList.remove('done');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function getTodoSnapshot() {
|
|
276
|
+
return {
|
|
277
|
+
tasks: Array.from(todoState.tasks.entries()),
|
|
278
|
+
panelOpen: todoState.panelOpen,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function restoreTodoSnapshot(snapshot) {
|
|
283
|
+
resetTodoState();
|
|
284
|
+
if (!snapshot || !Array.isArray(snapshot.tasks) || snapshot.tasks.length === 0) return;
|
|
285
|
+
snapshot.tasks.forEach(([taskId, task]) => {
|
|
286
|
+
todoState.tasks.set(String(taskId), task);
|
|
287
|
+
});
|
|
288
|
+
todoState.panelOpen = !!snapshot.panelOpen;
|
|
289
|
+
todoState.autoOpenedForBatch = todoState.tasks.size > 0;
|
|
290
|
+
renderTodoPanel();
|
|
291
|
+
$('todo-panel').classList.toggle('open', todoState.panelOpen && todoState.tasks.size > 0);
|
|
292
|
+
}
|