@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +5 -5
  2. package/dist/bin/self-relay-server.mjs +5 -2
  3. package/dist/shared/app-server-events.cjs +45 -19
  4. package/dist/shared/p2p-data-channel.cjs +50 -0
  5. package/dist/src/app-server-tasks.mjs +28 -25
  6. package/dist/src/lan-socket-server.mjs +8 -2
  7. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  8. package/dist/src/platform/windows/background-process.mjs +7 -2
  9. package/dist/src/platform/windows/launch-worker.ps1 +13 -6
  10. package/dist/src/platform/windows/start-hidden-console.ps1 +11 -6
  11. package/dist/src/self-relay/admin-state.mjs +61 -0
  12. package/dist/src/self-relay/client.mjs +2 -0
  13. package/dist/src/self-relay/connector.mjs +23 -8
  14. package/dist/src/self-relay/demo.mjs +2 -2
  15. package/dist/src/self-relay/lifecycle.mjs +1 -1
  16. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  17. package/dist/src/self-relay/server.mjs +137 -11
  18. package/dist/src/server.mjs +337 -255
  19. package/dist/src/shared-app-server.mjs +180 -24
  20. package/dist/src/shared-catalog.mjs +4 -14
  21. package/dist/src/thread-catalog.mjs +12 -7
  22. package/dist/web/activity-view.js +283 -0
  23. package/dist/web/capabilities.js +2 -2
  24. package/dist/web/chat-transport.js +15 -9
  25. package/dist/web/chat.css +139 -93
  26. package/dist/web/chat.js +1451 -2770
  27. package/dist/web/community-view.js +20 -0
  28. package/dist/web/community.css +77 -0
  29. package/dist/web/community.html +37 -0
  30. package/dist/web/composer-controller.js +101 -0
  31. package/dist/web/conversation-controller.js +99 -0
  32. package/dist/web/disclosure-state-controller.js +95 -0
  33. package/dist/web/draft-controller.js +103 -0
  34. package/dist/web/harmony-platform.js +3 -2
  35. package/dist/web/history-cache.js +112 -18
  36. package/dist/web/history-controller.js +167 -0
  37. package/dist/web/index.html +141 -38
  38. package/dist/web/link-action-controller.js +212 -0
  39. package/dist/web/message-send-controller.js +177 -0
  40. package/dist/web/message-view.js +98 -0
  41. package/dist/web/p2p-data-channel.js +50 -0
  42. package/dist/web/p2p-probe.js +67 -27
  43. package/dist/web/page-resume.js +28 -0
  44. package/dist/web/pending-message-store.js +108 -0
  45. package/dist/web/queue-controller.js +82 -0
  46. package/dist/web/resources.json +1 -1
  47. package/dist/web/self-relay-session.js +28 -18
  48. package/dist/web/station-connection-controller.js +75 -0
  49. package/dist/web/task-list-view.js +296 -0
  50. package/dist/web/thread-attention-controller.js +124 -0
  51. package/dist/web/thread-context-controller.js +30 -0
  52. package/dist/web/thread-list-controller.js +61 -0
  53. package/dist/web/thread-list-sync.js +86 -0
  54. package/dist/web/thread-title-controller.js +57 -0
  55. package/dist/web/timeline-formatters.js +249 -0
  56. package/dist/web/timeline-reducer.js +81 -0
  57. package/dist/web/timeline-renderer.js +161 -0
  58. package/dist/web/timeline-scroll-controller.js +50 -0
  59. package/dist/web/usage-controller.js +258 -0
  60. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  61. package/package.json +1 -1
  62. package/tools/postinstall.mjs +66 -2
