@oar-sh/oar 0.9.20 → 0.9.21
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 +212 -0
- package/README.md +13 -13
- package/bin/oar.js +2 -2
- package/package.json +1 -1
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
- package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
- package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
- package/server/claude-worker/claude-session-process.mjs +7 -0
- package/server/claude-worker/claude-session-worker-main.mjs +8 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
- package/server/cursor-worker/cursor-session-worker.mjs +12 -1
- package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
- package/server/grok-worker/grok-background-tasks.mjs +9 -0
- package/server/grok-worker/grok-session-worker.mjs +12 -1
- package/server/grok-worker/grok-turn-runner.mjs +3 -0
- package/server/grok-worker/grok-worker-steering.mjs +23 -0
- package/server/public/app/about-ui.js +1 -1
- package/server/public/app/about-view.mjs +19 -0
- package/server/public/app/action-confirmations.js +1 -1
- package/server/public/app/api-client.js +4 -2
- package/server/public/app/ask-user-view.js +20 -6
- package/server/public/app/attachments-view.js +1 -1
- package/server/public/app/back-guard.mjs +31 -5
- package/server/public/app/bootstrap.js +556 -68
- package/server/public/app/chat-actions-menu-state.mjs +139 -4
- package/server/public/app/chat-header-meta.mjs +27 -0
- package/server/public/app/claude-billing-toggle-ui.js +2 -1
- package/server/public/app/claude-cloud-conversation-ui.js +2 -2
- package/server/public/app/composer-settings-label.mjs +90 -0
- package/server/public/app/composer-settings-ui.js +442 -0
- package/server/public/app/context-menu.mjs +36 -5
- package/server/public/app/conversation-deep-link.mjs +15 -11
- package/server/public/app/conversation-group-drag.mjs +12 -2
- package/server/public/app/conversation-group-order.mjs +9 -2
- package/server/public/app/conversation-group-state.mjs +47 -10
- package/server/public/app/conversation-origin-ui.js +17 -1
- package/server/public/app/conversation-view.js +3 -0
- package/server/public/app/cwd-menu-placement.mjs +18 -6
- package/server/public/app/cwd-picker.js +36 -6
- package/server/public/app/emoji-view.js +1 -1
- package/server/public/app/font-scaling.js +26 -0
- package/server/public/app/header-priority-actions.mjs +234 -0
- package/server/public/app/hidden-messages-view.mjs +4 -0
- package/server/public/app/inbox-ui.js +18 -2
- package/server/public/app/journal-view.js +311 -58
- package/server/public/app/phone-landscape-ui.js +225 -0
- package/server/public/app/phone-landscape.mjs +99 -0
- package/server/public/app/phone-typing-state.mjs +104 -0
- package/server/public/app/pinned-messages-view.mjs +5 -0
- package/server/public/app/push-settings.js +2 -2
- package/server/public/app/pwa-install.js +24 -3
- package/server/public/app/relay-event-subscriptions-ui.js +71 -22
- package/server/public/app/relay-event-view.mjs +38 -0
- package/server/public/app/relay-restart-ui.js +2 -2
- package/server/public/app/relay-schedule-view.mjs +32 -6
- package/server/public/app/relay-schedules-ui.js +69 -20
- package/server/public/app/remote-relay-origin-view.mjs +41 -23
- package/server/public/app/session-history-ui.js +250 -0
- package/server/public/app/session-history.mjs +599 -0
- package/server/public/app/settings-modal.js +61 -6
- package/server/public/app/settings-tabs.js +12 -0
- package/server/public/app/share-state.js +1 -1
- package/server/public/app/shared-conversations-settings.js +2 -2
- package/server/public/app/socket-handlers.js +5 -2
- package/server/public/app/status-view.mjs +18 -2
- package/server/public/app/store.js +87 -16
- package/server/public/app/subagent-viewer.mjs +27 -2
- package/server/public/app/tab-strip-fade.mjs +101 -0
- package/server/public/app/tap-actions.js +66 -0
- package/server/public/app/usage-limit-ui.js +63 -2
- package/server/public/app/viewer-history.mjs +13 -2
- package/server/public/index.html +2249 -1590
- package/server/relay-tools.md +1 -0
- package/server/repositories/message-repository.mjs +23 -0
- package/server/repositories/session-repository.mjs +19 -0
- package/server/routes/claude-cloud-routes.mjs +19 -0
- package/server/routes/messages-routes.mjs +57 -1
- package/server/routes/sessions-routes.mjs +41 -7
- package/server/server-runtime.mjs +190 -40
- package/server/services/about-service.mjs +22 -53
- package/server/services/claude-billing-swap-service.mjs +7 -20
- package/server/services/claude-cloud-session-service.mjs +47 -5
- package/server/services/remote-relay-dispatcher.mjs +61 -14
- package/server/services/session-activity-tracker.mjs +1 -1
- package/server/services/session-launch-mutex-service.mjs +23 -0
- package/server/services/session-worker-registry-service.mjs +36 -0
- package/server/services/session-worker-websocket-service.mjs +40 -16
- package/server/services/worker-recycle-service.mjs +772 -0
- package/server/services/worker-replacement.mjs +197 -0
- package/shared/build-id.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/simple-steering-state.mjs +38 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
- package/shared/worker-runtime/working-directory.mjs +1 -1
|
@@ -42,12 +42,24 @@ export function summarizeGroupMembers(states = []) {
|
|
|
42
42
|
export function groupSummaryText(summary) {
|
|
43
43
|
const count = Number(summary?.count || 0);
|
|
44
44
|
if (!count) return '';
|
|
45
|
+
const activity = groupActivityText(summary);
|
|
46
|
+
if (!activity) return groupSessionCountText(count);
|
|
47
|
+
return `${count} · ${activity}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** "1 waiting for you, 2 running", or "" while nothing waits, runs or failed. */
|
|
51
|
+
export function groupActivityText(summary) {
|
|
45
52
|
const parts = [];
|
|
46
|
-
if (summary
|
|
47
|
-
if (summary
|
|
48
|
-
if (summary
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
if (Number(summary?.waiting) > 0) parts.push(`${summary.waiting} waiting for you`);
|
|
54
|
+
if (Number(summary?.running) > 0) parts.push(`${summary.running} running`);
|
|
55
|
+
if (Number(summary?.error) > 0) parts.push(`${summary.error} failed`);
|
|
56
|
+
return parts.join(', ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** "1 session", "4 sessions". */
|
|
60
|
+
export function groupSessionCountText(count) {
|
|
61
|
+
const n = Number(count) || 0;
|
|
62
|
+
return `${n} session${n === 1 ? '' : 's'}`;
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
/** Whether a group has something going on: a member runs or waits. */
|
|
@@ -55,14 +67,39 @@ export function groupIsActive(summary) {
|
|
|
55
67
|
return Number(summary?.running || 0) > 0 || Number(summary?.waiting || 0) > 0;
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
/**
|
|
71
|
+
* A member's colour in its group's name: 'waiting', 'running', 'ready' (idle
|
|
72
|
+
* with a live worker, the darker green) or 'offline'. A failed member does
|
|
73
|
+
* not colour the name; the pill says so.
|
|
74
|
+
*/
|
|
75
|
+
export function memberTone({ memberState = 'idle', visualState = '' } = {}) {
|
|
76
|
+
if (memberState === 'waiting' || memberState === 'running') return memberState;
|
|
77
|
+
return String(visualState || '').trim().toLowerCase() === 'ready' ? 'ready' : 'offline';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The colour of a group's name from its members' tones: red while one waits
|
|
82
|
+
* for the user, bright green while one works, the darker green while one is
|
|
83
|
+
* ready, else the plain title colour. Returns 'question' | 'working' |
|
|
84
|
+
* 'ready' | 'offline'.
|
|
85
|
+
*/
|
|
86
|
+
export function groupTitleTone(tones = []) {
|
|
87
|
+
const list = Array.isArray(tones) ? tones : [];
|
|
88
|
+
if (list.includes('waiting')) return 'question';
|
|
89
|
+
if (list.includes('running')) return 'working';
|
|
90
|
+
if (list.includes('ready')) return 'ready';
|
|
91
|
+
return 'offline';
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
/**
|
|
59
95
|
* Open or folded. The user's choice ('open' | 'folded') wins; otherwise a
|
|
60
|
-
* group is open while active and folded once everything is finished.
|
|
61
|
-
* group
|
|
62
|
-
*
|
|
96
|
+
* group is open while active and folded once everything is finished. Every
|
|
97
|
+
* group under a title filter is always open. A folded group that holds the
|
|
98
|
+
* open conversation still shows that row (arrangeConversationList's
|
|
99
|
+
* keepVisibleId).
|
|
63
100
|
*/
|
|
64
|
-
export function resolveGroupFold({ choice = null, active = false,
|
|
65
|
-
if (
|
|
101
|
+
export function resolveGroupFold({ choice = null, active = false, filterActive = false } = {}) {
|
|
102
|
+
if (filterActive) return true;
|
|
66
103
|
if (choice === 'open') return true;
|
|
67
104
|
if (choice === 'folded') return false;
|
|
68
105
|
return active === true;
|
|
@@ -6,7 +6,23 @@
|
|
|
6
6
|
// marker is all there is), and neither has one a person started.
|
|
7
7
|
|
|
8
8
|
import { conversations, currentConvId, IS_SHARED_VIEW } from './store.js';
|
|
9
|
-
import { renderConversationHeaderOriginHtml } from './remote-relay-origin-view.mjs';
|
|
9
|
+
import { conversationOriginInfo, renderConversationHeaderOriginHtml } from './remote-relay-origin-view.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The ︙ entry that stands in for the line where the meta line is hidden
|
|
13
|
+
* (phone landscape): `{ label: 'Started by an agent in “title”',
|
|
14
|
+
* conversationId }` while the line is a button that opens that conversation,
|
|
15
|
+
* else null.
|
|
16
|
+
*/
|
|
17
|
+
export function conversationOriginMenuEntry() {
|
|
18
|
+
if (IS_SHARED_VIEW) return null;
|
|
19
|
+
const id = String(currentConvId || '').trim();
|
|
20
|
+
const conversation = id ? (conversations[id] || null) : null;
|
|
21
|
+
if (!conversation) return null;
|
|
22
|
+
const info = conversationOriginInfo(conversation, { conversations, linkable: true });
|
|
23
|
+
if (!info || info.kind !== 'local' || !info.linkable || !info.conversationId) return null;
|
|
24
|
+
return { label: info.label, conversationId: info.conversationId };
|
|
25
|
+
}
|
|
10
26
|
|
|
11
27
|
let renderedHtml = '';
|
|
12
28
|
|
|
@@ -155,6 +155,7 @@ import { buildInFlightSnapshotKey } from './in-flight-snapshot.mjs';
|
|
|
155
155
|
import { computeStablePrefixLength, planListPatch } from './streaming-dom-patch.mjs';
|
|
156
156
|
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
157
157
|
import { conversationPayloadCache } from './conversation-payload-cache.mjs';
|
|
158
|
+
import { noteConversationShown } from './session-history-ui.js';
|
|
158
159
|
|
|
159
160
|
const CONVERSATION_HISTORY_PAGE_SIZE = 20;
|
|
160
161
|
const HISTORY_LOAD_MORE_ID = 'history-load-more';
|
|
@@ -4755,6 +4756,8 @@ export async function sendMessage() {
|
|
|
4755
4756
|
const adoptNewConversation = viewingSendConversation();
|
|
4756
4757
|
if (adoptNewConversation) {
|
|
4757
4758
|
setCurrentConv(r.conversationId);
|
|
4759
|
+
// The new chat gets its history entry, as one opened from the list does.
|
|
4760
|
+
noteConversationShown(r.conversationId);
|
|
4758
4761
|
composerConversationId = r.conversationId;
|
|
4759
4762
|
nameOwnSendConversation(send, r.conversationId);
|
|
4760
4763
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Kept DOM-free so they can run under `node --test` without a browser.
|
|
3
3
|
|
|
4
4
|
const FLIP_THRESHOLD_PX = 180;
|
|
5
|
+
const FALLBACK_WIDTH_PX = 260;
|
|
5
6
|
|
|
6
7
|
function clamp(value, min, max) {
|
|
7
8
|
if (max < min) return min;
|
|
@@ -11,17 +12,28 @@ function clamp(value, min, max) {
|
|
|
11
12
|
/**
|
|
12
13
|
* Decide where the fixed-position picker panel should sit relative to its trigger.
|
|
13
14
|
* Used on desktop only; the mobile layout is a bottom sheet driven purely by CSS.
|
|
15
|
+
*
|
|
16
|
+
* `gap`, `edge`, `minHeight`, `maxHeight` are design sizes at 100 %; `scale`
|
|
17
|
+
* is the UI zoom (font-scaling.js getUiScale) they are multiplied by, so the
|
|
18
|
+
* panel keeps its proportions next to the scaled stylesheet. The trigger rect
|
|
19
|
+
* and the viewport are measured, never scaled.
|
|
14
20
|
*/
|
|
15
21
|
export function resolveCwdMenuPlacement({
|
|
16
22
|
triggerRect,
|
|
17
23
|
viewportWidth = 0,
|
|
18
24
|
viewportHeight = 0,
|
|
19
25
|
panelHeight = 0,
|
|
20
|
-
gap = 6,
|
|
21
|
-
edge = 8,
|
|
22
|
-
minHeight = 140,
|
|
23
|
-
maxHeight = 300,
|
|
26
|
+
gap: gapBase = 6,
|
|
27
|
+
edge: edgeBase = 8,
|
|
28
|
+
minHeight: minHeightBase = 140,
|
|
29
|
+
maxHeight: maxHeightBase = 300,
|
|
30
|
+
scale = 1,
|
|
24
31
|
} = {}) {
|
|
32
|
+
const factor = Number.isFinite(Number(scale)) && Number(scale) > 0 ? Number(scale) : 1;
|
|
33
|
+
const gap = gapBase * factor;
|
|
34
|
+
const edge = edgeBase * factor;
|
|
35
|
+
const minHeight = minHeightBase * factor;
|
|
36
|
+
const maxHeight = maxHeightBase * factor;
|
|
25
37
|
const top = Number(triggerRect?.top) || 0;
|
|
26
38
|
const left = Number(triggerRect?.left) || 0;
|
|
27
39
|
const width = Math.max(0, Number(triggerRect?.width) || 0);
|
|
@@ -30,13 +42,13 @@ export function resolveCwdMenuPlacement({
|
|
|
30
42
|
|
|
31
43
|
const spaceBelow = Math.max(0, viewportHeight - bottom - gap - edge);
|
|
32
44
|
const spaceAbove = Math.max(0, top - gap - edge);
|
|
33
|
-
const flip = spaceBelow < FLIP_THRESHOLD_PX && spaceAbove > spaceBelow;
|
|
45
|
+
const flip = spaceBelow < FLIP_THRESHOLD_PX * factor && spaceAbove > spaceBelow;
|
|
34
46
|
const available = flip ? spaceAbove : spaceBelow;
|
|
35
47
|
|
|
36
48
|
const desiredHeight = panelHeight > 0 ? Math.min(panelHeight, maxHeight) : maxHeight;
|
|
37
49
|
const resolvedMaxHeight = clamp(Math.min(desiredHeight, available), Math.min(minHeight, available), maxHeight);
|
|
38
50
|
|
|
39
|
-
const resolvedWidth = width > 0 ? width :
|
|
51
|
+
const resolvedWidth = width > 0 ? width : FALLBACK_WIDTH_PX * factor;
|
|
40
52
|
const maxLeft = Math.max(edge, viewportWidth - resolvedWidth - edge);
|
|
41
53
|
const resolvedLeft = clamp(left, edge, maxLeft);
|
|
42
54
|
const resolvedTop = flip
|
|
@@ -19,11 +19,13 @@ import {
|
|
|
19
19
|
resolveCwdMenuPlacement,
|
|
20
20
|
resolveTypeaheadIndex,
|
|
21
21
|
} from './cwd-menu-placement.mjs';
|
|
22
|
+
import { FONT_SCALE_CHANGE_EVENT, getUiScale } from './font-scaling.js';
|
|
22
23
|
import {
|
|
23
24
|
buildKnownCwdOptions as buildKnownCwdOptionsFromInputs,
|
|
24
25
|
normalizeKnownCwdPath,
|
|
25
26
|
} from './known-cwd-options.mjs';
|
|
26
27
|
import { buildCloudLineModel, cloudHeaderLabel, isClaudeCloudConversation } from './claude-cloud-ui.mjs';
|
|
28
|
+
import { CHAT_META_LONG_CLASS, CHAT_META_SHORT_CLASS, cwdFolderName } from './chat-header-meta.mjs';
|
|
27
29
|
|
|
28
30
|
export { normalizeKnownCwdPath };
|
|
29
31
|
|
|
@@ -163,6 +165,7 @@ function positionChangeCwdMenu() {
|
|
|
163
165
|
viewportWidth,
|
|
164
166
|
viewportHeight,
|
|
165
167
|
panelHeight: panel.scrollHeight,
|
|
168
|
+
scale: getUiScale(),
|
|
166
169
|
});
|
|
167
170
|
panel.style.left = `${Math.round(placement.left)}px`;
|
|
168
171
|
panel.style.top = `${Math.round(placement.top)}px`;
|
|
@@ -176,6 +179,8 @@ function bindRepositionListeners() {
|
|
|
176
179
|
// Capture phase so scrolling inside .summary-body (which does not bubble) counts.
|
|
177
180
|
window.addEventListener('scroll', positionChangeCwdMenu, true);
|
|
178
181
|
window.addEventListener('resize', positionChangeCwdMenu);
|
|
182
|
+
// A zoom moves the trigger and rescales the gap and the panel's sizes.
|
|
183
|
+
window.addEventListener(FONT_SCALE_CHANGE_EVENT, positionChangeCwdMenu);
|
|
179
184
|
window.visualViewport?.addEventListener?.('resize', positionChangeCwdMenu);
|
|
180
185
|
window.visualViewport?.addEventListener?.('scroll', positionChangeCwdMenu);
|
|
181
186
|
}
|
|
@@ -185,6 +190,7 @@ function unbindRepositionListeners() {
|
|
|
185
190
|
repositionBound = false;
|
|
186
191
|
window.removeEventListener('scroll', positionChangeCwdMenu, true);
|
|
187
192
|
window.removeEventListener('resize', positionChangeCwdMenu);
|
|
193
|
+
window.removeEventListener(FONT_SCALE_CHANGE_EVENT, positionChangeCwdMenu);
|
|
188
194
|
window.visualViewport?.removeEventListener?.('resize', positionChangeCwdMenu);
|
|
189
195
|
window.visualViewport?.removeEventListener?.('scroll', positionChangeCwdMenu);
|
|
190
196
|
}
|
|
@@ -231,7 +237,7 @@ function openChangeCwdMenu() {
|
|
|
231
237
|
bindRepositionListeners();
|
|
232
238
|
}
|
|
233
239
|
|
|
234
|
-
function closeChangeCwdMenu({ focusTrigger = false } = {}) {
|
|
240
|
+
export function closeChangeCwdMenu({ focusTrigger = false } = {}) {
|
|
235
241
|
const { trigger, panel, backdrop } = getPickerEls();
|
|
236
242
|
menuOpen = false;
|
|
237
243
|
typeaheadBuffer = '';
|
|
@@ -470,6 +476,7 @@ export function syncChatHeaderWorkspaceLabel() {
|
|
|
470
476
|
const label = cloudHeaderLabel(conversation.cloud);
|
|
471
477
|
labelEl.hidden = !label;
|
|
472
478
|
labelEl.textContent = label;
|
|
479
|
+
labelEl.classList.remove('chat-title-cwd-expanded');
|
|
473
480
|
if (label) labelEl.title = buildCloudLineModel(conversation.cloud)?.repoUrl || label;
|
|
474
481
|
else labelEl.removeAttribute('title');
|
|
475
482
|
return;
|
|
@@ -479,11 +486,34 @@ export function syncChatHeaderWorkspaceLabel() {
|
|
|
479
486
|
labelEl.hidden = true;
|
|
480
487
|
labelEl.textContent = '';
|
|
481
488
|
labelEl.removeAttribute('title');
|
|
489
|
+
labelEl.classList.remove('chat-title-cwd-expanded');
|
|
482
490
|
return;
|
|
483
491
|
}
|
|
484
492
|
labelEl.hidden = false;
|
|
485
|
-
labelEl.textContent = cwd;
|
|
486
493
|
labelEl.title = cwd;
|
|
494
|
+
// The whole path, and its last folder for the phone's meta row
|
|
495
|
+
// (chat-header-meta.mjs); a tap there shows the whole path.
|
|
496
|
+
const longEl = labelEl.querySelector(`.${CHAT_META_LONG_CLASS}`);
|
|
497
|
+
const shortEl = labelEl.querySelector(`.${CHAT_META_SHORT_CLASS}`);
|
|
498
|
+
const folder = cwdFolderName(cwd) || cwd;
|
|
499
|
+
if (longEl && shortEl && longEl.textContent === cwd && shortEl.textContent === folder) return;
|
|
500
|
+
if (longEl?.textContent !== cwd) labelEl.classList.remove('chat-title-cwd-expanded');
|
|
501
|
+
const long = document.createElement('span');
|
|
502
|
+
long.className = CHAT_META_LONG_CLASS;
|
|
503
|
+
long.textContent = cwd;
|
|
504
|
+
const short = document.createElement('span');
|
|
505
|
+
short.className = CHAT_META_SHORT_CLASS;
|
|
506
|
+
short.textContent = folder;
|
|
507
|
+
labelEl.replaceChildren(long, short);
|
|
508
|
+
if (labelEl.dataset.cwdToggleBound !== '1') {
|
|
509
|
+
labelEl.dataset.cwdToggleBound = '1';
|
|
510
|
+
labelEl.addEventListener('click', () => {
|
|
511
|
+
// Only the two-label folder line toggles (not a cloud repo or the
|
|
512
|
+
// status view's text); on a desktop the class changes nothing.
|
|
513
|
+
if (!labelEl.querySelector(`.${CHAT_META_SHORT_CLASS}`)) return;
|
|
514
|
+
labelEl.classList.toggle('chat-title-cwd-expanded');
|
|
515
|
+
});
|
|
516
|
+
}
|
|
487
517
|
}
|
|
488
518
|
|
|
489
519
|
function getCurrentLaunchableSessionId() {
|
|
@@ -513,14 +543,14 @@ export function openChangeCwdModal() {
|
|
|
513
543
|
subtitle: 'Select a known launch directory',
|
|
514
544
|
kind: 'change-cwd',
|
|
515
545
|
bodyHtml: `
|
|
516
|
-
<p style="margin-bottom:
|
|
546
|
+
<p style="margin-bottom:calc(5px * var(--ui-scale));color:var(--muted);line-height:1.45">
|
|
517
547
|
Pick the selected session's launch directory. An idle CLI restarts in the selected CWD; active turns must finish first.
|
|
518
548
|
</p>
|
|
519
|
-
<div style="display:grid;gap:4px;margin-bottom:
|
|
549
|
+
<div style="display:grid;gap:calc(4px * var(--ui-scale));margin-bottom:calc(5px * var(--ui-scale));font-size:0.78rem;color:var(--muted)">
|
|
520
550
|
<div><strong style="color:var(--text)">Current CWD:</strong> ${escHtml(currentCwd || 'Unknown')}</div>
|
|
521
551
|
<div><strong style="color:var(--text)">Next launch:</strong> ${escHtml(nextLaunchCwd || currentCwd || 'Unknown')}</div>
|
|
522
552
|
</div>
|
|
523
|
-
<div class="change-cwd-picker" style="margin-bottom:
|
|
553
|
+
<div class="change-cwd-picker" style="margin-bottom:calc(5px * var(--ui-scale))">
|
|
524
554
|
<label class="change-cwd-picker-label" for="change-cwd-manual-path">Manual path</label>
|
|
525
555
|
<div class="cwd-browse-row">
|
|
526
556
|
<input id="change-cwd-manual-path" class="change-cwd-manual-input" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="Manual path">
|
|
@@ -539,7 +569,7 @@ export function openChangeCwdModal() {
|
|
|
539
569
|
${menuItemsHtml}
|
|
540
570
|
</div>
|
|
541
571
|
</div>
|
|
542
|
-
<div id="change-cwd-details" style="margin-top:
|
|
572
|
+
<div id="change-cwd-details" style="margin-top:calc(5px * var(--ui-scale));font-size:0.78rem;color:var(--muted);line-height:1.45;word-break:break-word"></div>
|
|
543
573
|
<div class="summary-modal-actions" id="change-cwd-actions">
|
|
544
574
|
<button class="summary-btn" type="button" onclick="confirmChangeCwd()">🗂️ Save next-launch CWD</button>
|
|
545
575
|
<button class="summary-btn" type="button" ${launchableSessionId ? 'onclick="confirmChangeCwdAndLaunch()"' : 'disabled data-keep-disabled="1"'} title="${escHtml(launchDisabledReason || 'Set the CWD and (re)launch the current session worker')}">🚀 Set new CWD and (re)launch</button>
|
|
@@ -23,7 +23,7 @@ function updateEmojiButton() {
|
|
|
23
23
|
button.textContent = getLastEmoji();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function closeEmojiPicker() {
|
|
26
|
+
export function closeEmojiPicker() {
|
|
27
27
|
const popup = document.getElementById('emoji-popup');
|
|
28
28
|
if (!popup) return;
|
|
29
29
|
popup.classList.remove('visible');
|
|
@@ -8,6 +8,9 @@ const FONT_SCALE_WHEEL_STEP_MIN = 0.01;
|
|
|
8
8
|
const FONT_SCALE_WHEEL_STEP_MAX = 0.2;
|
|
9
9
|
const FONT_SCALE_PILL_VISIBLE_MS = 900;
|
|
10
10
|
|
|
11
|
+
/** Fired on window after --font-scale changed; detail: { scale }. */
|
|
12
|
+
export const FONT_SCALE_CHANGE_EVENT = 'oar:font-scale-change';
|
|
13
|
+
|
|
11
14
|
let fontScaleValue = FONT_SCALE_DEFAULT;
|
|
12
15
|
let fontScaleIndicatorTimer = null;
|
|
13
16
|
let fontScalePinchState = {
|
|
@@ -46,6 +49,25 @@ function readStoredFontScale() {
|
|
|
46
49
|
return clampFontScale(readLocalStorage(FONT_SCALE_STORAGE_KEY));
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The factor the stylesheet multiplies its px sizes by (--ui-scale, which
|
|
54
|
+
* follows --font-scale). JS that places popovers with px gaps and margins
|
|
55
|
+
* of its own multiplies them by this, so they match the scaled CSS. Read
|
|
56
|
+
* per call: the inline value (set before first paint and on every change),
|
|
57
|
+
* else the computed one; 1 outside a browser.
|
|
58
|
+
*/
|
|
59
|
+
export function getUiScale() {
|
|
60
|
+
if (typeof document === 'undefined' || !document.documentElement) return FONT_SCALE_DEFAULT;
|
|
61
|
+
let raw = '';
|
|
62
|
+
try { raw = document.documentElement.style.getPropertyValue('--font-scale'); } catch {}
|
|
63
|
+
if (!String(raw || '').trim() && typeof getComputedStyle === 'function') {
|
|
64
|
+
try { raw = getComputedStyle(document.documentElement).getPropertyValue('--font-scale'); } catch {}
|
|
65
|
+
}
|
|
66
|
+
const numeric = Number(String(raw || '').trim());
|
|
67
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return FONT_SCALE_DEFAULT;
|
|
68
|
+
return clampFontScale(numeric);
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
function normalizeFontScaleSelectValue(raw = '') {
|
|
50
72
|
const text = String(raw || '').trim();
|
|
51
73
|
if (!text) return null;
|
|
@@ -90,6 +112,10 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
|
|
|
90
112
|
document.documentElement.style.setProperty('--font-scale-percent', `${Math.round(normalized * 100)}%`);
|
|
91
113
|
if (persist) writeLocalStorage(FONT_SCALE_STORAGE_KEY, String(normalized));
|
|
92
114
|
syncFontScaleSelect();
|
|
115
|
+
// Layout that measures itself (the header's priority+ actions) re-runs.
|
|
116
|
+
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function' && typeof CustomEvent === 'function') {
|
|
117
|
+
window.dispatchEvent(new CustomEvent(FONT_SCALE_CHANGE_EVENT, { detail: { scale: normalized } }));
|
|
118
|
+
}
|
|
93
119
|
if (anchor) {
|
|
94
120
|
requestAnimationFrame(() => {
|
|
95
121
|
restoreTranscriptAnchor(document.getElementById('messages'), anchor);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// The chat header's priority+ actions: which icon buttons sit in the header
|
|
2
|
+
// and which fall back into the ︙ menu when the header runs out of room.
|
|
3
|
+
//
|
|
4
|
+
// Every action exists twice in the markup: an inline header button and a twin
|
|
5
|
+
// entry in #chat-actions-menu. Nothing moves in the DOM; a pass toggles
|
|
6
|
+
// `hidden` on the pair, so listeners are bound once and the menu keeps its
|
|
7
|
+
// order. This module is DOM-free: bootstrap.js measures, calls
|
|
8
|
+
// fitHeaderPriorityActions and writes the result.
|
|
9
|
+
//
|
|
10
|
+
// Order is priority: the first entry is the LAST to overflow. Once one action
|
|
11
|
+
// does not fit, it and every later one go into the menu, so a lower priority
|
|
12
|
+
// action never "jumps" a higher one back into the header.
|
|
13
|
+
|
|
14
|
+
export const HEADER_PRIORITY_ACTIONS = Object.freeze([
|
|
15
|
+
Object.freeze({ key: 'pins', inlineId: 'pinned-messages-btn', menuId: 'chat-menu-pins' }),
|
|
16
|
+
Object.freeze({ key: 'hidden', inlineId: 'hidden-messages-btn', menuId: 'chat-menu-hidden' }),
|
|
17
|
+
Object.freeze({ key: 'search', inlineId: 'message-search-btn', menuId: 'chat-menu-search' }),
|
|
18
|
+
Object.freeze({ key: 'context', inlineId: 'context-btn', menuId: 'chat-menu-context' }),
|
|
19
|
+
Object.freeze({ key: 'usage', inlineId: 'usage-btn', menuId: 'chat-menu-usage' }),
|
|
20
|
+
Object.freeze({ key: 'theme', inlineId: 'theme-toggle-btn', menuId: 'chat-menu-theme-toggle' }),
|
|
21
|
+
Object.freeze({ key: 'fullscreen', inlineId: 'fullscreen-btn', menuId: 'chat-menu-fullscreen' }),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export const HEADER_PRIORITY_ACTION_KEYS = Object.freeze(HEADER_PRIORITY_ACTIONS.map((action) => action.key));
|
|
25
|
+
|
|
26
|
+
/** The twin menu entries, in priority order. */
|
|
27
|
+
export const HEADER_PRIORITY_MENU_IDS = Object.freeze(HEADER_PRIORITY_ACTIONS.map((action) => action.menuId));
|
|
28
|
+
|
|
29
|
+
/** The width the title keeps before any action is shown inline, in rem. */
|
|
30
|
+
export const HEADER_TITLE_MIN_REM = 8;
|
|
31
|
+
|
|
32
|
+
/** The window event that asks for a new pass (counts, availability). */
|
|
33
|
+
export const HEADER_ACTIONS_CHANGE_EVENT = 'oar:header-actions-change';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where the pending-question banner has a row of its own (the phone's header
|
|
37
|
+
* CSS): there it takes nothing from the first row.
|
|
38
|
+
*/
|
|
39
|
+
export const HEADER_BANNER_OWN_ROW_QUERY = '(max-width: 680px)';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The share of the header's content width the question banner may take on
|
|
43
|
+
* the first row (its `max-width: 40%` in index.html): the title keeps the rest.
|
|
44
|
+
*/
|
|
45
|
+
export const HEADER_BANNER_MAX_SHARE = 0.4;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The width of everything on the header's first row that never overflows:
|
|
49
|
+
* its padding, ☰, the title's minimum, ︙, the question banner and the flex
|
|
50
|
+
* gaps between them. Widths of 0 are elements that do not show. The banner
|
|
51
|
+
* counts only while it shares the first row (`bannerOwnRow` false), and only
|
|
52
|
+
* with its compact width ("❓ N"): the rest of it gives way before an action
|
|
53
|
+
* does (resolveQuestionBannerMode).
|
|
54
|
+
*
|
|
55
|
+
* @param {object} input
|
|
56
|
+
* @param {number} [input.padding] left plus right padding
|
|
57
|
+
* @param {number} [input.titleMin] the width the title keeps
|
|
58
|
+
* @param {number} [input.toggle] ☰
|
|
59
|
+
* @param {number} [input.banner] the question banner's compact chip
|
|
60
|
+
* @param {number} [input.menu] ︙ (its wrapper)
|
|
61
|
+
* @param {number} [input.gap] the header's column gap
|
|
62
|
+
* @param {boolean} [input.bannerOwnRow] the banner sits on a row of its own
|
|
63
|
+
* @returns {number}
|
|
64
|
+
*/
|
|
65
|
+
export function headerReservedWidth({
|
|
66
|
+
padding = 0,
|
|
67
|
+
titleMin = 0,
|
|
68
|
+
toggle = 0,
|
|
69
|
+
banner = 0,
|
|
70
|
+
menu = 0,
|
|
71
|
+
gap = 0,
|
|
72
|
+
bannerOwnRow = false,
|
|
73
|
+
} = {}) {
|
|
74
|
+
const size = (value) => (Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : 0);
|
|
75
|
+
let width = size(padding) + size(titleMin) + size(menu);
|
|
76
|
+
let items = 2; // the title and #chat-header-actions
|
|
77
|
+
if (size(toggle)) {
|
|
78
|
+
width += size(toggle);
|
|
79
|
+
items += 1;
|
|
80
|
+
}
|
|
81
|
+
if (size(banner) && !bannerOwnRow) {
|
|
82
|
+
width += size(banner);
|
|
83
|
+
items += 1;
|
|
84
|
+
}
|
|
85
|
+
return width + size(gap) * (items - 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How the question banner shows once the actions have their places:
|
|
90
|
+
* - 'hidden': no banner;
|
|
91
|
+
* - 'row': a row of its own (a phone held upright, or a header whose first
|
|
92
|
+
* row cannot hold even the chip next to ☰, the title's minimum and ︙),
|
|
93
|
+
* the whole text;
|
|
94
|
+
* - 'compact': the chip "❓ N" on the first row (always on a phone on its
|
|
95
|
+
* side; elsewhere where the room cannot even hold "❓ N open questions");
|
|
96
|
+
* - 'full': the whole text on the first row, ending in an ellipsis where
|
|
97
|
+
* the room is short.
|
|
98
|
+
* The room is what the first row leaves it (the compact width the fit
|
|
99
|
+
* reserved plus whatever the inline actions did not take), capped at
|
|
100
|
+
* `maxShare` of the header's content width.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} input
|
|
103
|
+
* @param {boolean} [input.visible] the banner shows
|
|
104
|
+
* @param {boolean} [input.ownRow] it sits on a row of its own
|
|
105
|
+
* @param {boolean} [input.phoneLandscape] :root[data-phone-landscape]
|
|
106
|
+
* @param {number} [input.room] the width the first row leaves it
|
|
107
|
+
* @param {number} [input.compact] the chip's width: a room below it is 'row'
|
|
108
|
+
* @param {number} [input.contentWidth] the header's width minus its padding
|
|
109
|
+
* @param {number} [input.maxShare] HEADER_BANNER_MAX_SHARE
|
|
110
|
+
* @param {number} [input.fullMin] "❓ N open questions" with the banner's padding and border
|
|
111
|
+
* @returns {'hidden'|'row'|'compact'|'full'}
|
|
112
|
+
*/
|
|
113
|
+
export function resolveQuestionBannerMode({
|
|
114
|
+
visible = false,
|
|
115
|
+
ownRow = false,
|
|
116
|
+
phoneLandscape = false,
|
|
117
|
+
room = 0,
|
|
118
|
+
compact = 0,
|
|
119
|
+
contentWidth = Infinity,
|
|
120
|
+
maxShare = HEADER_BANNER_MAX_SHARE,
|
|
121
|
+
fullMin = 0,
|
|
122
|
+
} = {}) {
|
|
123
|
+
if (!visible) return 'hidden';
|
|
124
|
+
if (ownRow) return 'row';
|
|
125
|
+
if (phoneLandscape) return 'compact';
|
|
126
|
+
// Not even the chip fits on the first row: it would push ︙ off the
|
|
127
|
+
// screen, so the banner takes a row of its own (an unmeasured room does not).
|
|
128
|
+
if (Number.isFinite(Number(room)) && Number(room) < (Number(compact) || 0)) return 'row';
|
|
129
|
+
const share = Number(maxShare);
|
|
130
|
+
const width = Number(contentWidth);
|
|
131
|
+
const cap = Number.isFinite(share) && share > 0 && Number.isFinite(width) ? width * share : Infinity;
|
|
132
|
+
// An unmeasured room (NaN) is the chip: it is the width the fit reserved.
|
|
133
|
+
const space = Math.min(Number(room), cap);
|
|
134
|
+
return space >= Math.max(0, Number(fullMin) || 0) ? 'full' : 'compact';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether each action may show at all, in the header or in the menu. Distinct
|
|
139
|
+
* from the fit question, which is only about WHERE an available action shows.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} input
|
|
142
|
+
* @param {boolean} [input.sharedMode] the shared read-only view: Day/Night only
|
|
143
|
+
* @param {number} [input.pinCount] pins of the open conversation
|
|
144
|
+
* @param {number} [input.hiddenCount] messages hidden from shared viewers
|
|
145
|
+
* @param {boolean} [input.fullscreenAvailable] pwa-install.js's verdict (a phone, not installed)
|
|
146
|
+
* @returns {Record<string, boolean>}
|
|
147
|
+
*/
|
|
148
|
+
export function resolveHeaderActionAvailability({
|
|
149
|
+
sharedMode = false,
|
|
150
|
+
pinCount = 0,
|
|
151
|
+
hiddenCount = 0,
|
|
152
|
+
fullscreenAvailable = false,
|
|
153
|
+
} = {}) {
|
|
154
|
+
if (sharedMode) {
|
|
155
|
+
return { pins: false, hidden: false, search: false, context: false, usage: false, theme: true, fullscreen: false };
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
pins: Number(pinCount) > 0,
|
|
159
|
+
hidden: Number(hiddenCount) > 0,
|
|
160
|
+
search: true,
|
|
161
|
+
context: true,
|
|
162
|
+
usage: true,
|
|
163
|
+
theme: true,
|
|
164
|
+
fullscreen: !!fullscreenAvailable,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The fit decision. `available` is the width the inline actions may use in
|
|
170
|
+
* total; each inline action costs its width plus `gap`. An action whose width
|
|
171
|
+
* is unknown (not a finite number) counts as 0. An `available` that is not a
|
|
172
|
+
* finite number means the header could not be measured: every available
|
|
173
|
+
* action then goes into the menu, which always reaches all of them.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} input
|
|
176
|
+
* @param {Record<string, number>} input.widths the inline buttons' widths, by key
|
|
177
|
+
* @param {number} [input.gap] the gap after each inline button
|
|
178
|
+
* @param {number} input.available the width left for the inline actions
|
|
179
|
+
* @param {Record<string, boolean>} input.availability from resolveHeaderActionAvailability
|
|
180
|
+
* @param {string[]} [input.order] priority order (default: HEADER_PRIORITY_ACTION_KEYS)
|
|
181
|
+
* @returns {{ inline: string[], menu: string[], unavailable: string[], used: number }}
|
|
182
|
+
* `used` is the width the inline actions take, their gaps included
|
|
183
|
+
*/
|
|
184
|
+
export function fitHeaderPriorityActions({
|
|
185
|
+
widths = {},
|
|
186
|
+
gap = 0,
|
|
187
|
+
available,
|
|
188
|
+
availability = {},
|
|
189
|
+
order = HEADER_PRIORITY_ACTION_KEYS,
|
|
190
|
+
} = {}) {
|
|
191
|
+
const inline = [];
|
|
192
|
+
const menu = [];
|
|
193
|
+
const unavailable = [];
|
|
194
|
+
const room = Number(available);
|
|
195
|
+
const step = Math.max(0, Number(gap) || 0);
|
|
196
|
+
let overflowing = !Number.isFinite(room);
|
|
197
|
+
let used = 0;
|
|
198
|
+
let inlineUsed = 0;
|
|
199
|
+
for (const key of order) {
|
|
200
|
+
if (!availability[key]) {
|
|
201
|
+
// Spends no width, so it never pushes an available action out.
|
|
202
|
+
unavailable.push(key);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const width = Number(widths[key]);
|
|
206
|
+
used += (Number.isFinite(width) && width > 0 ? width : 0) + step;
|
|
207
|
+
if (!overflowing && used <= room) {
|
|
208
|
+
inline.push(key);
|
|
209
|
+
inlineUsed = used;
|
|
210
|
+
} else {
|
|
211
|
+
overflowing = true;
|
|
212
|
+
menu.push(key);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return { inline, menu, unavailable, used: inlineUsed };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The menu ids of the keys a fit put into the menu. */
|
|
219
|
+
export function headerPriorityMenuIdsFor(keys) {
|
|
220
|
+
const wanted = new Set(keys || []);
|
|
221
|
+
return HEADER_PRIORITY_ACTIONS.filter((action) => wanted.has(action.key)).map((action) => action.menuId);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Asks for a new pass. Views that change an action's availability (pin and
|
|
226
|
+
* hidden counts, fullscreen) call this; a no-op outside a browser.
|
|
227
|
+
*/
|
|
228
|
+
export function notifyHeaderActionsChanged() {
|
|
229
|
+
const target = typeof window !== 'undefined' ? window : null;
|
|
230
|
+
if (!target || typeof target.dispatchEvent !== 'function' || typeof CustomEvent !== 'function') return;
|
|
231
|
+
try {
|
|
232
|
+
target.dispatchEvent(new CustomEvent(HEADER_ACTIONS_CHANGE_EVENT));
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './store.js';
|
|
20
20
|
import { unhideAllMessagesFromShares, updateMessageShareVisibility } from './api-client.js';
|
|
21
21
|
import { jumpToPinnedMessage } from './pinned-messages-view.mjs';
|
|
22
|
+
import { notifyHeaderActionsChanged } from './header-priority-actions.mjs';
|
|
22
23
|
|
|
23
24
|
export const HIDDEN_MESSAGES_MODAL_KIND = 'hidden-messages';
|
|
24
25
|
|
|
@@ -113,12 +114,15 @@ function syncHiddenHeaderButton() {
|
|
|
113
114
|
const button = document.getElementById('hidden-messages-btn');
|
|
114
115
|
if (!button) return;
|
|
115
116
|
const count = getConversationHiddenMessages(currentConversationId).length;
|
|
117
|
+
// Available or not; the priority+ pass places it (header or ︙).
|
|
116
118
|
button.hidden = IS_SHARED_VIEW || !count;
|
|
119
|
+
button.dataset.count = IS_SHARED_VIEW ? '0' : String(count);
|
|
117
120
|
const countEl = button.querySelector('.header-icon-count');
|
|
118
121
|
if (countEl) countEl.textContent = count ? String(count) : '';
|
|
119
122
|
const label = count ? `Hidden from shared viewers (${count})` : 'Hidden from shared viewers';
|
|
120
123
|
button.setAttribute('title', label);
|
|
121
124
|
button.setAttribute('aria-label', label);
|
|
125
|
+
notifyHeaderActionsChanged();
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
function hiddenModalContent() {
|
|
@@ -186,12 +186,28 @@ export function leaveInboxView() {
|
|
|
186
186
|
return true;
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
|
|
189
|
+
/**
|
|
190
|
+
* Nothing to go back to (opened with nothing open, as after an unknown
|
|
191
|
+
* link): the header and the transcript give way to the empty screen.
|
|
192
|
+
* Loaded on demand: the transcript's module is already in the page.
|
|
193
|
+
*/
|
|
194
|
+
async function showNothingOpen() {
|
|
195
|
+
const { clearOpenConversation } = await import('./journal-view.js');
|
|
196
|
+
clearOpenConversation({ history: false });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Opens the Inbox, or leaves it for the conversation it was opened from.
|
|
201
|
+
* Opened from Status there is none stored: `returnTo` (the session the
|
|
202
|
+
* history stands on) is opened instead, so the transcript never keeps the list.
|
|
203
|
+
*/
|
|
204
|
+
export async function toggleInboxView({ returnTo = '' } = {}) {
|
|
190
205
|
if (inboxActive) {
|
|
191
206
|
leaveInboxView();
|
|
192
|
-
const restoreId = previousConversationId;
|
|
207
|
+
const restoreId = previousConversationId || String(returnTo || '').trim();
|
|
193
208
|
previousConversationId = '';
|
|
194
209
|
if (restoreId) await window.openConversation?.(restoreId, { restoreScroll: true });
|
|
210
|
+
else await showNothingOpen();
|
|
195
211
|
return;
|
|
196
212
|
}
|
|
197
213
|
if (isStatusViewActive()) leaveStatusView();
|