@oar-sh/oar 0.9.21 → 0.9.22
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/CHANGELOG.md +113 -0
- package/README.md +9 -9
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
- package/server/claude-worker/claude-session-process.mjs +9 -4
- package/server/claude-worker/claude-session-worker-main.mjs +10 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
- package/server/cursor-worker/cursor-session-worker.mjs +9 -1
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/grok-background-tasks.mjs +1 -0
- package/server/grok-worker/grok-session-worker.mjs +10 -1
- package/server/grok-worker/grok-turn-runner.mjs +1 -0
- package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
- package/server/migrations/0038-turn-seq.mjs +92 -0
- package/server/public/app/activity-replay-state.mjs +160 -42
- package/server/public/app/api-client.js +7 -0
- package/server/public/app/ask-user-view.js +4 -3
- package/server/public/app/bootstrap.js +62 -4
- package/server/public/app/chat-actions-menu-state.mjs +2 -0
- package/server/public/app/context-usage-view.mjs +68 -0
- package/server/public/app/conversation-view.js +306 -49
- package/server/public/app/header-priority-actions.mjs +5 -1
- package/server/public/app/held-details.mjs +202 -0
- package/server/public/app/inbox-ui.js +70 -79
- package/server/public/app/inbox-view.mjs +35 -19
- package/server/public/app/journal-view.js +17 -0
- package/server/public/app/live-activity-cap.mjs +103 -0
- package/server/public/app/questions-panel-ui.js +634 -0
- package/server/public/app/questions-stub-view.mjs +40 -0
- package/server/public/app/questions-view.mjs +89 -0
- package/server/public/app/relay-board-view.js +12 -2
- package/server/public/app/relay-thoughts.mjs +15 -1
- package/server/public/app/session-history-ui.js +1 -0
- package/server/public/app/settings-modal.js +6 -0
- package/server/public/app/socket-handlers.js +42 -11
- package/server/public/app/stream-view-preference.mjs +60 -0
- package/server/public/app/subagent-fold.mjs +6 -3
- package/server/public/app/subagent-timeline-view.mjs +12 -5
- package/server/public/app/subagent-viewer.mjs +8 -6
- package/server/public/app/thoughts-preference.mjs +79 -0
- package/server/public/app/turn-stream-view.mjs +124 -0
- package/server/public/index.html +391 -214
- package/server/repositories/message-repository.mjs +10 -0
- package/server/repositories/question-repository.mjs +51 -7
- package/server/repositories/relay-turn-rows.mjs +56 -0
- package/server/routes/ask-user-routes.mjs +34 -5
- package/server/routes/messages-routes.mjs +35 -14
- package/server/routes/relay-ask-routes.mjs +15 -5
- package/server/routes/sessions-routes.mjs +76 -5
- package/server/server-runtime.mjs +178 -131
- package/server/services/cloudflared-tunnel-service.mjs +7 -0
- package/server/services/idle-timers.mjs +184 -0
- package/server/services/queue-wake-hook.mjs +145 -0
- package/server/services/queue-waker.mjs +127 -0
- package/server/services/relay-ask-service.mjs +64 -5
- package/server/services/relay-event-service.mjs +4 -1
- package/server/services/remote-relay-dispatcher.mjs +23 -4
- package/server/services/session-history-refresh-service.mjs +4 -0
- package/server/services/session-worker-supervisor-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +143 -15
- package/server/services/ssh-tunnel-manager-service.mjs +7 -0
- package/shared/ask-user-bridge.mjs +9 -6
- package/shared/relay-asks.mjs +10 -0
- package/shared/relay-events.mjs +1 -0
- package/shared/worker-runtime/heartbeat.mjs +26 -0
- package/shared/worker-runtime/worker-keepalive.mjs +108 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// 📓 Questions of one conversation: the order the panel lists them in, the
|
|
2
|
+
// header icon's count and its labels. Pure: no DOM, no fetch.
|
|
3
|
+
//
|
|
4
|
+
// An ask (relay-ask-service.mjs shape) is pending, or closed: answered,
|
|
5
|
+
// dismissed, withdrawn or cancelled. Pending asks come first, the urgent ones
|
|
6
|
+
// (`urgency: 'now'`) ahead of the rest, each group newest first; then those
|
|
7
|
+
// closed in the last day, the latest close first.
|
|
8
|
+
|
|
9
|
+
export const QUESTIONS_CLOSED_KEEP_MS = 24 * 60 * 60_000;
|
|
10
|
+
|
|
11
|
+
function toText(value) {
|
|
12
|
+
return String(value ?? '').trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function timeOf(value) {
|
|
16
|
+
const ms = Date.parse(toText(value));
|
|
17
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function newestFirst(a, b) {
|
|
21
|
+
return timeOf(b.createdAt) - timeOf(a.createdAt) || toText(b.id).localeCompare(toText(a.id));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function closedAt(ask) {
|
|
25
|
+
return timeOf(ask.answeredAt) || timeOf(ask.updatedAt);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isUrgentAsk(ask) {
|
|
29
|
+
return ask?.urgency === 'now';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The asks of `conversationId`, split and ordered for the panel:
|
|
34
|
+
* `{ pending, urgent, answered }` where `pending` lists every pending ask
|
|
35
|
+
* (urgent ones first), `urgent` counts them and `answered` holds the asks
|
|
36
|
+
* closed within the last day.
|
|
37
|
+
*/
|
|
38
|
+
export function questionsOfConversation(asks, conversationId, now = Date.now()) {
|
|
39
|
+
const id = toText(conversationId);
|
|
40
|
+
const mine = (Array.isArray(asks) ? asks : [])
|
|
41
|
+
.filter((ask) => ask && !ask.relayId && id && toText(ask.conversationId || ask.session?.id) === id);
|
|
42
|
+
const pending = mine.filter((ask) => ask.status === 'pending');
|
|
43
|
+
const urgentAsks = pending.filter(isUrgentAsk).sort(newestFirst);
|
|
44
|
+
const normalAsks = pending.filter((ask) => !isUrgentAsk(ask)).sort(newestFirst);
|
|
45
|
+
const since = now - QUESTIONS_CLOSED_KEEP_MS;
|
|
46
|
+
const answered = mine
|
|
47
|
+
.filter((ask) => ask.status !== 'pending' && closedAt(ask) >= since)
|
|
48
|
+
.sort((a, b) => closedAt(b) - closedAt(a) || toText(b.id).localeCompare(toText(a.id)));
|
|
49
|
+
return { pending: [...urgentAsks, ...normalAsks], urgent: urgentAsks.length, answered };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A count badge: nothing at zero; with an urgent one waiting the urgent
|
|
54
|
+
* count, marked; else the pending count.
|
|
55
|
+
*/
|
|
56
|
+
export function questionsBadge({ pending = 0, urgent = 0 } = {}) {
|
|
57
|
+
const open = Math.max(0, Number(pending) || 0);
|
|
58
|
+
const hot = Math.max(0, Number(urgent) || 0);
|
|
59
|
+
if (hot > 0) return { text: String(hot), urgent: true };
|
|
60
|
+
if (open > 0) return { text: String(open), urgent: false };
|
|
61
|
+
return { text: '', urgent: false };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The 📓 button's tooltip and label. */
|
|
65
|
+
export function questionsAria({ pending = 0, urgent = 0 } = {}) {
|
|
66
|
+
const open = Math.max(0, Number(pending) || 0);
|
|
67
|
+
const hot = Math.max(0, Number(urgent) || 0);
|
|
68
|
+
if (hot > 0) return `Questions (${hot} urgent of ${open} open)`;
|
|
69
|
+
if (open > 0) return `Questions (${open} open)`;
|
|
70
|
+
return 'Questions';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The ︙ twin's text: "📓 Questions (2 urgent)" / "(3 open)". */
|
|
74
|
+
export function questionsTwinLabel({ pending = 0, urgent = 0 } = {}) {
|
|
75
|
+
const open = Math.max(0, Number(pending) || 0);
|
|
76
|
+
const hot = Math.max(0, Number(urgent) || 0);
|
|
77
|
+
if (hot > 0) return `📓 Questions (${hot} urgent)`;
|
|
78
|
+
if (open > 0) return `📓 Questions (${open} open)`;
|
|
79
|
+
return '📓 Questions';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The panel's head line: "📓 Questions · 2 urgent · 5 open". */
|
|
83
|
+
export function questionsPanelTitle({ pending = 0, urgent = 0 } = {}) {
|
|
84
|
+
const open = Math.max(0, Number(pending) || 0);
|
|
85
|
+
const hot = Math.max(0, Number(urgent) || 0);
|
|
86
|
+
return ['📓 Questions', hot > 0 ? `${hot} urgent` : '', open > 0 ? `${open} open` : 'none open']
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.join(' · ');
|
|
89
|
+
}
|
|
@@ -77,8 +77,18 @@ async function loadBoardHistory(conversationId) {
|
|
|
77
77
|
|
|
78
78
|
/** Where a board is shown: its turn's reply, the live bubble of its turn, or nowhere yet. */
|
|
79
79
|
function findBoardHost(el, board) {
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
return findTurnBubble(el, board?.messageId || board?.queueId);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The bubble of the turn that answers queue row `queueId`: its reply
|
|
85
|
+
* (`{ bubble, live: false }`), else the live bubble while it runs
|
|
86
|
+
* (`live: true`), else null (not loaded, or not there). Plan boards and 📓
|
|
87
|
+
* stubs both live there.
|
|
88
|
+
*/
|
|
89
|
+
export function findTurnBubble(el, queueId) {
|
|
90
|
+
const source = String(queueId || '').trim();
|
|
91
|
+
if (!el || !source) return null;
|
|
82
92
|
const replies = el.querySelectorAll(`.msg.assistant[data-source-message-id="${CSS.escape(source)}"]`);
|
|
83
93
|
// The turn's own reply is the last row that answers the message; a steer's
|
|
84
94
|
// marker above it is not a reply.
|
|
@@ -11,6 +11,13 @@ function normalizeTimestampMs(value) {
|
|
|
11
11
|
return Number.isFinite(parsed) ? parsed : null;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// A thought's place in its turn's order (migration 0038), or null.
|
|
15
|
+
function normalizeTurnSeq(value) {
|
|
16
|
+
if (value === null || value === undefined || value === '') return null;
|
|
17
|
+
const num = Number(value);
|
|
18
|
+
return Number.isInteger(num) && num > 0 ? num : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
function toThoughtEntry(value, order) {
|
|
15
22
|
const text = String(value?.text || '');
|
|
16
23
|
if (!text.trim()) return null;
|
|
@@ -19,6 +26,7 @@ function toThoughtEntry(value, order) {
|
|
|
19
26
|
const subagentRunId = String(value?.subagentRunId || '').trim() || null;
|
|
20
27
|
return {
|
|
21
28
|
reasoningId,
|
|
29
|
+
turnSeq: normalizeTurnSeq(value?.turnSeq),
|
|
22
30
|
seq: normalizeSeq(value?.seq),
|
|
23
31
|
text,
|
|
24
32
|
done: !!value?.done,
|
|
@@ -75,7 +83,12 @@ export function normalizeRelayThoughtList(thoughts = []) {
|
|
|
75
83
|
}
|
|
76
84
|
const existing = byReasoningId.get(entry.reasoningId);
|
|
77
85
|
if (!existing || isCandidateNewer(entry, existing)) {
|
|
78
|
-
|
|
86
|
+
// The text goes to the newer copy; the turn order stays the first one
|
|
87
|
+
// known (a thought sits where its first chunk arrived).
|
|
88
|
+
const turnSeq = existing?.turnSeq ?? entry.turnSeq;
|
|
89
|
+
byReasoningId.set(entry.reasoningId, { ...entry, turnSeq });
|
|
90
|
+
} else if (existing.turnSeq === null && entry.turnSeq !== null) {
|
|
91
|
+
existing.turnSeq = entry.turnSeq;
|
|
79
92
|
}
|
|
80
93
|
}
|
|
81
94
|
|
|
@@ -87,6 +100,7 @@ export function normalizeRelayThoughtList(thoughts = []) {
|
|
|
87
100
|
done: !!entry.done,
|
|
88
101
|
timestamp: entry.timestamp || null,
|
|
89
102
|
subagentRunId: entry.subagentRunId || null,
|
|
103
|
+
...(entry.turnSeq !== null ? { turnSeq: entry.turnSeq } : {}),
|
|
90
104
|
}));
|
|
91
105
|
}
|
|
92
106
|
|
|
@@ -96,6 +96,7 @@ function overlayDescriptors(closers) {
|
|
|
96
96
|
{ name: 'tmux-inspector-modal', find: byId('tmux-inspector-modal'), isOpen: isVisible, close: closers.closeTmuxInspector },
|
|
97
97
|
{ name: 'image-annotate-modal', find: byId('image-annotate-modal'), isOpen: isVisible, close: closers.closeAnnotateEditor },
|
|
98
98
|
{ name: 'relay-watch-panel', find: byId('relay-watch-panel'), isOpen: isShown, close: closers.closeRelayWatchPanel },
|
|
99
|
+
{ name: 'questions-panel', find: byId('questions-panel'), isOpen: isShown, close: closers.closeQuestionsPanel },
|
|
99
100
|
{ name: 'relay-schedule-panel', find: byId('relay-schedule-panel'), isOpen: isShown, close: closers.closeRelaySchedulePanel },
|
|
100
101
|
{ name: 'emoji-popup', find: byId('emoji-popup'), isOpen: isVisible, close: closers.closeEmojiPicker },
|
|
101
102
|
// A row's menu (right-click, long press, ⓘ): on <body> while open.
|
|
@@ -82,6 +82,9 @@ import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
|
|
|
82
82
|
import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
|
|
83
83
|
import { refreshSharedConversationsSection } from './shared-conversations-settings.js';
|
|
84
84
|
import { syncScrollOnSendToggle } from './transcript-jump-bottom.mjs';
|
|
85
|
+
import { syncThoughtsExpandedToggle } from './thoughts-preference.mjs';
|
|
86
|
+
import { syncLiveActivityCapSlider } from './live-activity-cap.mjs';
|
|
87
|
+
import { syncStreamViewToggle } from './stream-view-preference.mjs';
|
|
85
88
|
import { syncWindowRetentionSlider } from './conversation-window-retention.mjs';
|
|
86
89
|
import {
|
|
87
90
|
getPreviews,
|
|
@@ -1942,6 +1945,9 @@ export function openSettingsModal(tab, providerTab) {
|
|
|
1942
1945
|
}
|
|
1943
1946
|
syncSuspendHostVisibility();
|
|
1944
1947
|
syncScrollOnSendToggle();
|
|
1948
|
+
syncThoughtsExpandedToggle();
|
|
1949
|
+
syncLiveActivityCapSlider();
|
|
1950
|
+
syncStreamViewToggle();
|
|
1945
1951
|
syncWindowRetentionSlider();
|
|
1946
1952
|
syncFontScaleSelect();
|
|
1947
1953
|
syncPwaAppNameInput();
|
|
@@ -33,7 +33,8 @@ import {
|
|
|
33
33
|
IS_SHARED_VIEW,
|
|
34
34
|
} from './store.js';
|
|
35
35
|
import { scheduleContextUsageRefresh } from './api-client.js';
|
|
36
|
-
import { capLiveActivityEntries } from './activity-replay-state.mjs';
|
|
36
|
+
import { capFinishedActivityEntries, capLiveActivityEntries, normalizeTurnSeq } from './activity-replay-state.mjs';
|
|
37
|
+
import { liveActivityCap } from './live-activity-cap.mjs';
|
|
37
38
|
import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
|
|
38
39
|
import { clearOpenConversation, renderConvList, refreshConversations, scheduleLoadedConversationsRefresh, applyConversationGroupEvent, openConversation } from './journal-view.js';
|
|
39
40
|
import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } from './conversation-turn-state.mjs';
|
|
@@ -50,6 +51,7 @@ import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-bo
|
|
|
50
51
|
import { setConversationBackgroundTasks, setConversationTaskHealth } from './background-tasks-view.mjs';
|
|
51
52
|
import { handleTaskConsoleReconnect, handleTaskOutputChunk } from './task-console.mjs';
|
|
52
53
|
import { setConversationPins } from './pinned-messages-view.mjs';
|
|
54
|
+
import { noteQuestionAskUpdated, refreshQuestions } from './questions-panel-ui.js';
|
|
53
55
|
import { setConversationHiddenMessages } from './hidden-messages-view.mjs';
|
|
54
56
|
import { setPreviews } from './preview-cards.mjs';
|
|
55
57
|
import { applyShareSettings, hasActiveShare, refreshShares } from './share-state.js';
|
|
@@ -377,8 +379,10 @@ export async function connectSocket(overrideDeps) {
|
|
|
377
379
|
void loadRelayEventSubscriptions();
|
|
378
380
|
// Schedules that changed or fired while away.
|
|
379
381
|
void loadRelaySchedules();
|
|
380
|
-
// Questions asked or answered while away
|
|
382
|
+
// Questions asked or answered while away: the Inbox and the open
|
|
383
|
+
// conversation's 📓.
|
|
381
384
|
void refreshInbox();
|
|
385
|
+
void refreshQuestions();
|
|
382
386
|
// Shares made or stopped while away.
|
|
383
387
|
void refreshShares().catch(() => {});
|
|
384
388
|
});
|
|
@@ -600,7 +604,8 @@ export async function connectSocket(overrideDeps) {
|
|
|
600
604
|
}
|
|
601
605
|
if ((!message?.activities || !message.activities.length) && sourceMessageId) {
|
|
602
606
|
const cached = relayActivities.get(sourceMessageId) || [];
|
|
603
|
-
|
|
607
|
+
// The finished turn's cap (newest rows per thread), as the relay sends it.
|
|
608
|
+
if (cached.length) message.activities = capFinishedActivityEntries(cached);
|
|
604
609
|
}
|
|
605
610
|
if (sourceMessageId) {
|
|
606
611
|
const persistedThoughts = Array.isArray(message?.thoughts) ? message.thoughts : [];
|
|
@@ -669,7 +674,10 @@ export async function connectSocket(overrideDeps) {
|
|
|
669
674
|
// A paired relay's pending cards changed (this relay checks them every ~20 s).
|
|
670
675
|
socket.on(INBOX_REMOTE_CHANGED_EVENT, () => noteInboxQuestionsChanged());
|
|
671
676
|
// Questions that don't block: one ask, after every change of it.
|
|
672
|
-
socket.on(RELAY_ASK_SOCKET_EVENT, (payload) =>
|
|
677
|
+
socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => {
|
|
678
|
+
noteRelayAskUpdated(payload);
|
|
679
|
+
noteQuestionAskUpdated(payload?.ask);
|
|
680
|
+
});
|
|
673
681
|
socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
|
|
674
682
|
socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
|
|
675
683
|
socket.on('relay_board_changed', () => {
|
|
@@ -752,21 +760,36 @@ export async function connectSocket(overrideDeps) {
|
|
|
752
760
|
if (payload && typeof payload === 'object') applyAgentSessionsSettingsState(payload);
|
|
753
761
|
else void refreshAgentSessionsSection();
|
|
754
762
|
});
|
|
755
|
-
socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata }) => {
|
|
763
|
+
socket.on('relay_activity', ({ conversationId, messageId, text, subagentRunId, metadata, turnSeq }) => {
|
|
756
764
|
if (!messageId || !text) return;
|
|
765
|
+
const numbered = normalizeTurnSeq(turnSeq);
|
|
757
766
|
const entry = {
|
|
758
767
|
text: String(text || '').trim(),
|
|
759
768
|
subagentRunId: subagentRunId ? String(subagentRunId).trim() : null,
|
|
760
769
|
metadata: (metadata && typeof metadata === 'object' && !Array.isArray(metadata)) ? metadata : null,
|
|
770
|
+
...(numbered !== null ? { turnSeq: numbered } : {}),
|
|
761
771
|
};
|
|
762
772
|
if (!entry.text) return;
|
|
763
773
|
const items = relayActivities.get(messageId) || [];
|
|
764
774
|
const last = items[items.length - 1];
|
|
765
775
|
const lastText = typeof last === 'string' ? last : String(last?.text || '');
|
|
766
776
|
const lastSubagentRunId = typeof last === 'object' && last ? (last.subagentRunId || null) : null;
|
|
767
|
-
// The same
|
|
768
|
-
|
|
769
|
-
|
|
777
|
+
// The same event again (a replay after a reconnect) adds nothing. A
|
|
778
|
+
// numbered row is known by its number, so the same tool line run twice
|
|
779
|
+
// is two rows; an unnumbered one (an older relay) by being the last line.
|
|
780
|
+
const added = numbered !== null
|
|
781
|
+
? !items.some((item) => item?.turnSeq === numbered)
|
|
782
|
+
: (lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId);
|
|
783
|
+
if (numbered !== null && !added) return;
|
|
784
|
+
if (added) {
|
|
785
|
+
// A numbered list stays in the turn's order, so a row replayed after
|
|
786
|
+
// the cap trimmed it sorts to the front and is trimmed again.
|
|
787
|
+
let next = items.concat(entry);
|
|
788
|
+
if (numbered !== null && next.every((item) => item?.turnSeq != null)) next = next.sort((a, b) => a.turnSeq - b.turnSeq);
|
|
789
|
+
const kept = capLiveActivityEntries(next, liveActivityCap());
|
|
790
|
+
relayActivities.set(messageId, kept);
|
|
791
|
+
if (numbered !== null && !kept.includes(entry)) return;
|
|
792
|
+
}
|
|
770
793
|
if (entry.subagentRunId) {
|
|
771
794
|
upsertSubagentRun({ subagentRunId: entry.subagentRunId, messageId, conversationId });
|
|
772
795
|
addSubagentActivity(entry.subagentRunId, entry.text);
|
|
@@ -795,14 +818,22 @@ export async function connectSocket(overrideDeps) {
|
|
|
795
818
|
// A frame that was dropped (out of order, a finished turn) is no news.
|
|
796
819
|
if (accepted && !autoScroll) noteTranscriptNews();
|
|
797
820
|
});
|
|
798
|
-
socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId }) => {
|
|
821
|
+
socket.on('relay_thought', ({ conversationId, messageId, reasoningId, text, done, subagentRunId, turnSeq }) => {
|
|
799
822
|
if (!messageId) return;
|
|
800
823
|
const key = String(reasoningId || 'reasoning');
|
|
801
824
|
const thoughtMap = relayThoughts.get(messageId) || new Map();
|
|
802
825
|
const known = thoughtMap.get(key);
|
|
803
826
|
// The same thought again (a repeated event) adds nothing.
|
|
804
827
|
const added = !known || known.text !== String(text || '') || known.done !== !!done;
|
|
805
|
-
|
|
828
|
+
// A thought keeps the place of its first chunk.
|
|
829
|
+
const keptTurnSeq = known?.turnSeq ?? normalizeTurnSeq(turnSeq);
|
|
830
|
+
thoughtMap.set(key, {
|
|
831
|
+
reasoningId: key,
|
|
832
|
+
text: String(text || ''),
|
|
833
|
+
done: !!done,
|
|
834
|
+
subagentRunId: subagentRunId || null,
|
|
835
|
+
...(keptTurnSeq != null ? { turnSeq: keptTurnSeq } : {}),
|
|
836
|
+
});
|
|
806
837
|
relayThoughts.set(messageId, thoughtMap);
|
|
807
838
|
if (subagentRunId) {
|
|
808
839
|
upsertSubagentRun({ subagentRunId, messageId, conversationId });
|
|
@@ -810,7 +841,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
810
841
|
}
|
|
811
842
|
if (conversationId === currentConvId) {
|
|
812
843
|
const autoScroll = followsTranscriptEnd();
|
|
813
|
-
appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll);
|
|
844
|
+
appendThinkingThought(key, String(text || ''), !!done, subagentRunId, autoScroll, messageId);
|
|
814
845
|
if (added && !autoScroll) noteTranscriptNews();
|
|
815
846
|
}
|
|
816
847
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// "Stream view" (Settings › General › Appearance, and its mirror in the 🧠
|
|
2
|
+
// modal): an agent turn's thoughts, tool calls and reply as one list in the
|
|
3
|
+
// order they happened, live and in the finished message. A setting of this
|
|
4
|
+
// browser, kept in localStorage like the thoughts one; off unless switched
|
|
5
|
+
// on. Turns recorded before the relay numbered their rows keep the grouped
|
|
6
|
+
// layout either way (turn-stream-view.mjs). The document, the storage and
|
|
7
|
+
// the event target are passed in by the tests.
|
|
8
|
+
|
|
9
|
+
export const STREAM_VIEW_STORAGE_KEY = 'copilot_stream_view';
|
|
10
|
+
export const STREAM_VIEW_TOGGLE_ID = 'stream-view-toggle';
|
|
11
|
+
// The page re-renders its transcript and the live bubble on this.
|
|
12
|
+
export const STREAM_VIEW_CHANGE_EVENT = 'oar:stream-view-change';
|
|
13
|
+
// The 🧠 modal's two buttons: `data-stream-view` is "on" or "off".
|
|
14
|
+
export const STREAM_VIEW_BUTTON_SELECTOR = '.ctx-stream-view-btn[data-stream-view]';
|
|
15
|
+
|
|
16
|
+
/** Only an explicit on is on: a missing or damaged value reads off. */
|
|
17
|
+
export function parseStreamViewSetting(stored) {
|
|
18
|
+
const value = String(stored ?? '').trim().toLowerCase();
|
|
19
|
+
return value === '1' || value === 'true';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serializeStreamViewSetting(on) {
|
|
23
|
+
return on ? '1' : '0';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function storage() {
|
|
27
|
+
try { return globalThis.localStorage || null; } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isStreamViewEnabled(store = storage()) {
|
|
31
|
+
try {
|
|
32
|
+
return parseStreamViewSetting(store?.getItem(STREAM_VIEW_STORAGE_KEY));
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The Settings checkbox and the 🧠 buttons, wherever they are rendered. */
|
|
39
|
+
export function syncStreamViewToggle(doc = globalThis.document, store = storage()) {
|
|
40
|
+
const on = isStreamViewEnabled(store);
|
|
41
|
+
const checkbox = doc?.getElementById?.(STREAM_VIEW_TOGGLE_ID);
|
|
42
|
+
if (checkbox) checkbox.checked = on;
|
|
43
|
+
const buttons = doc?.querySelectorAll?.(STREAM_VIEW_BUTTON_SELECTOR) || [];
|
|
44
|
+
for (const btn of buttons) {
|
|
45
|
+
const active = (btn.dataset?.streamView === 'on') === on;
|
|
46
|
+
btn.classList?.toggle('is-active', active);
|
|
47
|
+
btn.setAttribute?.('aria-pressed', active ? 'true' : 'false');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function updateStreamViewSetting(on, { doc = globalThis.document, store = storage(), target = globalThis.window } = {}) {
|
|
52
|
+
const value = !!on;
|
|
53
|
+
const before = isStreamViewEnabled(store);
|
|
54
|
+
try { store?.setItem(STREAM_VIEW_STORAGE_KEY, serializeStreamViewSetting(value)); } catch {}
|
|
55
|
+
syncStreamViewToggle(doc, store);
|
|
56
|
+
if (before === isStreamViewEnabled(store)) return;
|
|
57
|
+
try {
|
|
58
|
+
target?.dispatchEvent?.(new CustomEvent(STREAM_VIEW_CHANGE_EVENT, { detail: { on: value } }));
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
@@ -35,13 +35,16 @@ function oneLine(text) {
|
|
|
35
35
|
/**
|
|
36
36
|
* The line a folded header shows: the number of steps (activity lines) and
|
|
37
37
|
* the latest one, or the end of what the run has written when it has taken
|
|
38
|
-
* no step yet. Empty when there is nothing to say.
|
|
38
|
+
* no step yet. Empty when there is nothing to say. `cap` is how many lines
|
|
39
|
+
* the bubble keeps (the "Tool lines shown" setting, 0 = all): a bubble that
|
|
40
|
+
* holds that many has likely dropped older ones, so the count reads "50+".
|
|
39
41
|
*/
|
|
40
|
-
export function subagentFoldSummary({ activities = [], streamText = '' } = {}) {
|
|
42
|
+
export function subagentFoldSummary({ activities = [], streamText = '', cap = 0 } = {}) {
|
|
41
43
|
const steps = (Array.isArray(activities) ? activities : []).map(oneLine).filter(Boolean);
|
|
42
44
|
if (steps.length) {
|
|
43
45
|
const latest = steps[steps.length - 1];
|
|
44
|
-
const
|
|
46
|
+
const atCap = Number(cap) > 0 && steps.length >= Number(cap);
|
|
47
|
+
const count = atCap ? `${steps.length}+ steps` : (steps.length === 1 ? '1 step' : `${steps.length} steps`);
|
|
45
48
|
return `${count} · ${latest}`.slice(0, SUMMARY_MAX_CHARS);
|
|
46
49
|
}
|
|
47
50
|
const lines = String(streamText || '').split(/\r?\n/).map(oneLine).filter(Boolean);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
6
6
|
import { subagentRowHtml } from './subagent-row.mjs';
|
|
7
|
+
import { isThoughtsExpanded, thoughtFoldOpen } from './thoughts-preference.mjs';
|
|
7
8
|
|
|
8
9
|
function escHtml(value) {
|
|
9
10
|
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
|
@@ -100,20 +101,26 @@ function firstLine(text, max = 140) {
|
|
|
100
101
|
/**
|
|
101
102
|
* One entry's markup. `renderMarkdown(text)` renders reply text (and
|
|
102
103
|
* thoughts); `children` maps a child run id to its header so a child entry
|
|
103
|
-
* renders as a clickable row; `openDetails` keeps
|
|
104
|
-
* open
|
|
104
|
+
* renders as a clickable row; `openDetails` keeps the reader's folds across
|
|
105
|
+
* re-renders: a Map or a function from seq to 'open' | 'folded' | undefined
|
|
106
|
+
* (`true` still means "open"). A thought without a choice, streaming or
|
|
107
|
+
* done, opens as `thoughtsExpanded` (the "Expand thoughts" setting) says.
|
|
105
108
|
*/
|
|
106
|
-
export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, clickable = true, now = Date.now() } = {}) {
|
|
109
|
+
export function timelineEntryHtml(entry, { renderMarkdown = (text) => `<p>${escHtml(text)}</p>`, children = new Map(), openDetails = false, thoughtsExpanded = isThoughtsExpanded(), clickable = true, now = Date.now() } = {}) {
|
|
107
110
|
const kind = String(entry?.kind || '');
|
|
108
111
|
const seq = Number(entry?.seq) || 0;
|
|
109
112
|
const open = entry?.done === false;
|
|
110
113
|
const text = String(entry?.text || '');
|
|
111
114
|
const wrap = (inner, extraClass = '') => `<div class="sav-entry sav-entry-${escHtml(kind)}${open ? ' is-open' : ''}${extraClass}" data-seq="${seq}">${inner}</div>`;
|
|
112
|
-
const
|
|
115
|
+
const choice = typeof openDetails === 'function'
|
|
116
|
+
? openDetails(seq)
|
|
117
|
+
: (openDetails instanceof Map ? openDetails.get(seq) : (openDetails === true ? 'open' : undefined));
|
|
118
|
+
const openAttr = choice === 'open' ? ' open' : '';
|
|
113
119
|
|
|
114
120
|
if (kind === 'thought') {
|
|
115
121
|
const label = open && !text.trim() ? 'Thinking…' : (firstLine(text, 110) || 'Thought');
|
|
116
|
-
|
|
122
|
+
const thoughtOpen = thoughtFoldOpen({ choice: choice || null, expanded: thoughtsExpanded });
|
|
123
|
+
return wrap(`<details class="sav-fold"${thoughtOpen ? ' open' : ''}><summary><span class="sav-kind">💭</span> <span class="sav-summary-text">${escHtml(label)}</span></summary><div class="sav-thought-body markdown-body">${renderMarkdown(text)}</div></details>`);
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
if (kind === 'text') {
|
|
@@ -50,7 +50,9 @@ const viewer = {
|
|
|
50
50
|
stopTimer: null,
|
|
51
51
|
tickTimer: null,
|
|
52
52
|
sharedPollTimer: null,
|
|
53
|
-
|
|
53
|
+
// The reader's folds by seq ('open' | 'folded'); a thought without one
|
|
54
|
+
// follows the "Expand thoughts" setting.
|
|
55
|
+
openFolds: new Map(),
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
let viewerHistory = null;
|
|
@@ -151,7 +153,7 @@ function buildEntryNode(entry) {
|
|
|
151
153
|
holder.innerHTML = timelineEntryHtml(entry, {
|
|
152
154
|
renderMarkdown,
|
|
153
155
|
children: viewer.children,
|
|
154
|
-
openDetails: viewer.openFolds
|
|
156
|
+
openDetails: viewer.openFolds,
|
|
155
157
|
clickable: true,
|
|
156
158
|
});
|
|
157
159
|
return holder.firstElementChild;
|
|
@@ -283,7 +285,7 @@ async function loadRun(runId) {
|
|
|
283
285
|
if (!id) return;
|
|
284
286
|
viewer.state = createTimelineState(id);
|
|
285
287
|
viewer.children = new Map();
|
|
286
|
-
viewer.openFolds = new
|
|
288
|
+
viewer.openFolds = new Map();
|
|
287
289
|
viewer.error = '';
|
|
288
290
|
viewer.stopping = false;
|
|
289
291
|
clearTimeout(viewer.stopTimer);
|
|
@@ -552,7 +554,8 @@ export function initSubagentViewer() {
|
|
|
552
554
|
body?.addEventListener('scroll', () => {
|
|
553
555
|
if (jump && !jump.hidden && isAtBottom(body)) jump.hidden = true;
|
|
554
556
|
}, { passive: true });
|
|
555
|
-
// A fold the reader opened stays
|
|
557
|
+
// A fold the reader opened or closed stays that way while the entry keeps
|
|
558
|
+
// changing.
|
|
556
559
|
timeline?.addEventListener('click', (event) => {
|
|
557
560
|
const summary = event.target?.closest?.('summary');
|
|
558
561
|
const entry = summary?.closest?.('.sav-entry');
|
|
@@ -560,8 +563,7 @@ export function initSubagentViewer() {
|
|
|
560
563
|
const seq = Number(entry.dataset.seq);
|
|
561
564
|
setTimeout(() => {
|
|
562
565
|
const details = summary.parentElement;
|
|
563
|
-
|
|
564
|
-
else viewer.openFolds.delete(seq);
|
|
566
|
+
viewer.openFolds.set(seq, details?.open ? 'open' : 'folded');
|
|
565
567
|
}, 0);
|
|
566
568
|
});
|
|
567
569
|
document.addEventListener('keydown', (event) => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// "Expand thoughts while an agent works" (Settings › General, and its mirror
|
|
2
|
+
// in the 🧠 modal). A setting of this browser, kept in localStorage like the
|
|
3
|
+
// scroll-on-send one. On unless switched off: it decides whether a thought
|
|
4
|
+
// starts open in the live turn, in a live sub-agent bubble and in the
|
|
5
|
+
// sub-agent viewer. A fold the reader opened or closed by hand wins over it.
|
|
6
|
+
// A finished message's 💭 fold stays closed either way. The document, the
|
|
7
|
+
// storage and the event target are passed in by the tests.
|
|
8
|
+
|
|
9
|
+
export const THOUGHTS_EXPANDED_STORAGE_KEY = 'copilot_thoughts_expanded';
|
|
10
|
+
export const THOUGHTS_EXPANDED_TOGGLE_ID = 'thoughts-expanded-toggle';
|
|
11
|
+
export const THOUGHTS_EXPANDED_CHANGE_EVENT = 'oar:thoughts-expanded-change';
|
|
12
|
+
// The 🧠 modal's two buttons: `data-thoughts-expanded` is "on" or "off".
|
|
13
|
+
export const THOUGHTS_EXPANDED_BUTTON_SELECTOR = '.ctx-thoughts-btn[data-thoughts-expanded]';
|
|
14
|
+
|
|
15
|
+
/** Anything but an explicit off is on: a missing or damaged value included. */
|
|
16
|
+
export function parseThoughtsExpandedSetting(stored) {
|
|
17
|
+
const value = String(stored ?? '').trim().toLowerCase();
|
|
18
|
+
if (!value) return true;
|
|
19
|
+
return value !== '0' && value !== 'false';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function serializeThoughtsExpandedSetting(expanded) {
|
|
23
|
+
return expanded ? '1' : '0';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function storage() {
|
|
27
|
+
try { return globalThis.localStorage || null; } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isThoughtsExpanded(store = storage()) {
|
|
31
|
+
try {
|
|
32
|
+
return parseThoughtsExpandedSetting(store?.getItem(THOUGHTS_EXPANDED_STORAGE_KEY));
|
|
33
|
+
} catch {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The Settings checkbox and the 🧠 buttons, wherever they are rendered. */
|
|
39
|
+
export function syncThoughtsExpandedToggle(doc = globalThis.document, store = storage()) {
|
|
40
|
+
const expanded = isThoughtsExpanded(store);
|
|
41
|
+
const checkbox = doc?.getElementById?.(THOUGHTS_EXPANDED_TOGGLE_ID);
|
|
42
|
+
if (checkbox) checkbox.checked = expanded;
|
|
43
|
+
const buttons = doc?.querySelectorAll?.(THOUGHTS_EXPANDED_BUTTON_SELECTOR) || [];
|
|
44
|
+
for (const btn of buttons) {
|
|
45
|
+
const active = (btn.dataset?.thoughtsExpanded === 'on') === expanded;
|
|
46
|
+
btn.classList?.toggle('is-active', active);
|
|
47
|
+
btn.setAttribute?.('aria-pressed', active ? 'true' : 'false');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function updateThoughtsExpandedSetting(expanded, { doc = globalThis.document, store = storage(), target = globalThis.window } = {}) {
|
|
52
|
+
const value = !!expanded;
|
|
53
|
+
try { store?.setItem(THOUGHTS_EXPANDED_STORAGE_KEY, serializeThoughtsExpandedSetting(value)); } catch {}
|
|
54
|
+
syncThoughtsExpandedToggle(doc, store);
|
|
55
|
+
try {
|
|
56
|
+
target?.dispatchEvent?.(new CustomEvent(THOUGHTS_EXPANDED_CHANGE_EVENT, { detail: { expanded: value } }));
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Whether a thought fold shows open. `choice` is what the reader did by hand
|
|
62
|
+
* ('open' | 'folded'), and it wins; everything else, a thought still
|
|
63
|
+
* streaming included, follows the setting (`expanded`).
|
|
64
|
+
*/
|
|
65
|
+
export function thoughtFoldOpen({ choice = null, expanded = true } = {}) {
|
|
66
|
+
if (choice === 'open') return true;
|
|
67
|
+
if (choice === 'folded') return false;
|
|
68
|
+
return !!expanded;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const MAX_THOUGHT_FOLD_CHOICES = 500;
|
|
72
|
+
|
|
73
|
+
/** Record a hand fold; the oldest choice goes once the map is full. */
|
|
74
|
+
export function rememberThoughtFoldChoice(choices, key, open, max = MAX_THOUGHT_FOLD_CHOICES) {
|
|
75
|
+
if (!choices || key === undefined || key === null || key === '') return;
|
|
76
|
+
choices.delete(key);
|
|
77
|
+
while (choices.size >= max) choices.delete(choices.keys().next().value);
|
|
78
|
+
choices.set(key, open ? 'open' : 'folded');
|
|
79
|
+
}
|