@@ -0,0 +1,75 @@
1
+ (function (root) {
2
+ function createStationConnectionController(options) {
3
+ let recoveryTask = null;
4
+ let epoch = 0;
5
+ let paused = false;
6
+
7
+ function isCurrent(value) {
8
+ return !paused && value === epoch;
9
+ }
10
+
11
+ function suspend() {
12
+ paused = true;
13
+ epoch++;
14
+ recoveryTask = null;
15
+ options.onSuspend?.();
16
+ }
17
+
18
+ function activate() {
19
+ paused = false;
20
+ }
21
+
22
+ function recover() {
23
+ if (paused) return Promise.resolve();
24
+ if (recoveryTask) return recoveryTask;
25
+ const recoveryEpoch = ++epoch;
26
+ recoveryTask = Promise.resolve().then(async () => {
27
+ let attempts = 0;
28
+ options.onStarted?.();
29
+ while (isCurrent(recoveryEpoch)) {
30
+ options.onAttempt?.();
31
+ try {
32
+ await options.connect();
33
+ if (!isCurrent(recoveryEpoch)) return;
34
+ try { await options.restore(); }
35
+ catch (error) {
36
+ if (!isCurrent(recoveryEpoch)) return;
37
+ if (options.isConnected?.()) {
38
+ options.onRestoreFailure?.(error);
39
+ return;
40
+ }
41
+ throw error;
42
+ }
43
+ if (!isCurrent(recoveryEpoch)) return;
44
+ options.onRecovered?.(attempts + 1);
45
+ return;
46
+ } catch (error) {
47
+ if (!isCurrent(recoveryEpoch)) return;
48
+ if (options.isTerminal(error)) {
49
+ options.onTerminal?.(error);
50
+ return;
51
+ }
52
+ options.onRetry?.(error, attempts + 1);
53
+ const delay = Math.min(1000 * 2 ** Math.min(attempts++, 5), 30000) * (0.8 + Math.random() * 0.2);
54
+ await new Promise(resolve => setTimeout(resolve, delay));
55
+ }
56
+ }
57
+ }).finally(() => {
58
+ if (recoveryEpoch === epoch) recoveryTask = null;
59
+ });
60
+ return recoveryTask;
61
+ }
62
+
63
+ return Object.freeze({
64
+ recover,
65
+ suspend,
66
+ activate,
67
+ isPaused: () => paused,
68
+ isRecovering: () => Boolean(recoveryTask),
69
+ generation: () => epoch,
70
+ isCurrent,
71
+ });
72
+ }
73
+
74
+ root.createStationConnectionController = createStationConnectionController;
75
+ })(globalThis);
@@ -0,0 +1,296 @@
1
+ (function (root, factory) {
2
+ const create = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = create;
4
+ else root.createTaskListView = create;
5
+ })(globalThis, function () {
6
+ function createTaskListView(options) {
7
+ const {
8
+ document, el, state, archiveBusy, archivedThreadsEntryVisible,
9
+ archiveSupported, isThreadRunning, isThreadWaitingForApproval,
10
+ resolvedThreadTitle, statusText, onSelectThread, onArchiveThread,
11
+ onOpenNewThread, onFeedback,
12
+ } = options;
13
+ function statusIcon(className, label, text = '') {
14
+ const icon = document.createElement('span');
15
+ icon.className = className;
16
+ icon.textContent = text;
17
+ icon.title = label;
18
+ icon.setAttribute('role', 'status');
19
+ icon.setAttribute('aria-label', label);
20
+ return icon;
21
+ }
22
+
23
+ function projectFolderIcon(closed) {
24
+ const namespace = 'http://www.w3.org/2000/svg';
25
+ const folder = document.createElement('span');
26
+ folder.className = 'project-folder';
27
+ folder.setAttribute('aria-hidden', 'true');
28
+ const svg = document.createElementNS(namespace, 'svg');
29
+ svg.setAttribute('viewBox', '0 0 18 18');
30
+ const path = document.createElementNS(namespace, 'path');
31
+ path.setAttribute('d', closed
32
+ ? 'M2.25 6.25V5.1c0-.9.73-1.63 1.63-1.63h3.06l1.42 1.45h5.76c.9 0 1.63.73 1.63 1.63v6.35c0 .9-.73 1.63-1.63 1.63H3.88c-.9 0-1.63-.73-1.63-1.63V6.25Z'
33
+ : 'M2.25 12.9V5.1c0-.9.73-1.63 1.63-1.63h3.06l1.42 1.45h5.76c.9 0 1.63.73 1.63 1.63v1.12 M2.4 12.84l1.63-3.66c.25-.57.81-.93 1.43-.93h9.31c.71 0 1.19.72.92 1.37l-1.55 3.66c-.32.76-1.06 1.25-1.88 1.25H3.49c-.86 0-1.44-.9-1.09-1.69Z');
34
+ svg.append(path);
35
+ folder.append(svg);
36
+ return folder;
37
+ }
38
+
39
+ function newThreadIcon() {
40
+ const namespace = 'http://www.w3.org/2000/svg';
41
+ const svg = document.createElementNS(namespace, 'svg');
42
+ svg.setAttribute('viewBox', '0 0 18 18');
43
+ const frame = document.createElementNS(namespace, 'path');
44
+ frame.setAttribute('d', 'M7.3 3H4.5A1.5 1.5 0 0 0 3 4.5v9A1.5 1.5 0 0 0 4.5 15h9a1.5 1.5 0 0 0 1.5-1.5v-2.8');
45
+ const pencil = document.createElementNS(namespace, 'path');
46
+ pencil.setAttribute('d', 'm7.1 10.9.35-2.05 5.7-5.7a1.2 1.2 0 0 1 1.7 1.7l-5.7 5.7-2.05.35Z M12.25 4.05l1.7 1.7');
47
+ svg.append(frame, pencil);
48
+ return svg;
49
+ }
50
+
51
+ function projectActions(project, projectId) {
52
+ const actions = document.createElement('div');
53
+ actions.className = 'project-actions';
54
+ const more = document.createElement('button');
55
+ more.className = 'project-action more';
56
+ more.type = 'button';
57
+ more.textContent = '···';
58
+ more.title = '更多操作(暂未实现)';
59
+ more.setAttribute('aria-label', more.title);
60
+ more.setAttribute('aria-disabled', 'true');
61
+ const create = document.createElement('button');
62
+ create.className = 'project-action create-thread';
63
+ create.type = 'button';
64
+ create.title = '新建对话';
65
+ create.setAttribute('aria-label', create.title);
66
+ create.append(newThreadIcon());
67
+ create.onclick = () => onOpenNewThread(project, projectId);
68
+ actions.append(more, create);
69
+ return actions;
70
+ }
71
+
72
+ function bindTaskSwipe(wrapper, button, action, onCommit) {
73
+ let startX = 0, startY = 0, direction = '', suppressClick = false;
74
+ let pointerId = null, distance = 0, threshold = 81;
75
+ const label = action.textContent;
76
+ const reset = () => {
77
+ button.style.removeProperty('transition');
78
+ button.style.removeProperty('transform');
79
+ wrapper.classList.remove('swiping', 'swipe-ready');
80
+ wrapper.style.removeProperty('--swipe-offset');
81
+ wrapper.style.removeProperty('--swipe-progress');
82
+ action.textContent = label;
83
+ const captured = pointerId;
84
+ pointerId = null;
85
+ if (captured !== null && button.hasPointerCapture(captured)) button.releasePointerCapture(captured);
86
+ };
87
+ button.addEventListener('pointerdown', event => {
88
+ if (event.button !== 0 || !event.isPrimary) return;
89
+ pointerId = event.pointerId;
90
+ button.setPointerCapture(pointerId);
91
+ startX = event.clientX; startY = event.clientY;
92
+ threshold = Math.min(102, Math.max(81, wrapper.clientWidth * .31875));
93
+ wrapper.style.setProperty('--swipe-threshold', `${threshold}px`);
94
+ distance = 0; direction = ''; suppressClick = false;
95
+ button.style.transition = 'none';
96
+ });
97
+ button.addEventListener('pointermove', event => {
98
+ if (event.pointerId !== pointerId) return;
99
+ const dx = event.clientX - startX, dy = event.clientY - startY;
100
+ if (!direction && Math.max(Math.abs(dx), Math.abs(dy)) < 8) return;
101
+ if (!direction) direction = Math.abs(dx) > Math.abs(dy) * 1.15 ? 'horizontal' : 'vertical';
102
+ if (direction !== 'horizontal') return;
103
+ suppressClick = true;
104
+ distance = Math.max(0, -dx);
105
+ const offset = Math.min(distance, threshold) + Math.max(0, distance - threshold) * .25;
106
+ button.style.transform = `translateX(${-offset}px)`;
107
+ wrapper.classList.add('swiping');
108
+ wrapper.classList.toggle('swipe-ready', distance >= threshold);
109
+ wrapper.style.setProperty('--swipe-progress', String(Math.min(1, distance / threshold)));
110
+ action.textContent = distance >= threshold ? `松手${label}` : `左滑${label}`;
111
+ wrapper.style.setProperty('--swipe-offset', `${offset}px`);
112
+ });
113
+ button.addEventListener('pointerup', event => {
114
+ if (event.pointerId !== pointerId) return;
115
+ const commit = direction === 'horizontal' && distance >= threshold;
116
+ reset();
117
+ if (commit) onCommit();
118
+ });
119
+ const cancel = event => {
120
+ if (event.pointerId !== pointerId) return;
121
+ reset(); direction = '';
122
+ };
123
+ button.addEventListener('pointercancel', cancel);
124
+ button.addEventListener('lostpointercapture', event => { if (event.target === button) cancel(event); });
125
+ button.addEventListener('click', event => {
126
+ if (!suppressClick) return;
127
+ event.preventDefault(); event.stopImmediatePropagation(); suppressClick = false;
128
+ }, true);
129
+ }
130
+
131
+ function render() {
132
+ const container = el('tasks');
133
+ const scrollOwner = document.body.classList.contains('embedded-terminal') ? container : container.closest('aside');
134
+ const previousScrollTop = scrollOwner?.scrollTop || 0;
135
+ container.replaceChildren();
136
+ const projectsById = new Map(state.projects.map(project => [project.projectId, project]));
137
+ const groups = new Map();
138
+ for (const project of state.projects) groups.set(project.projectId, []);
139
+ const visibleThreads = state.showingArchived ? state.archivedThreads : state.threads;
140
+ el('archivedThreads').hidden = !archivedThreadsEntryVisible || !archiveSupported();
141
+ el('archivedThreadsLabel').textContent = state.showingArchived ? '返回对话' : '已归档对话';
142
+ document.querySelector('.sidebar-title').textContent = state.showingArchived ? '已归档 · 左滑恢复' : '项目';
143
+ for (const thread of visibleThreads) {
144
+ const groupId = thread.projectId || '__unassigned__';
145
+ if (!groups.has(groupId)) groups.set(groupId, []);
146
+ groups.get(groupId).push(thread);
147
+ }
148
+
149
+ for (const [projectId, threads] of groups) {
150
+ if (!threads.length && projectId === '__unassigned__') continue;
151
+ const project = projectsById.get(projectId);
152
+ const group = document.createElement('div');
153
+ const recent = projectId === '__unassigned__';
154
+ const collapsed = !recent && state.collapsedProjects.has(projectId);
155
+ const hasApprovalTask = threads.some(isThreadWaitingForApproval);
156
+ const hasRunningTask = threads.some(isThreadRunning);
157
+ const hasUnreadTask = threads.some(thread => state.unreadThreads.has(thread.id));
158
+ group.className = `project${recent ? ' recent' : ''}${collapsed ? ' collapsed' : ''}`;
159
+ if (recent) {
160
+ const heading = document.createElement('div');
161
+ heading.className = 'recent-heading';
162
+ heading.textContent = '最近';
163
+ group.append(heading);
164
+ } else {
165
+ const header = document.createElement('div');
166
+ header.className = 'project-header';
167
+ const toggle = document.createElement('button');
168
+ toggle.className = 'project-toggle';
169
+ toggle.type = 'button';
170
+ toggle.setAttribute('aria-expanded', String(!collapsed));
171
+ const name = document.createElement('div');
172
+ name.className = 'project-name';
173
+ name.textContent = project?.label || (projectId === '__unassigned__' ? '未归属项目' : projectId);
174
+ toggle.title = project?.path || name.textContent;
175
+ toggle.append(projectFolderIcon(collapsed), name);
176
+ toggle.onclick = () => {
177
+ if (state.collapsedProjects.has(projectId)) state.collapsedProjects.delete(projectId);
178
+ else state.collapsedProjects.add(projectId);
179
+ render();
180
+ };
181
+ header.append(toggle);
182
+ if (collapsed && hasApprovalTask) header.append(statusIcon('approval-status', '项目中有任务等待批准', '等待批准'));
183
+ else if (collapsed && hasRunningTask) header.append(statusIcon('running-status', '项目中有任务正在进行'));
184
+ else if (collapsed && hasUnreadTask) header.append(statusIcon('unread-status', '项目中有未读更新'));
185
+ if (!state.showingArchived) header.append(projectActions(project, projectId));
186
+ group.append(header);
187
+ }
188
+
189
+ const taskList = document.createElement('div');
190
+ taskList.className = 'project-tasks';
191
+ for (const thread of threads) {
192
+ const archivePending = archiveBusy.has(thread.id);
193
+ const swipe = document.createElement('div');
194
+ swipe.className = 'task-swipe';
195
+ swipe.dataset.threadId = thread.id;
196
+ const action = document.createElement('div');
197
+ action.className = `task-swipe-action${state.showingArchived ? ' restore' : ''}`;
198
+ action.textContent = state.showingArchived ? '恢复' : '归档';
199
+ const running = !state.showingArchived && isThreadRunning(thread);
200
+ action.title = running ? '请先停止当前任务' : action.textContent;
201
+ action.setAttribute('aria-hidden', 'true');
202
+ const button = document.createElement('button');
203
+ button.className = `task${thread.id === state.selectedId ? ' active' : ''}`;
204
+ button.disabled = archivePending;
205
+ const title = document.createElement('div');
206
+ title.className = 'task-title';
207
+ const displayTitle = resolvedThreadTitle(thread);
208
+ title.textContent = displayTitle;
209
+ button.title = [displayTitle, thread.transport === 'app-server' ? 'app-server 独立任务' : '', statusText(thread.status), thread.cwd].filter(Boolean).join(' · ');
210
+ button.append(title);
211
+ if (thread.transport === 'app-server') {
212
+ const badge = document.createElement('span');
213
+ badge.className = 'transport-badge';
214
+ badge.textContent = 'AS';
215
+ badge.title = '独立 app-server 任务';
216
+ button.append(badge);
217
+ }
218
+ if (archivePending) button.append(statusIcon('archive-status', state.showingArchived ? '恢复中' : '归档中', state.showingArchived ? '恢复中…' : '归档中…'));
219
+ else if (isThreadWaitingForApproval(thread)) button.append(statusIcon('approval-status', '等待批准', '等待批准'));
220
+ else if (isThreadRunning(thread)) button.append(statusIcon('running-status', '任务正在进行'));
221
+ else if (state.unreadThreads.has(thread.id)) button.append(statusIcon('unread-status', '有未读更新'));
222
+ button.onclick = () => state.showingArchived ? onFeedback('左滑可以恢复这个对话') : onSelectThread(thread);
223
+ const supported = archiveSupported(thread);
224
+ action.hidden = !supported || archivePending;
225
+ if (supported && !running && !archivePending) {
226
+ bindTaskSwipe(swipe, button, action, () => onArchiveThread(thread, state.showingArchived));
227
+ }
228
+ swipe.append(action, button);
229
+ taskList.append(swipe);
230
+ }
231
+ group.append(taskList);
232
+ container.append(group);
233
+ }
234
+ if (!visibleThreads.length && state.showingArchived) {
235
+ const empty = document.createElement('div');
236
+ empty.className = 'recent-heading';
237
+ empty.textContent = '暂无已归档对话';
238
+ container.append(empty);
239
+ }
240
+ if (scrollOwner) scrollOwner.scrollTop = previousScrollTop;
241
+ }
242
+
243
+ function syncSelection(thread) {
244
+ const expandedProject = state.collapsedProjects.delete(thread.projectId || '__unassigned__');
245
+ if (expandedProject) { render(); return; }
246
+ let selectedFound = false;
247
+ for (const swipe of el('tasks').querySelectorAll('.task-swipe')) {
248
+ const button = swipe.querySelector('.task');
249
+ const selected = swipe.dataset.threadId === thread.id;
250
+ button?.classList.toggle('active', selected);
251
+ if (selected) {
252
+ selectedFound = true;
253
+ button.querySelector('.unread-status')?.remove();
254
+ }
255
+ }
256
+ if (!selectedFound) render();
257
+ }
258
+
259
+ function setArchivePending(threadId, pending) {
260
+ const wrapper = [...el('tasks').querySelectorAll('.task-swipe')]
261
+ .find(item => item.dataset.threadId === threadId);
262
+ if (!wrapper) return;
263
+ const button = wrapper.querySelector('.task');
264
+ const action = wrapper.querySelector('.task-swipe-action');
265
+ button.disabled = true;
266
+ action.hidden = true;
267
+ button.querySelector('.archive-status')?.remove();
268
+ button.append(statusIcon('archive-status', pending, `${pending}…`));
269
+ }
270
+
271
+ function clearArchivePending(threadId) {
272
+ const wrapper = [...el('tasks').querySelectorAll('.task-swipe')]
273
+ .find(item => item.dataset.threadId === threadId);
274
+ if (!wrapper) return;
275
+ const button = wrapper.querySelector('.task');
276
+ button.disabled = false;
277
+ button.querySelector('.archive-status')?.remove();
278
+ const action = wrapper.querySelector('.task-swipe-action');
279
+ if (action) action.hidden = false;
280
+ }
281
+
282
+ function removeThread(threadId) {
283
+ const wrapper = [...el('tasks').querySelectorAll('.task-swipe')]
284
+ .find(item => item.dataset.threadId === threadId);
285
+ if (!wrapper) return;
286
+ const tasks = wrapper.parentElement;
287
+ const group = tasks?.parentElement;
288
+ wrapper.remove();
289
+ if (tasks && !tasks.children.length && group?.classList.contains('recent')) group.remove();
290
+ }
291
+
292
+ return { clearArchivePending, removeThread, render, setArchivePending, syncSelection };
293
+ }
294
+
295
+ return createTaskListView;
296
+ });
@@ -0,0 +1,124 @@
1
+ (function (root, factory) {
2
+ const create = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = create;
4
+ else root.createThreadAttentionController = create;
5
+ })(globalThis, function () {
6
+ function createThreadAttentionController(options) {
7
+ const { state, platform, localStorage, scope, isThreadRunning, isSelectedVisible, report } = options;
8
+ const storageKey = 'codexRemoteThreadAttentionV2';
9
+ const nativeStorage = platform.has('readThreadAttention') && platform.has('saveThreadAttention');
10
+
11
+ function metric(action, thread, previous, source, reason = '') {
12
+ const safe = value => String(value ?? '').replace(/[^A-Za-z0-9._/-]/g, '_').slice(0, 48);
13
+ report(`stage=attention;action=${safe(action)};source=${safe(source)};reason=${safe(reason)};id=${safe(thread?.id)};prevStatus=${safe(previous?.status)};status=${safe(thread?.status)};prevAt=${recency(previous)};at=${recency(thread)}`);
14
+ }
15
+
16
+ function recency(thread) {
17
+ return Number(thread?.recencyAt ?? thread?.updatedAt) || 0;
18
+ }
19
+
20
+ function restore() {
21
+ try {
22
+ const serialized = nativeStorage
23
+ ? platform.readThreadAttention(scope)
24
+ : localStorage.getItem(storageKey);
25
+ const saved = JSON.parse(serialized || '{}');
26
+ state.unreadThreads = new Set(Array.isArray(saved.unread) ? saved.unread.filter(Boolean) : []);
27
+ state.threadSnapshots = new Map(Object.entries(saved.snapshots || {}));
28
+ report(`stage=attention;action=restore;source=${nativeStorage ? 'native' : 'local'};unread=${state.unreadThreads.size};snapshots=${state.threadSnapshots.size}`);
29
+ } catch {
30
+ state.unreadThreads = new Set();
31
+ state.threadSnapshots = new Map();
32
+ report(`stage=attention;action=restore;source=${nativeStorage ? 'native' : 'local'};reason=invalid;unread=0;snapshots=0`);
33
+ }
34
+ }
35
+
36
+ function persist() {
37
+ try {
38
+ const snapshots = [...state.threadSnapshots.entries()].slice(-100);
39
+ const serialized = JSON.stringify({
40
+ unread: [...state.unreadThreads],
41
+ snapshots: Object.fromEntries(snapshots),
42
+ });
43
+ if (nativeStorage) platform.saveThreadAttention(scope, serialized);
44
+ else localStorage.setItem(storageKey, serialized);
45
+ } catch {}
46
+ }
47
+
48
+ function reconcile(threads, source = 'list') {
49
+ let changed = false;
50
+ for (const thread of threads) {
51
+ const previous = state.threadSnapshots.get(thread.id);
52
+ const currentRecency = recency(thread);
53
+ const previousRecency = recency(previous);
54
+ const changedWhileIdle = previous && !isThreadRunning(thread)
55
+ && (isThreadRunning(previous) || currentRecency > previousRecency);
56
+ if (thread.id === state.selectedId && isSelectedVisible()) {
57
+ if (state.unreadThreads.delete(thread.id)) {
58
+ metric('read', thread, previous, source, 'selected');
59
+ changed = true;
60
+ }
61
+ } else if (changedWhileIdle && !state.unreadThreads.has(thread.id)) {
62
+ state.unreadThreads.add(thread.id);
63
+ const statusCompleted = isThreadRunning(previous);
64
+ const timeAdvanced = currentRecency > previousRecency;
65
+ metric('unread', thread, previous, source,
66
+ statusCompleted && timeAdvanced ? 'status-time' : statusCompleted ? 'status' : 'time');
67
+ changed = true;
68
+ }
69
+ if (previous && currentRecency < previousRecency) metric('baseline-backward', thread, previous, source, 'list');
70
+ const snapshot = { status: thread.status || '', recencyAt: currentRecency,
71
+ lastReadTurnId: previous?.lastReadTurnId || '' };
72
+ if (!previous || previous.status !== snapshot.status || recency(previous) !== snapshot.recencyAt) {
73
+ state.threadSnapshots.delete(thread.id);
74
+ state.threadSnapshots.set(thread.id, snapshot);
75
+ changed = true;
76
+ }
77
+ }
78
+ if (changed) persist();
79
+ }
80
+
81
+ function markRead(thread, source = 'timeline', lastReadTurnId = '') {
82
+ const previous = state.threadSnapshots.get(thread.id);
83
+ const snapshot = {
84
+ status: thread.status || previous?.status || '',
85
+ recencyAt: Math.max(recency(previous), recency(thread)),
86
+ lastReadTurnId: lastReadTurnId || previous?.lastReadTurnId || '',
87
+ };
88
+ const hadUnread = state.unreadThreads.delete(thread.id);
89
+ const baselineAdvanced = Boolean(previous) && snapshot.recencyAt > recency(previous);
90
+ if (hadUnread || baselineAdvanced) {
91
+ metric(hadUnread ? 'read' : 'baseline-read', { ...thread, ...snapshot }, previous, source,
92
+ hadUnread ? 'opened' : 'time');
93
+ }
94
+ const changed = hadUnread
95
+ || !previous || previous.status !== snapshot.status || recency(previous) !== snapshot.recencyAt
96
+ || previous.lastReadTurnId !== snapshot.lastReadTurnId;
97
+ state.threadSnapshots.delete(thread.id);
98
+ state.threadSnapshots.set(thread.id, snapshot);
99
+ if (changed) persist();
100
+ }
101
+
102
+ function recordSelectedStatus(thread, source = 'stream-status') {
103
+ const previous = state.threadSnapshots.get(thread.id);
104
+ const snapshot = { status: thread.status || '', recencyAt: recency(thread),
105
+ lastReadTurnId: previous?.lastReadTurnId || '' };
106
+ if (previous && snapshot.recencyAt < recency(previous)) {
107
+ metric('baseline-backward', { ...thread, ...snapshot }, previous, source, 'selected');
108
+ }
109
+ state.threadSnapshots.set(thread.id, snapshot);
110
+ persist();
111
+ }
112
+
113
+ function remove(threadId) {
114
+ const removedUnread = state.unreadThreads.delete(threadId);
115
+ const removedSnapshot = state.threadSnapshots.delete(threadId);
116
+ if (removedUnread || removedSnapshot) persist();
117
+ }
118
+
119
+ restore();
120
+ return { metric, reconcile, markRead, recordSelectedStatus, remove };
121
+ }
122
+
123
+ return createThreadAttentionController;
124
+ });
@@ -0,0 +1,30 @@
1
+ (function (root) {
2
+ function createThreadContextController(options) {
3
+ const interval = Math.max(1000, Number(options.interval) || 10000);
4
+ let timer = null;
5
+
6
+ async function update(threadId) {
7
+ try {
8
+ const usage = await options.load(threadId);
9
+ if (options.isCurrent(threadId)) options.onUsage(usage);
10
+ } catch (error) {
11
+ if (options.isCurrent(threadId)) options.onUnavailable(error);
12
+ }
13
+ }
14
+
15
+ function stop() {
16
+ if (timer) clearInterval(timer);
17
+ timer = null;
18
+ }
19
+
20
+ function start(threadId) {
21
+ stop();
22
+ void update(threadId);
23
+ timer = setInterval(() => void update(threadId), interval);
24
+ }
25
+
26
+ return Object.freeze({ start, stop, update });
27
+ }
28
+
29
+ root.createThreadContextController = createThreadContextController;
30
+ })(globalThis);
@@ -0,0 +1,61 @@
1
+ (function (root, factory) {
2
+ const create = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = create;
4
+ else root.createThreadListController = create;
5
+ })(globalThis, function () {
6
+ function createThreadListController(options) {
7
+ const {
8
+ state, approvalOverrides, resolveTitle, attention,
9
+ isWaitingForApproval, setApprovalFlag, onSelected, onRender,
10
+ } = options;
11
+
12
+ function listed(threadData) {
13
+ return [...(threadData.pinnedThreads || []), ...(threadData.threads || [])]
14
+ .filter((thread, index, array) => array.findIndex(item => item.id === thread.id) === index);
15
+ }
16
+
17
+ function signature(threads = state.threads) {
18
+ const projects = state.projects.map(project => [
19
+ project.projectId || '',
20
+ project.label || '',
21
+ project.path || '',
22
+ ].join('\u0001')).join('\u0002');
23
+ const threadList = threads.map(thread => [
24
+ thread.id,
25
+ thread.projectId || '',
26
+ thread.title || '',
27
+ thread.status || '',
28
+ Array.isArray(thread.activeFlags) ? thread.activeFlags.join(',') : '',
29
+ state.unreadThreads.has(thread.id) ? 'unread' : '',
30
+ ].join('\u0001')).join('\u0002');
31
+ return `${projects}\u0003${threadList}`;
32
+ }
33
+
34
+ function apply(threadData, forceRender = false, source = 'list') {
35
+ const before = signature();
36
+ if (Array.isArray(threadData.projects)) state.projects = threadData.projects;
37
+ const threads = listed(threadData).map(thread => ({ ...thread, title: resolveTitle(thread) }));
38
+ for (const thread of threads) {
39
+ if (!approvalOverrides.has(thread.id)) continue;
40
+ const pending = approvalOverrides.get(thread.id);
41
+ const serverPending = isWaitingForApproval(thread);
42
+ setApprovalFlag(thread, pending);
43
+ if (!pending && !serverPending) approvalOverrides.delete(thread.id);
44
+ }
45
+ attention.reconcile(threads, source);
46
+ state.threads = threads;
47
+ state.threadListComplete = true;
48
+ const selected = threads.find(thread => thread.id === state.selectedId);
49
+ if (selected) {
50
+ state.selectedThread = selected;
51
+ onSelected(selected);
52
+ }
53
+ if (forceRender || before !== signature()) onRender();
54
+ return threads;
55
+ }
56
+
57
+ return { listed, apply };
58
+ }
59
+
60
+ return createThreadListController;
61
+ });
@@ -0,0 +1,86 @@
1
+ (function (root, factory) {
2
+ const create = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = create;
4
+ else root.createThreadListSync = create;
5
+ })(globalThis, function () {
6
+ function createThreadListSync(options) {
7
+ const {
8
+ api, isBlocked, isVisible, apply, count,
9
+ setStatus, setRefreshDisabled, report,
10
+ } = options;
11
+ let pollingTimer = null;
12
+ let wakeTimer = null;
13
+ let wakeForced = false;
14
+ let activeRequests = 0;
15
+ let activeLoads = 0;
16
+ let generation = 0;
17
+
18
+ async function request(reason, forceRender, required) {
19
+ if (!forceRender && (isBlocked() || activeRequests > 0)) return false;
20
+ const requestGeneration = ++generation;
21
+ activeRequests += 1;
22
+ if (forceRender) {
23
+ activeLoads += 1;
24
+ setRefreshDisabled(true);
25
+ }
26
+ const started = performance.now();
27
+ try {
28
+ const threadData = await api('/api/threads?limit=200');
29
+ if (requestGeneration !== generation) return false;
30
+ const renderStarted = performance.now();
31
+ apply(threadData, forceRender, reason);
32
+ report(`stage=list;reason=${reason};totalMs=${Math.round(performance.now() - started)};renderMs=${Math.round(performance.now() - renderStarted)};threads=${count(threadData)}`);
33
+ setStatus('已连接', true);
34
+ return true;
35
+ } catch (error) {
36
+ if (required && requestGeneration === generation) throw error;
37
+ if (requestGeneration === generation) setStatus(error.message, false);
38
+ return false;
39
+ } finally {
40
+ activeRequests -= 1;
41
+ if (forceRender) {
42
+ activeLoads -= 1;
43
+ if (activeLoads === 0) setRefreshDisabled(false);
44
+ }
45
+ }
46
+ }
47
+
48
+ function refresh(reason = 'refresh') {
49
+ return request(reason, false, false);
50
+ }
51
+
52
+ function load(required = false) {
53
+ return request('load', true, required);
54
+ }
55
+
56
+ function start() {
57
+ if (pollingTimer) clearInterval(pollingTimer);
58
+ pollingTimer = setInterval(() => {
59
+ if (isVisible()) void refresh('poll');
60
+ }, 30_000);
61
+ }
62
+
63
+ function schedule(delay = 0, force = false, reason = 'scheduled') {
64
+ if (wakeTimer) clearTimeout(wakeTimer);
65
+ wakeForced ||= force;
66
+ wakeTimer = setTimeout(() => {
67
+ const forced = wakeForced;
68
+ wakeTimer = null;
69
+ wakeForced = false;
70
+ if (forced || isVisible()) void refresh(reason);
71
+ }, delay);
72
+ }
73
+
74
+ function stop() {
75
+ if (pollingTimer) clearInterval(pollingTimer);
76
+ if (wakeTimer) clearTimeout(wakeTimer);
77
+ pollingTimer = null;
78
+ wakeTimer = null;
79
+ wakeForced = false;
80
+ }
81
+
82
+ return { refresh, load, start, schedule, stop };
83
+ }
84
+
85
+ return createThreadListSync;
86
+ });