@oar-sh/oar 0.9.5 → 0.9.7
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/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
- package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
- package/CHANGELOG.md +340 -0
- package/README.md +196 -26
- package/package.json +2 -1
- package/server/README.md +174 -7
- package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
- package/server/claude-worker/claude-session-process.mjs +151 -1
- package/server/claude-worker/claude-session-worker.mjs +6 -0
- package/server/claude-worker/claude-turn-failure.mjs +92 -0
- package/server/claude-worker/claude-turn-publisher.mjs +52 -3
- package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
- package/server/copilot-worker/copilot-model-switch.mjs +29 -3
- package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
- package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
- package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
- package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
- package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
- package/server/db-schema.mjs +24 -0
- package/server/grok-worker/acp-client.mjs +68 -12
- package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
- package/server/grok-worker/grok-turn-runner.mjs +6 -2
- package/server/migrations/0006-workspace-root-settings.mjs +67 -0
- package/server/migrations/0007-claude-cloud.mjs +82 -0
- package/server/public/app/agent-sessions-settings-ui.js +197 -0
- package/server/public/app/agent-sessions-settings.mjs +134 -0
- package/server/public/app/api-client.js +160 -6
- package/server/public/app/attachments-view.js +20 -1
- package/server/public/app/attribution-control.mjs +46 -0
- package/server/public/app/bootstrap.js +347 -31
- package/server/public/app/claude-cloud-conversation-ui.js +110 -0
- package/server/public/app/claude-cloud-settings-ui.js +374 -0
- package/server/public/app/claude-cloud-ui.mjs +611 -0
- package/server/public/app/composer-control-state.mjs +15 -3
- package/server/public/app/context-menu.mjs +140 -0
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/conversation-origin-ui.js +27 -0
- package/server/public/app/conversation-provider-indicator.mjs +22 -4
- package/server/public/app/conversation-view.js +178 -8
- package/server/public/app/cwd-picker.js +12 -0
- package/server/public/app/journal-view.js +704 -28
- package/server/public/app/markdown-strikethrough.mjs +31 -0
- package/server/public/app/math-dollars.mjs +89 -0
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +36 -8
- package/server/public/app/relay-board-inline.mjs +70 -0
- package/server/public/app/relay-board-view.js +157 -61
- package/server/public/app/relay-error-ctas.mjs +28 -5
- package/server/public/app/remote-relay-origin-view.mjs +120 -4
- package/server/public/app/router.js +49 -12
- package/server/public/app/settings-modal.js +33 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +60 -0
- package/server/public/app/store.js +4 -1
- package/server/public/app/subagent-fold.mjs +61 -0
- package/server/public/app/usage-limit-ui.js +225 -0
- package/server/public/index.html +471 -8
- package/server/relay-tools.md +13 -1
- package/server/repositories/message-repository.mjs +10 -1
- package/server/repositories/question-repository.mjs +1 -0
- package/server/repositories/remote-relay-repository.mjs +72 -1
- package/server/repositories/session-repository.mjs +47 -1
- package/server/routes/claude-cloud-routes.mjs +140 -0
- package/server/routes/git-routes.mjs +37 -0
- package/server/routes/messages-routes.mjs +202 -26
- package/server/routes/relay-board-routes.mjs +56 -2
- package/server/routes/remote-relay-routes.mjs +15 -1
- package/server/routes/sessions-routes.mjs +538 -72
- package/server/server-runtime.mjs +291 -27
- package/server/services/claude-account-usage-service.mjs +176 -0
- package/server/services/claude-cloud-repo-service.mjs +408 -0
- package/server/services/claude-cloud-session-service.mjs +326 -0
- package/server/services/claude-cloud-settings-service.mjs +269 -0
- package/server/services/git-remote-service.mjs +115 -0
- package/server/services/plan-usage-claude-cloud.mjs +211 -0
- package/server/services/plan-usage-claude.mjs +202 -40
- package/server/services/plan-usage-contract.mjs +39 -3
- package/server/services/plan-usage-service.mjs +82 -2
- package/server/services/preview-proxy-server.mjs +64 -36
- package/server/services/process-list-service.mjs +122 -0
- package/server/services/provider-reasoning-effort.mjs +3 -0
- package/server/services/relay-console-log.mjs +199 -0
- package/server/services/remote-relay-caller-context.mjs +78 -19
- package/server/services/remote-relay-client.mjs +9 -0
- package/server/services/remote-relay-dispatcher.mjs +451 -39
- package/server/services/remote-relay-inbound.mjs +83 -1
- package/server/services/remote-relay-loopback.mjs +245 -0
- package/server/services/remote-relay-registry-service.mjs +52 -0
- package/server/services/session-worker-launch-service.mjs +100 -15
- package/server/services/session-worker-process-service.mjs +410 -138
- package/server/services/session-worker-registry-service.mjs +1 -1
- package/server/services/session-worker-stop-service.mjs +24 -13
- package/server/services/session-worker-supervisor-service.mjs +89 -2
- package/server/services/usage-limit-pause-service.mjs +443 -0
- package/server/services/worker-launch-note-service.mjs +151 -0
- package/shared/ask-user-bridge.mjs +14 -39
- package/shared/claude-attribution.mjs +117 -0
- package/shared/claude-cloud/api-client.mjs +809 -0
- package/shared/claude-cloud/base-url.mjs +47 -0
- package/shared/claude-cloud/credentials.mjs +221 -0
- package/shared/claude-cloud/repo-url.mjs +111 -0
- package/shared/claude-usage-limit.mjs +257 -0
- package/shared/control-poller.mjs +69 -20
- package/shared/failure-note-text.mjs +38 -0
- package/shared/provider-routing.mjs +4 -2
- package/shared/question-wait.mjs +108 -0
- package/shared/remote-relay-contract.mjs +167 -22
- package/shared/remote-relay-tool-core.mjs +11 -8
- package/shared/steer-settle-failure.mjs +17 -0
- package/shared/steer-settle-markers.mjs +8 -0
- package/shared/worker-runtime/process-tree.mjs +483 -0
- package/shared/worker-runtime/relay-errors.mjs +31 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
- package/shared/worker-runtime/turn-liveness.mjs +249 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// A context menu for the conversation list: right-click on a desktop, a long
|
|
2
|
+
// press on a phone. One menu at a time, closed by a pointer-down outside it,
|
|
3
|
+
// Escape, a scroll or a resize, like a native menu; flipped at the screen
|
|
4
|
+
// edge rather than clipped. The entries are buttons, so keyboard users get
|
|
5
|
+
// focus on the first one.
|
|
6
|
+
|
|
7
|
+
const LONG_PRESS_MS = 500;
|
|
8
|
+
const LONG_PRESS_SLOP_PX = 10;
|
|
9
|
+
|
|
10
|
+
/** A horizontal rule between groups. */
|
|
11
|
+
export const MENU_SEPARATOR = null;
|
|
12
|
+
|
|
13
|
+
let closeOpenMenu = null;
|
|
14
|
+
|
|
15
|
+
function esc(value) {
|
|
16
|
+
return String(value ?? '')
|
|
17
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Close the open menu, if any. */
|
|
21
|
+
export function closeContextMenu() {
|
|
22
|
+
closeOpenMenu?.();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Open a menu at viewport coordinates. `items`: `{ label, onSelect, danger?,
|
|
27
|
+
* disabled?, id? }`, or MENU_SEPARATOR between groups. Returns the menu node.
|
|
28
|
+
*/
|
|
29
|
+
export function openContextMenu(x, y, items, { documentRef = document, windowRef = window } = {}) {
|
|
30
|
+
closeOpenMenu?.();
|
|
31
|
+
const menu = documentRef.createElement('div');
|
|
32
|
+
menu.className = 'context-menu';
|
|
33
|
+
menu.setAttribute('role', 'menu');
|
|
34
|
+
|
|
35
|
+
const close = () => {
|
|
36
|
+
if (closeOpenMenu !== close) return;
|
|
37
|
+
closeOpenMenu = null;
|
|
38
|
+
documentRef.removeEventListener('pointerdown', onOutside, true);
|
|
39
|
+
documentRef.removeEventListener('keydown', onKey, true);
|
|
40
|
+
windowRef.removeEventListener('resize', close);
|
|
41
|
+
windowRef.removeEventListener('scroll', close, true);
|
|
42
|
+
menu.remove();
|
|
43
|
+
};
|
|
44
|
+
const onOutside = (event) => {
|
|
45
|
+
if (!menu.contains(event.target)) close();
|
|
46
|
+
};
|
|
47
|
+
const onKey = (event) => {
|
|
48
|
+
if (event.key === 'Escape') {
|
|
49
|
+
event.stopPropagation();
|
|
50
|
+
close();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
54
|
+
const buttons = [...menu.querySelectorAll('button:not([disabled])')];
|
|
55
|
+
if (!buttons.length) return;
|
|
56
|
+
const index = buttons.indexOf(documentRef.activeElement);
|
|
57
|
+
const next = event.key === 'ArrowDown'
|
|
58
|
+
? buttons[(index + 1) % buttons.length]
|
|
59
|
+
: buttons[(index <= 0 ? buttons.length : index) - 1];
|
|
60
|
+
next.focus();
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
if (item === MENU_SEPARATOR) {
|
|
66
|
+
const rule = documentRef.createElement('div');
|
|
67
|
+
rule.className = 'context-menu-sep';
|
|
68
|
+
rule.setAttribute('role', 'separator');
|
|
69
|
+
menu.append(rule);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!item) continue;
|
|
73
|
+
const entry = documentRef.createElement('button');
|
|
74
|
+
entry.type = 'button';
|
|
75
|
+
entry.className = `context-menu-item${item.danger ? ' context-menu-item-danger' : ''}`;
|
|
76
|
+
entry.setAttribute('role', 'menuitem');
|
|
77
|
+
if (item.id) entry.dataset.menuItem = String(item.id);
|
|
78
|
+
entry.innerHTML = esc(item.label);
|
|
79
|
+
entry.disabled = item.disabled === true;
|
|
80
|
+
entry.addEventListener('click', (event) => {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
event.stopPropagation();
|
|
83
|
+
close();
|
|
84
|
+
item.onSelect?.();
|
|
85
|
+
});
|
|
86
|
+
menu.append(entry);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
documentRef.body.append(menu);
|
|
90
|
+
// Positioned after insertion so the measured size is the real one.
|
|
91
|
+
const rect = menu.getBoundingClientRect();
|
|
92
|
+
const left = Math.max(4, Math.min(x, windowRef.innerWidth - rect.width - 4));
|
|
93
|
+
const top = Math.max(4, Math.min(y, windowRef.innerHeight - rect.height - 4));
|
|
94
|
+
menu.style.left = `${left}px`;
|
|
95
|
+
menu.style.top = `${top}px`;
|
|
96
|
+
|
|
97
|
+
closeOpenMenu = close;
|
|
98
|
+
documentRef.addEventListener('pointerdown', onOutside, true);
|
|
99
|
+
documentRef.addEventListener('keydown', onKey, true);
|
|
100
|
+
windowRef.addEventListener('resize', close);
|
|
101
|
+
windowRef.addEventListener('scroll', close, true);
|
|
102
|
+
queueMicrotask(() => menu.querySelector('button:not([disabled])')?.focus());
|
|
103
|
+
return menu;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Long-press as the touch equivalent of right-click, on every descendant of
|
|
108
|
+
* `root` that matches `selector`. Android fires no `contextmenu` for a long
|
|
109
|
+
* press on a non-selectable element. Cancelled by movement, so it never fires
|
|
110
|
+
* mid-scroll; mouse pointers are left to the real `contextmenu` event.
|
|
111
|
+
* `handler(target, x, y)`.
|
|
112
|
+
*/
|
|
113
|
+
export function bindLongPress(root, selector, handler, { setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout } = {}) {
|
|
114
|
+
let timer = null;
|
|
115
|
+
let startX = 0;
|
|
116
|
+
let startY = 0;
|
|
117
|
+
const cancel = () => {
|
|
118
|
+
if (timer !== null) clearTimeoutImpl(timer);
|
|
119
|
+
timer = null;
|
|
120
|
+
};
|
|
121
|
+
root.addEventListener('pointerdown', (event) => {
|
|
122
|
+
if (event.pointerType === 'mouse') return;
|
|
123
|
+
const target = event.target?.closest?.(selector);
|
|
124
|
+
if (!target || !root.contains(target)) return;
|
|
125
|
+
startX = event.clientX;
|
|
126
|
+
startY = event.clientY;
|
|
127
|
+
cancel();
|
|
128
|
+
timer = setTimeoutImpl(() => {
|
|
129
|
+
timer = null;
|
|
130
|
+
handler(target, startX, startY);
|
|
131
|
+
}, LONG_PRESS_MS);
|
|
132
|
+
}, { passive: true });
|
|
133
|
+
root.addEventListener('pointermove', (event) => {
|
|
134
|
+
if (Math.abs(event.clientX - startX) > LONG_PRESS_SLOP_PX || Math.abs(event.clientY - startY) > LONG_PRESS_SLOP_PX) cancel();
|
|
135
|
+
}, { passive: true });
|
|
136
|
+
for (const name of ['pointerup', 'pointercancel', 'pointerleave']) {
|
|
137
|
+
root.addEventListener(name, cancel, { passive: true });
|
|
138
|
+
}
|
|
139
|
+
return cancel;
|
|
140
|
+
}
|
|
@@ -55,6 +55,10 @@ export function buildContextTierOptions({
|
|
|
55
55
|
const meta = metadata && typeof metadata === 'object' ? metadata : {};
|
|
56
56
|
const defaultLabel = tokenLabel(defaultWindowTokens(meta));
|
|
57
57
|
const provider = String(providerType || '').trim().toLowerCase();
|
|
58
|
+
// A cloud session has no tier to choose and its window is not in the merged
|
|
59
|
+
// metadata either (that number is Copilot's): one blank default, as for a
|
|
60
|
+
// Claude model whose window is unknown.
|
|
61
|
+
if (provider === 'claude-cloud') return [{ value: 'default', label: UNKNOWN_WINDOW_LABEL }];
|
|
58
62
|
const claudeTierList = provider === 'claude' ? claudeTiersForModel(modelId, claudeTiers) : null;
|
|
59
63
|
if (claudeTierList) {
|
|
60
64
|
return claudeTierList
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The header's "via agent · “title”" line: the conversation on screen was
|
|
2
|
+
// started by an agent in another conversation on this relay, and the line
|
|
3
|
+
// opens that conversation. Derived from the conversation record alone, so the
|
|
4
|
+
// one sync function is idempotent and runs with every header sync. A
|
|
5
|
+
// conversation another relay's agent created has no header line (its sidebar
|
|
6
|
+
// marker is all there is), and neither has one a person started.
|
|
7
|
+
|
|
8
|
+
import { conversations, currentConvId, IS_SHARED_VIEW } from './store.js';
|
|
9
|
+
import { renderConversationHeaderOriginHtml } from './remote-relay-origin-view.mjs';
|
|
10
|
+
|
|
11
|
+
let renderedHtml = '';
|
|
12
|
+
|
|
13
|
+
export function syncConversationOriginHeader() {
|
|
14
|
+
const line = document.getElementById('chat-title-origin');
|
|
15
|
+
if (!line) return;
|
|
16
|
+
const id = String(currentConvId || '').trim();
|
|
17
|
+
const conversation = id ? (conversations[id] || null) : null;
|
|
18
|
+
const html = conversation
|
|
19
|
+
? renderConversationHeaderOriginHtml(conversation, { conversations, linkable: !IS_SHARED_VIEW })
|
|
20
|
+
: '';
|
|
21
|
+
// This runs on every list render; the button must not be rebuilt under a tap.
|
|
22
|
+
if (html !== renderedHtml) {
|
|
23
|
+
line.innerHTML = html;
|
|
24
|
+
renderedHtml = html;
|
|
25
|
+
}
|
|
26
|
+
line.hidden = !html;
|
|
27
|
+
}
|
|
@@ -23,23 +23,39 @@ export function isConversationUsingGrokProvider(conversation = null) {
|
|
|
23
23
|
return resolveConversationProviderType(conversation) === 'grok';
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
26
|
+
// Its own provider, never a flavour of `claude`: the session runs in a sandbox
|
|
27
|
+
// at Anthropic, so nothing that is true of a local Claude worker (modes,
|
|
28
|
+
// effort, steering, a working directory) may be assumed for it.
|
|
29
|
+
export function isConversationUsingClaudeCloudProvider(conversation = null) {
|
|
30
|
+
return resolveConversationProviderType(conversation) === 'claude-cloud';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The marker that tells a cloud chat from a local Claude one at a glance.
|
|
34
|
+
const CLAUDE_CLOUD_MARKER = '☁';
|
|
35
|
+
|
|
36
|
+
function conversationProviderName(conversation = null) {
|
|
29
37
|
const providerType = resolveConversationProviderType(conversation);
|
|
30
38
|
if (providerType === 'openai') return 'OpenAI';
|
|
31
39
|
if (providerType === 'claude') return 'Claude';
|
|
40
|
+
if (providerType === 'claude-cloud') return 'Claude Cloud';
|
|
32
41
|
if (providerType === 'cursor') return 'Cursor';
|
|
33
42
|
if (providerType === 'grok') return 'Grok';
|
|
34
43
|
if (providerType === 'github' || providerType === 'github-copilot') return 'Copilot';
|
|
35
44
|
return '';
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
// An unresolved provider stays unlabelled rather than defaulting to Copilot, so
|
|
48
|
+
// a conversation the client has not loaded yet cannot claim the wrong provider.
|
|
49
|
+
export function conversationProviderIndicatorLabel(conversation = null) {
|
|
50
|
+
const name = conversationProviderName(conversation);
|
|
51
|
+
return isConversationUsingClaudeCloudProvider(conversation) ? `${CLAUDE_CLOUD_MARKER} ${name}` : name;
|
|
52
|
+
}
|
|
53
|
+
|
|
38
54
|
// A question card names the runtime that asks, the way the list pill names it.
|
|
39
55
|
// Unresolved reads plain "Question": every card used to say "Copilot question",
|
|
40
56
|
// Claude's and Cursor's included.
|
|
41
57
|
export function questionCardHeadLabel(conversation = null) {
|
|
42
|
-
const provider =
|
|
58
|
+
const provider = conversationProviderName(conversation);
|
|
43
59
|
return provider ? `${provider} question` : 'Question';
|
|
44
60
|
}
|
|
45
61
|
|
|
@@ -50,6 +66,7 @@ const SESSION_LOCK_LABELS = {
|
|
|
50
66
|
claude: 'Claude SDK',
|
|
51
67
|
cursor: 'Cursor SDK',
|
|
52
68
|
grok: 'Grok',
|
|
69
|
+
'claude-cloud': `${CLAUDE_CLOUD_MARKER} Claude Cloud`,
|
|
53
70
|
};
|
|
54
71
|
|
|
55
72
|
export function isOpenAIImageModelId(modelId = '') {
|
|
@@ -66,6 +83,7 @@ export function sessionLockProviderKey({ providerType = '', model = '' } = {}) {
|
|
|
66
83
|
}
|
|
67
84
|
if (normalized === 'openai-image' || normalized === 'openai-image-byok') return 'openai-image';
|
|
68
85
|
if (normalized === 'claude' || normalized === 'claude-agent-sdk' || normalized === 'anthropic') return 'claude';
|
|
86
|
+
if (normalized === 'claude-cloud') return 'claude-cloud';
|
|
69
87
|
if (normalized === 'cursor') return 'cursor';
|
|
70
88
|
if (normalized === 'grok' || normalized === 'xai' || normalized === 'xai-grok') return 'grok';
|
|
71
89
|
if (normalized === 'github' || normalized === 'github-copilot') return 'github';
|
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
closeSummaryModal,
|
|
50
50
|
} from './store.js';
|
|
51
51
|
import { getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
52
|
-
import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility } from './api-client.js';
|
|
52
|
+
import { sendMessage as sendMessageApi, cancelConversationTurn, cancelQueuedConversationTurn, cancelSubagentRun, compactConversation as compactConversationApi, scheduleContextUsageRefresh, loadConversation as loadConversationApi, loadSharedConversation, updateConversationDraft as updateConversationDraftApi, updateMessageShareVisibility, launchSessionWorker } from './api-client.js';
|
|
53
53
|
import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
|
|
54
54
|
import { linkifyWorkspaceMentionsInNode, parseAppFileHref, renderMarkdownPreview, rewriteLocalAssetUrlsInNode } from './router.js';
|
|
55
55
|
import { renderAttachmentMarkup, removeComposerAttachments, uploadAttachments, setComposerAttachments, setRepoBrowserSessionInfo, openDriveFilePreview, openWorkspaceFilePreview, openUploadedAttachmentViewer } from './attachments-view.js';
|
|
@@ -91,6 +91,7 @@ import {
|
|
|
91
91
|
} from './activity-replay-state.mjs';
|
|
92
92
|
import { SEPARATOR_CLASS, syncSeparatorRail, syncTranscriptSeparators } from './transcript-separators.mjs';
|
|
93
93
|
import { ABSORBED_MSG_CLASS, STEER_MARKER_CLASS_BY_KIND, syncSteeredTurnMerge } from './steered-turn-merge.mjs';
|
|
94
|
+
import { resolveSubagentFold, subagentFoldSummary, toggledSubagentFoldChoice } from './subagent-fold.mjs';
|
|
94
95
|
import { deriveComposerControlState, hasComposerDraft, hasUploadingAttachments } from './composer-control-state.mjs';
|
|
95
96
|
import { buildLiveMessageFingerprint } from './live-message-dedupe.mjs';
|
|
96
97
|
import { createInfiniteLoader } from './infinite-loader.js';
|
|
@@ -494,7 +495,7 @@ function cancellableSteerIdsForConversation(conversationId) {
|
|
|
494
495
|
// and why. Two sources, freshest first: the client's own knowledge of an open
|
|
495
496
|
// question card (instant — the worker's heartbeat snapshot lags up to ~10s),
|
|
496
497
|
// then the worker-reported steering snapshot for the slower holds only it can
|
|
497
|
-
// see (compaction, post-compaction adoption).
|
|
498
|
+
// see (compaction, post-compaction adoption, a running tool call).
|
|
498
499
|
function steeringHoldForConversation(conversationId) {
|
|
499
500
|
const conversationKey = String(conversationId || '').trim();
|
|
500
501
|
if (!conversationKey || !conversationSupportsSteering(conversationKey)) {
|
|
@@ -545,6 +546,7 @@ function syncSendButtonState() {
|
|
|
545
546
|
steeringSupported: conversationSupportsSteering(currentConvId),
|
|
546
547
|
steeringHeld: steeringHold.held,
|
|
547
548
|
steeringHoldReason: steeringHold.reason,
|
|
549
|
+
archived: conversations[String(currentConvId || '').trim()]?.archived === true,
|
|
548
550
|
});
|
|
549
551
|
btn.disabled = state.disabled;
|
|
550
552
|
btn.dataset.action = state.action;
|
|
@@ -1282,6 +1284,15 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1282
1284
|
const steerMarkerClass = msg.role === 'assistant' ? STEER_MARKER_CLASS_BY_KIND[messageKind] : null;
|
|
1283
1285
|
if (steerMarkerClass) div.classList.add(steerMarkerClass);
|
|
1284
1286
|
const isStoppedSteer = msg.role === 'assistant' && messageKind === 'stopped';
|
|
1287
|
+
// The relay's note while this session's worker cannot be started
|
|
1288
|
+
// (worker-launch-note-service.mjs), rewritten in place as the episode goes
|
|
1289
|
+
// on. Once the relay has stopped trying, the note carries Retry: a launch
|
|
1290
|
+
// for once the cause is fixed.
|
|
1291
|
+
const isWorkerLaunchNote = msg.role === 'assistant' && messageKind.startsWith('worker-launch-');
|
|
1292
|
+
if (isWorkerLaunchNote) div.classList.add('msg-worker-launch', `msg-${messageKind}`);
|
|
1293
|
+
const workerLaunchActionsHtml = (!IS_SHARED_VIEW && msgId && messageKind === 'worker-launch-stopped')
|
|
1294
|
+
? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn" data-action="retry-worker-launch" data-message-id="${escHtml(msgId)}" title="Start the worker for this conversation again now">Retry</button></div>`
|
|
1295
|
+
: '';
|
|
1285
1296
|
// A turn answered by a different provider than the conversation is bound to
|
|
1286
1297
|
// (e.g. the Copilot relay answering a Cursor conversation) must be visible,
|
|
1287
1298
|
// not silent: it ran on another plan than the header indicates.
|
|
@@ -1403,13 +1414,14 @@ function createMessageNode(msg, msgId = null, force = false) {
|
|
|
1403
1414
|
? `<div class="msg-bubble-actions"><button type="button" class="bubble-action-btn" data-action="resend-stopped-steer" data-message-id="${escHtml(msgId)}" title="Send this message again as a new turn"${resendState ? ' disabled' : ''}>${resendLabel}</button></div>`
|
|
1404
1415
|
: '';
|
|
1405
1416
|
|
|
1406
|
-
// Shared viewers get the badge without the link to the other relay
|
|
1417
|
+
// Shared viewers get the badge without the link to the other relay (or, for
|
|
1418
|
+
// an agent on this relay, to the conversation it runs in).
|
|
1407
1419
|
const originBadgeHtml = remoteOrigin
|
|
1408
|
-
? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW })
|
|
1420
|
+
? renderRemoteOriginBadgeHtml(remoteOrigin, { linkable: !IS_SHARED_VIEW, conversations })
|
|
1409
1421
|
: '';
|
|
1410
1422
|
|
|
1411
1423
|
div.innerHTML = `
|
|
1412
|
-
<div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}</div>
|
|
1424
|
+
<div class="${bubbleClass}">${shareVisibilityActionHtml}${thoughtsHtml}${originBadgeHtml}${content}${relayErrorCtaHtml}${attachmentHtml}${activityHtml}${subagentHtml}${workflowRunsHtml}${previewCardsHtml}${userBubbleActionsHtml}${stoppedSteerActionsHtml}${workerLaunchActionsHtml}</div>
|
|
1413
1425
|
<div class="msg-label">${label}${modelTag}${reasoningTag}${modeTag}${autoTag}${continuationTag}${crossProviderTag}${usageTurnTag}${usageRemainingTag}${usageStaleTag} · ${fmtDate(msg.timestamp)}</div>`;
|
|
1414
1426
|
|
|
1415
1427
|
const bubble = div.querySelector('.msg-bubble');
|
|
@@ -1766,6 +1778,8 @@ export function showThinking(messageId = null, autoScroll = true) {
|
|
|
1766
1778
|
anchorThinkingBubble(div, nextMessageId);
|
|
1767
1779
|
renderThinkingThoughts();
|
|
1768
1780
|
renderThinkingStream();
|
|
1781
|
+
// A board the turn has posted already lives in its live bubble.
|
|
1782
|
+
renderRelayBoards();
|
|
1769
1783
|
observeThinkingBubble(div);
|
|
1770
1784
|
if (getThinkingFollowMode()) applyThinkingFollow();
|
|
1771
1785
|
else if (autoScroll) scrollBottom();
|
|
@@ -1809,6 +1823,19 @@ function openStopTurnConfirmation(conversationId, messageId) {
|
|
|
1809
1823
|
}, { once: true });
|
|
1810
1824
|
}
|
|
1811
1825
|
|
|
1826
|
+
/** The Stop confirmation for a conversation's running turn, from outside the bubble (the sidebar menu). */
|
|
1827
|
+
export function openStopTurnConfirmationForConversation(conversationId) {
|
|
1828
|
+
const turn = getActiveTurnForConversation(conversationId);
|
|
1829
|
+
if (!turn?.messageId) return false;
|
|
1830
|
+
openStopTurnConfirmation(conversationId, turn.messageId);
|
|
1831
|
+
return true;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
/** Whether a turn of this conversation is running as far as this page knows. */
|
|
1835
|
+
export function conversationHasActiveTurn(conversationId) {
|
|
1836
|
+
return !!getActiveTurnForConversation(conversationId);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1812
1839
|
export function collapseThinkingThoughts() {
|
|
1813
1840
|
const panel = document.getElementById('thinking-thoughts');
|
|
1814
1841
|
if (!(panel instanceof HTMLDetailsElement)) return;
|
|
@@ -1885,6 +1912,7 @@ function renderSubagentStream(subagentRunId, text) {
|
|
|
1885
1912
|
}
|
|
1886
1913
|
box.hidden = false;
|
|
1887
1914
|
patchRenderedMarkdown(box, renderMarkdownPreview(value, false));
|
|
1915
|
+
refreshSubagentSummary(bubble);
|
|
1888
1916
|
}
|
|
1889
1917
|
|
|
1890
1918
|
function patchActivityList(box, expectedTexts, className) {
|
|
@@ -1932,6 +1960,7 @@ export function renderThinkingActivities() {
|
|
|
1932
1960
|
const runBox = bubble?.querySelector('.subagent-activity');
|
|
1933
1961
|
if (runBox && !selectionIntersectsNode(runBox)) {
|
|
1934
1962
|
patchActivityList(runBox, expected, 'subagent-activity-item');
|
|
1963
|
+
refreshSubagentSummary(bubble);
|
|
1935
1964
|
}
|
|
1936
1965
|
}
|
|
1937
1966
|
}
|
|
@@ -2046,6 +2075,7 @@ export function appendThinkingActivity(item, subagentRunId = null, autoScroll =
|
|
|
2046
2075
|
row.className = 'subagent-activity-item';
|
|
2047
2076
|
row.textContent = decorated;
|
|
2048
2077
|
activityBox.appendChild(row);
|
|
2078
|
+
refreshSubagentSummary(subagentBubble);
|
|
2049
2079
|
}
|
|
2050
2080
|
}
|
|
2051
2081
|
if (autoScroll) scrollBottom();
|
|
@@ -2125,6 +2155,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2125
2155
|
const status = getSubagentStatus(id);
|
|
2126
2156
|
updateSubagentBubbleStatus(bubble, status);
|
|
2127
2157
|
updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
|
|
2158
|
+
applySubagentFold(id);
|
|
2128
2159
|
return bubble;
|
|
2129
2160
|
}
|
|
2130
2161
|
|
|
@@ -2136,8 +2167,19 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2136
2167
|
bubble.className = 'subagent-bubble';
|
|
2137
2168
|
bubble.dataset.subagentRunId = id;
|
|
2138
2169
|
|
|
2170
|
+
// The whole header folds and unfolds the bubble (a phone needs the large
|
|
2171
|
+
// target); the Stop button inside it keeps its own click.
|
|
2139
2172
|
const header = document.createElement('div');
|
|
2140
2173
|
header.className = 'subagent-bubble-header';
|
|
2174
|
+
header.dataset.action = 'toggle-subagent';
|
|
2175
|
+
header.dataset.subagentRunId = id;
|
|
2176
|
+
header.setAttribute('role', 'button');
|
|
2177
|
+
header.tabIndex = 0;
|
|
2178
|
+
|
|
2179
|
+
const foldMark = document.createElement('span');
|
|
2180
|
+
foldMark.className = 'subagent-bubble-fold';
|
|
2181
|
+
foldMark.setAttribute('aria-hidden', 'true');
|
|
2182
|
+
header.appendChild(foldMark);
|
|
2141
2183
|
|
|
2142
2184
|
const nameSpan = document.createElement('span');
|
|
2143
2185
|
nameSpan.className = 'subagent-bubble-name';
|
|
@@ -2151,6 +2193,10 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2151
2193
|
? '● Running'
|
|
2152
2194
|
: normalizeSubagentBubbleStatus(status).charAt(0).toUpperCase() + normalizeSubagentBubbleStatus(status).slice(1);
|
|
2153
2195
|
|
|
2196
|
+
// What the run is doing, in one line, for the header of a folded bubble.
|
|
2197
|
+
const summarySpan = document.createElement('span');
|
|
2198
|
+
summarySpan.className = 'subagent-bubble-summary';
|
|
2199
|
+
|
|
2154
2200
|
const controls = document.createElement('div');
|
|
2155
2201
|
controls.className = 'subagent-bubble-controls';
|
|
2156
2202
|
controls.appendChild(statusSpan);
|
|
@@ -2163,6 +2209,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2163
2209
|
controls.appendChild(stopBtn);
|
|
2164
2210
|
|
|
2165
2211
|
header.appendChild(nameSpan);
|
|
2212
|
+
header.appendChild(summarySpan);
|
|
2166
2213
|
header.appendChild(controls);
|
|
2167
2214
|
|
|
2168
2215
|
const activityBox = document.createElement('div');
|
|
@@ -2176,6 +2223,7 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2176
2223
|
bubble.appendChild(activityBox);
|
|
2177
2224
|
container.appendChild(bubble);
|
|
2178
2225
|
updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
|
|
2226
|
+
applySubagentFold(id);
|
|
2179
2227
|
|
|
2180
2228
|
const entry = getSubagentRun(id);
|
|
2181
2229
|
if (entry?.thoughts?.length) {
|
|
@@ -2205,10 +2253,22 @@ function ensureSubagentBubble(subagentRunId, depth = 0) {
|
|
|
2205
2253
|
if (entry?.streamText) {
|
|
2206
2254
|
renderSubagentStream(id, entry.streamText);
|
|
2207
2255
|
}
|
|
2256
|
+
refreshSubagentSummary(bubble);
|
|
2208
2257
|
|
|
2209
2258
|
return bubble;
|
|
2210
2259
|
}
|
|
2211
2260
|
|
|
2261
|
+
/** Bring the folded header's line up to date with what the bubble holds. */
|
|
2262
|
+
function refreshSubagentSummary(bubble) {
|
|
2263
|
+
const summary = bubble?.querySelector(':scope > .subagent-bubble-header > .subagent-bubble-summary');
|
|
2264
|
+
if (!summary) return;
|
|
2265
|
+
const text = subagentFoldSummary({
|
|
2266
|
+
activities: Array.from(bubble.querySelectorAll(':scope > .subagent-activity > .subagent-activity-item'), (row) => row.textContent || ''),
|
|
2267
|
+
streamText: bubble.querySelector(':scope > .subagent-stream')?.textContent || '',
|
|
2268
|
+
});
|
|
2269
|
+
if (summary.textContent !== text) summary.textContent = text;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2212
2272
|
function updateSubagentBubbleStatus(bubble, status) {
|
|
2213
2273
|
if (!bubble) return;
|
|
2214
2274
|
const statusSpan = bubble.querySelector('.subagent-bubble-status');
|
|
@@ -2218,6 +2278,38 @@ function updateSubagentBubbleStatus(bubble, status) {
|
|
|
2218
2278
|
statusSpan.textContent = normalized === 'running' ? '● Running' : normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
|
2219
2279
|
}
|
|
2220
2280
|
|
|
2281
|
+
// What the user chose by hand for a live subagent bubble ('open' | 'folded'),
|
|
2282
|
+
// by run id. Kept outside the DOM: the live bubble is rebuilt on a
|
|
2283
|
+
// conversation switch and on reload-restore, and the choice must survive it.
|
|
2284
|
+
const subagentFoldChoices = new Map();
|
|
2285
|
+
const MAX_SUBAGENT_FOLD_CHOICES = 500;
|
|
2286
|
+
|
|
2287
|
+
/** Show a live subagent bubble folded or open (see subagent-fold.mjs). */
|
|
2288
|
+
function applySubagentFold(subagentRunId) {
|
|
2289
|
+
const id = String(subagentRunId || '').trim();
|
|
2290
|
+
if (!id) return;
|
|
2291
|
+
const bubble = document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(id)}"]`);
|
|
2292
|
+
if (!bubble) return;
|
|
2293
|
+
const folded = resolveSubagentFold({ choice: subagentFoldChoices.get(id) || null });
|
|
2294
|
+
bubble.classList.toggle('folded', folded);
|
|
2295
|
+
const header = bubble.querySelector(':scope > .subagent-bubble-header');
|
|
2296
|
+
if (header) {
|
|
2297
|
+
header.setAttribute('aria-expanded', folded ? 'false' : 'true');
|
|
2298
|
+
header.title = folded ? 'Show what this subagent did' : 'Fold this subagent';
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
function toggleSubagentFold(subagentRunId) {
|
|
2303
|
+
const id = String(subagentRunId || '').trim();
|
|
2304
|
+
const bubble = id ? document.querySelector(`.subagent-bubble[data-subagent-run-id="${CSS.escape(id)}"]`) : null;
|
|
2305
|
+
if (!bubble) return;
|
|
2306
|
+
while (subagentFoldChoices.size >= MAX_SUBAGENT_FOLD_CHOICES) {
|
|
2307
|
+
subagentFoldChoices.delete(subagentFoldChoices.keys().next().value);
|
|
2308
|
+
}
|
|
2309
|
+
subagentFoldChoices.set(id, toggledSubagentFoldChoice(bubble.classList.contains('folded')));
|
|
2310
|
+
applySubagentFold(id);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2221
2313
|
const subagentStopUnsupported = new Set();
|
|
2222
2314
|
|
|
2223
2315
|
/** The provider answered "not supported": the control must not re-arm. */
|
|
@@ -2238,6 +2330,11 @@ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverr
|
|
|
2238
2330
|
btn.disabled = true;
|
|
2239
2331
|
return;
|
|
2240
2332
|
}
|
|
2333
|
+
const status = normalizeSubagentBubbleStatus(statusOverride || getSubagentStatus(id));
|
|
2334
|
+
const terminal = isSubagentTerminalStatus(status);
|
|
2335
|
+
// Nothing is left to stop once the run has ended; a disabled button there
|
|
2336
|
+
// only took the room the name needs.
|
|
2337
|
+
btn.hidden = terminal;
|
|
2241
2338
|
if (subagentStopUnsupported.has(id)) {
|
|
2242
2339
|
btn.disabled = true;
|
|
2243
2340
|
btn.textContent = 'Stop unavailable';
|
|
@@ -2245,8 +2342,6 @@ function updateSubagentStopButton(subagentRunId, isStopping = false, statusOverr
|
|
|
2245
2342
|
btn.classList.remove('stopping');
|
|
2246
2343
|
return;
|
|
2247
2344
|
}
|
|
2248
|
-
const status = normalizeSubagentBubbleStatus(statusOverride || getSubagentStatus(id));
|
|
2249
|
-
const terminal = isSubagentTerminalStatus(status);
|
|
2250
2345
|
const stopping = !!isStopping;
|
|
2251
2346
|
btn.disabled = terminal || stopping;
|
|
2252
2347
|
btn.textContent = stopping ? 'Stopping…' : 'Stop';
|
|
@@ -2265,6 +2360,7 @@ export function updateSubagentBubbleFromStatus(subagentRunId, status) {
|
|
|
2265
2360
|
clearSubagentCancelInFlight(id);
|
|
2266
2361
|
}
|
|
2267
2362
|
updateSubagentStopButton(id, isSubagentCancelInFlight(id), status);
|
|
2363
|
+
applySubagentFold(id);
|
|
2268
2364
|
}
|
|
2269
2365
|
|
|
2270
2366
|
function renderSubagentBubbleRecursive(entry) {
|
|
@@ -2701,6 +2797,55 @@ function releaseStoppedSteerResendsOf(messageId) {
|
|
|
2701
2797
|
}
|
|
2702
2798
|
}
|
|
2703
2799
|
|
|
2800
|
+
// Retry on the worker-launch note: ask the relay for a launch now. The relay
|
|
2801
|
+
// rewrites the note itself — the worker started, or the launches are
|
|
2802
|
+
// exhausted again — so the button only has to report a refused request.
|
|
2803
|
+
async function retryWorkerLaunch(conversationId, noteId) {
|
|
2804
|
+
const conversationKey = String(conversationId || '').trim();
|
|
2805
|
+
const id = String(noteId || '').trim();
|
|
2806
|
+
if (!conversationKey || !id) return;
|
|
2807
|
+
const btn = document.querySelector(`.bubble-action-btn[data-message-id="${CSS.escape(id)}"][data-action="retry-worker-launch"]`);
|
|
2808
|
+
if (btn?.disabled) return;
|
|
2809
|
+
const sdkSessionId = String(conversations[conversationKey]?.sdkSessionId || '').trim();
|
|
2810
|
+
if (!sdkSessionId) {
|
|
2811
|
+
showTransientRelayNotice('This conversation has no session to start a worker for.');
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
if (btn) {
|
|
2815
|
+
btn.disabled = true;
|
|
2816
|
+
btn.textContent = 'Retrying…';
|
|
2817
|
+
}
|
|
2818
|
+
try {
|
|
2819
|
+
// apiFetch answers null for a refused request (the route says 409 with
|
|
2820
|
+
// the reason) as for a network failure: both re-arm the button.
|
|
2821
|
+
const result = await launchSessionWorker(sdkSessionId);
|
|
2822
|
+
if (!result || result.ok === false) throw new Error(String(result?.error || 'the relay refused the launch'));
|
|
2823
|
+
} catch (error) {
|
|
2824
|
+
showTransientRelayNotice(`The worker could not be started: ${error?.message || error}`);
|
|
2825
|
+
if (btn) {
|
|
2826
|
+
btn.disabled = false;
|
|
2827
|
+
btn.textContent = 'Retry';
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
// A message the relay rewrote in place (the worker-launch note): the bubble is
|
|
2833
|
+
// rebuilt where it stands, so the transcript keeps its order. False when the
|
|
2834
|
+
// message is not on the page.
|
|
2835
|
+
export function replaceRenderedMessage(msg, msgId) {
|
|
2836
|
+
const el = getMessagesElement();
|
|
2837
|
+
const id = String(msgId || '').trim();
|
|
2838
|
+
if (!el || !id) return false;
|
|
2839
|
+
const existing = el.querySelector(`[data-message-id="${CSS.escape(id)}"]`);
|
|
2840
|
+
if (!existing) return false;
|
|
2841
|
+
const placeholder = document.createComment('replacing');
|
|
2842
|
+
existing.replaceWith(placeholder);
|
|
2843
|
+
seenMessageIds.delete(id);
|
|
2844
|
+
const fresh = createMessageNode(msg, id, true);
|
|
2845
|
+
placeholder.replaceWith(fresh || existing);
|
|
2846
|
+
return Boolean(fresh);
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2704
2849
|
async function resendStoppedSteer(conversationId, markerId) {
|
|
2705
2850
|
const conversationKey = String(conversationId || '').trim();
|
|
2706
2851
|
const id = String(markerId || '').trim();
|
|
@@ -2992,7 +3137,7 @@ function runRelayErrorCta(cta) {
|
|
|
2992
3137
|
}
|
|
2993
3138
|
|
|
2994
3139
|
function handleBubbleActionClick(event) {
|
|
2995
|
-
const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn');
|
|
3140
|
+
const btn = event.target.closest('.bubble-action-btn, .msg-share-visibility-btn, .thinking-follow-btn, .subagent-bubble-header');
|
|
2996
3141
|
if (!btn) return;
|
|
2997
3142
|
const action = btn.dataset.action;
|
|
2998
3143
|
const messageId = btn.dataset.messageId;
|
|
@@ -3040,11 +3185,33 @@ function handleBubbleActionClick(event) {
|
|
|
3040
3185
|
void resendStoppedSteer(currentConvId, messageId);
|
|
3041
3186
|
}
|
|
3042
3187
|
|
|
3188
|
+
if (action === 'retry-worker-launch' && messageId) {
|
|
3189
|
+
event.preventDefault();
|
|
3190
|
+
event.stopPropagation();
|
|
3191
|
+
void retryWorkerLaunch(currentConvId, messageId);
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3043
3194
|
if (action === 'stop-subagent' && subagentRunId) {
|
|
3044
3195
|
event.preventDefault();
|
|
3045
3196
|
event.stopPropagation();
|
|
3046
3197
|
void cancelSubagentByRunId(currentConvId, subagentRunId);
|
|
3047
3198
|
}
|
|
3199
|
+
|
|
3200
|
+
if (action === 'toggle-subagent' && subagentRunId) {
|
|
3201
|
+
// A tap that ends a text selection inside the header is not a toggle.
|
|
3202
|
+
if (String(window.getSelection?.() || '').trim()) return;
|
|
3203
|
+
event.preventDefault();
|
|
3204
|
+
toggleSubagentFold(subagentRunId);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// Enter and Space on a focused subagent header, like the click.
|
|
3209
|
+
function handleBubbleActionKeydown(event) {
|
|
3210
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
3211
|
+
const header = event.target.closest?.('.subagent-bubble-header');
|
|
3212
|
+
if (!header || event.target !== header) return;
|
|
3213
|
+
event.preventDefault();
|
|
3214
|
+
toggleSubagentFold(header.dataset.subagentRunId);
|
|
3048
3215
|
}
|
|
3049
3216
|
|
|
3050
3217
|
// Clicking an inline-embedded image in a message bubble opens it in the file
|
|
@@ -3077,6 +3244,7 @@ export function initBubbleActionHandlers() {
|
|
|
3077
3244
|
const messagesEl = document.getElementById('messages');
|
|
3078
3245
|
if (!messagesEl) return;
|
|
3079
3246
|
messagesEl.addEventListener('click', handleBubbleActionClick);
|
|
3247
|
+
messagesEl.addEventListener('keydown', handleBubbleActionKeydown);
|
|
3080
3248
|
messagesEl.addEventListener('click', handleEmbeddedImageClick);
|
|
3081
3249
|
initThinkingFollow({ getCurrentConversationId: () => currentConvId });
|
|
3082
3250
|
}
|
|
@@ -3114,6 +3282,8 @@ export function appendMessage(msg, scroll = true, msgId = null, force = false, i
|
|
|
3114
3282
|
}
|
|
3115
3283
|
}
|
|
3116
3284
|
if (isNewNode && insertedNode) {
|
|
3285
|
+
// A reply takes over the board its turn posted (relay-board-view).
|
|
3286
|
+
if (msg?.role === 'assistant') renderRelayBoards();
|
|
3117
3287
|
syncSeparatorsNow();
|
|
3118
3288
|
if (scroll) scrollBottom();
|
|
3119
3289
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
buildKnownCwdOptions as buildKnownCwdOptionsFromInputs,
|
|
24
24
|
normalizeKnownCwdPath,
|
|
25
25
|
} from './known-cwd-options.mjs';
|
|
26
|
+
import { buildCloudLineModel, cloudHeaderLabel, isClaudeCloudConversation } from './claude-cloud-ui.mjs';
|
|
26
27
|
|
|
27
28
|
export { normalizeKnownCwdPath };
|
|
28
29
|
|
|
@@ -462,6 +463,17 @@ export function syncChatHeaderWorkspaceLabel() {
|
|
|
462
463
|
const labelEl = document.getElementById('chat-title-cwd');
|
|
463
464
|
if (!labelEl) return;
|
|
464
465
|
const convId = String(currentConvId || '').trim();
|
|
466
|
+
const conversation = convId ? (conversations?.[convId] || null) : null;
|
|
467
|
+
if (isClaudeCloudConversation(conversation)) {
|
|
468
|
+
// A cloud chat runs on a clone at Anthropic, so the line names the
|
|
469
|
+
// repository instead of a folder on the relay host.
|
|
470
|
+
const label = cloudHeaderLabel(conversation.cloud);
|
|
471
|
+
labelEl.hidden = !label;
|
|
472
|
+
labelEl.textContent = label;
|
|
473
|
+
if (label) labelEl.title = buildCloudLineModel(conversation.cloud)?.repoUrl || label;
|
|
474
|
+
else labelEl.removeAttribute('title');
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
465
477
|
const cwd = getSelectedConversationCurrentCwd();
|
|
466
478
|
if (!convId || !cwd) {
|
|
467
479
|
labelEl.hidden = true;
|