@oar-sh/oar 0.9.20 → 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.
Files changed (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -1,4 +1,4 @@
1
- // The header's ⋯ menu: one decision for its trigger and for every entry, with
1
+ // The header's ︙ menu: one decision for its trigger and for every entry, with
2
2
  // or without a conversation open. No DOM here; bootstrap.js applies the result.
3
3
  //
4
4
  // The menu holds two kinds of entries. Relay-wide ones (Settings, About OAR,
@@ -12,10 +12,27 @@
12
12
  //
13
13
  // The shared read-only view is its own branch: Day/Night only, whatever the
14
14
  // conversation state, and the trigger stays usable even when the shared
15
- // conversation failed to load.
15
+ // conversation failed to load. Once Day/Night fits in the header the shared
16
+ // menu is empty and the trigger hides.
17
+ //
18
+ // The twins of the header's priority+ actions (pins, hidden, search, context,
19
+ // Day/Night, Check Usage, fullscreen) show only while their header button
20
+ // does not fit; the header's fit pass hands in which ones that is.
21
+ //
22
+ // A phone on its side hides the header's meta line, and with it the chips
23
+ // that open something (the watching chip's list, the schedule chip's list,
24
+ // the origin's conversation). There, and only for a chip the conversation
25
+ // has, the menu carries an entry that does the same.
16
26
 
17
27
  export const CHAT_ACTIONS_MENU_TRIGGER_ID = 'chat-actions-menu-btn';
18
28
 
29
+ /** The stand-ins for the meta line's chips in phone landscape, by chip. */
30
+ export const CHAT_ACTIONS_MENU_META_CHIP_IDS = Object.freeze({
31
+ watching: 'chat-menu-watching',
32
+ schedules: 'chat-menu-schedules',
33
+ origin: 'chat-menu-origin',
34
+ });
35
+
19
36
  /** Entries that are text, not actions: `hidden` is applied, nothing else. */
20
37
  export const CHAT_ACTIONS_MENU_META_IDS = Object.freeze([
21
38
  'chat-menu-pwa-version',
@@ -26,9 +43,18 @@ export const CHAT_ACTIONS_MENU_META_IDS = Object.freeze([
26
43
  export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
27
44
  'chat-menu-pwa-version',
28
45
  'chat-menu-queue-status',
46
+ 'chat-menu-watching',
47
+ 'chat-menu-schedules',
48
+ 'chat-menu-origin',
29
49
  'chat-menu-edit-title',
50
+ 'chat-menu-questions',
51
+ 'chat-menu-pins',
52
+ 'chat-menu-hidden',
53
+ 'chat-menu-search',
54
+ 'chat-menu-context',
30
55
  'chat-menu-theme-toggle',
31
56
  'chat-menu-usage',
57
+ 'chat-menu-fullscreen',
32
58
  'chat-menu-compact',
33
59
  'chat-menu-share-conversation',
34
60
  'chat-menu-stop-sharing',
@@ -51,6 +77,36 @@ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
51
77
  /** Entries that are never shown: retired actions kept in the markup. */
52
78
  const ALWAYS_HIDDEN_IDS = new Set(['chat-menu-compact', 'chat-menu-refresh-history']);
53
79
 
80
+ /**
81
+ * The twins of the header's priority+ actions (header-priority-actions.mjs):
82
+ * each shows here only while its header button does not fit. Kept in sync
83
+ * with HEADER_PRIORITY_MENU_IDS by the tests.
84
+ */
85
+ export const CHAT_ACTIONS_MENU_TWIN_IDS = Object.freeze([
86
+ 'chat-menu-questions',
87
+ 'chat-menu-pins',
88
+ 'chat-menu-hidden',
89
+ 'chat-menu-search',
90
+ 'chat-menu-context',
91
+ 'chat-menu-usage',
92
+ 'chat-menu-theme-toggle',
93
+ 'chat-menu-fullscreen',
94
+ ]);
95
+
96
+ // Before the header was ever measured, the menu keeps the reach it always
97
+ // had: Day/Night and Check Usage. The other twins stay hidden, their header
98
+ // buttons show as they always did.
99
+ const UNMEASURED_TWIN_IDS = new Set(['chat-menu-theme-toggle', 'chat-menu-usage']);
100
+
101
+ // The shared read-only view offers Day/Night and nothing else.
102
+ const SHARED_TWIN_IDS = new Set(['chat-menu-theme-toggle']);
103
+
104
+ function twinShown(id, { sharedMode, headerMenuIds }) {
105
+ if (sharedMode && !SHARED_TWIN_IDS.has(id)) return false;
106
+ if (!Array.isArray(headerMenuIds)) return UNMEASURED_TWIN_IDS.has(id);
107
+ return headerMenuIds.includes(id);
108
+ }
109
+
54
110
  const shown = (disabled = false) => ({ hidden: false, disabled: !!disabled });
55
111
  const hidden = () => ({ hidden: true, disabled: true });
56
112
 
@@ -65,6 +121,17 @@ const hidden = () => ({ hidden: true, disabled: true });
65
121
  * @param {boolean} [input.editing] its title is being edited
66
122
  * @param {boolean} [input.suspendVisible] the "show Suspend host" setting
67
123
  * @param {boolean} [input.hasActiveShare] it has a public share link
124
+ * @param {string[]|null} [input.headerMenuIds] the twin entries the header's
125
+ * fit pass put into the menu (available and not fitting); null before the
126
+ * header was measured
127
+ * @param {boolean} [input.fullscreenSupported] the browser can go fullscreen
128
+ * (requestFullscreen exists); the twin is disabled otherwise, as the header
129
+ * button is
130
+ * @param {boolean} [input.phoneLandscape] :root[data-phone-landscape], where
131
+ * the header's meta line is hidden
132
+ * @param {{ watching?: boolean, schedules?: boolean, origin?: boolean }} [input.metaChips]
133
+ * the meta line's chips the conversation has (a watching chip, a schedule
134
+ * chip, an origin that opens its conversation)
68
135
  * @returns {{ trigger: { hidden: boolean, disabled: boolean }, entries: Record<string, { hidden: boolean, disabled: boolean }> }}
69
136
  */
70
137
  export function resolveChatActionsMenuState({
@@ -77,12 +144,19 @@ export function resolveChatActionsMenuState({
77
144
  editing = false,
78
145
  suspendVisible = false,
79
146
  hasActiveShare = false,
147
+ headerMenuIds = null,
148
+ fullscreenSupported = true,
149
+ phoneLandscape = false,
150
+ metaChips = null,
80
151
  } = {}) {
81
152
  const entries = {};
153
+ const twins = { sharedMode: !!sharedMode, headerMenuIds };
82
154
  if (sharedMode) {
83
155
  for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = hidden();
84
- entries['chat-menu-theme-toggle'] = shown();
85
- return { trigger: { hidden: false, disabled: false }, entries };
156
+ for (const id of CHAT_ACTIONS_MENU_TWIN_IDS) entries[id] = twinShown(id, twins) ? shown() : hidden();
157
+ // With Day/Night in the header there is nothing left to open.
158
+ const empty = !CHAT_ACTIONS_MENU_ENTRY_IDS.some((id) => !entries[id].hidden);
159
+ return { trigger: { hidden: empty, disabled: false }, entries };
86
160
  }
87
161
 
88
162
  const convId = String(conversationId || '').trim();
@@ -94,6 +168,14 @@ export function resolveChatActionsMenuState({
94
168
 
95
169
  for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = shown();
96
170
  for (const id of ALWAYS_HIDDEN_IDS) entries[id] = hidden();
171
+ for (const id of CHAT_ACTIONS_MENU_TWIN_IDS) entries[id] = twinShown(id, twins) ? shown() : hidden();
172
+ // Offered where the browser has no fullscreen (iPhone): disabled, as its
173
+ // header button is, rather than a live entry that does nothing.
174
+ if (!entries['chat-menu-fullscreen'].hidden && !fullscreenSupported) entries['chat-menu-fullscreen'] = shown(true);
175
+ // The meta line's chips, standing in where that line is hidden.
176
+ for (const [chip, id] of Object.entries(CHAT_ACTIONS_MENU_META_CHIP_IDS)) {
177
+ entries[id] = hasConversation && phoneLandscape && !!metaChips?.[chip] ? shown() : hidden();
178
+ }
97
179
  entries['chat-menu-edit-title'] = perConversation();
98
180
  entries['chat-menu-share-conversation'] = perConversation();
99
181
  // Only a conversation with a live link has something to stop.
@@ -119,6 +201,61 @@ export function resolveChatActionsMenuState({
119
201
  };
120
202
  }
121
203
 
204
+ /**
205
+ * The labels of the meta line's stand-ins: "👁 Watching 2 sessions…" (from the
206
+ * watching chip's text), "⏰ Schedules…", and the origin's line ("Started by
207
+ * an agent in “title”"). An entry with no text gets ''.
208
+ *
209
+ * @param {{ watching?: string, origin?: string }} input
210
+ * @returns {{ watching: string, schedules: string, origin: string }}
211
+ */
212
+ export function chatActionsMenuMetaChipLabels({ watching = '', origin = '' } = {}) {
213
+ const watchingText = String(watching || '').trim();
214
+ const originText = String(origin || '').trim();
215
+ return {
216
+ watching: watchingText ? `👁 ${watchingText.charAt(0).toUpperCase()}${watchingText.slice(1)}…` : '',
217
+ schedules: '⏰ Schedules…',
218
+ origin: originText ? `↗ ${originText}` : '',
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Where the open menu goes. It hangs from ︙ with its right edge on the
224
+ * button's; this keeps it inside `bounds` (the viewport and #main, so never
225
+ * off-screen and never under the sidebar) with `margin` to spare, narrows it
226
+ * when even that is too wide, and caps its height at what is left below its
227
+ * top (the menu then scrolls).
228
+ *
229
+ * @param {object} input
230
+ * @param {{ left: number, right: number, top: number }} input.rect the menu's box as CSS placed it
231
+ * @param {{ left: number, right: number, bottom: number }} input.bounds the area it must stay in
232
+ * @param {number} [input.margin] the gap it keeps from the bounds
233
+ * @param {number} [input.minHeight] the height it keeps at least
234
+ * @returns {{ right: number, maxWidth: number|null, maxHeight: number }}
235
+ * `right`: how far to move its right edge to the left of where CSS put it
236
+ * (negative: to the right); `maxWidth`: null when it fits as it is
237
+ */
238
+ export function resolveChatActionsMenuPlacement({ rect, bounds, margin = 8, minHeight = 120 } = {}) {
239
+ const left = Number(rect?.left) || 0;
240
+ const right = Number(rect?.right) || 0;
241
+ const top = Number(rect?.top) || 0;
242
+ const width = Math.max(0, right - left);
243
+ const minLeft = (Number(bounds?.left) || 0) + margin;
244
+ const maxRight = (Number(bounds?.right) || 0) - margin;
245
+ const room = Math.max(0, maxRight - minLeft);
246
+ const fitWidth = Math.min(width, room);
247
+ let nextLeft = right - fitWidth;
248
+ if (nextLeft + fitWidth > maxRight) nextLeft = maxRight - fitWidth;
249
+ if (nextLeft < minLeft) nextLeft = minLeft;
250
+ const nextRight = nextLeft + fitWidth;
251
+ const bottom = Number(bounds?.bottom) || 0;
252
+ return {
253
+ right: Math.round((right - nextRight) * 100) / 100,
254
+ maxWidth: width > room ? Math.floor(room) : null,
255
+ maxHeight: Math.floor(Math.max(minHeight, bottom - margin - top)),
256
+ };
257
+ }
258
+
122
259
  /** The ids of the entries a state shows, in menu order. */
123
260
  export function visibleChatActionsMenuEntryIds(state) {
124
261
  const entries = state?.entries || {};
@@ -0,0 +1,27 @@
1
+ // The lines under the chat title (origin, relay chips, folder) come with a
2
+ // long and a short label. On a desktop the long one shows; on a phone the
3
+ // meta lines share one row that scrolls sideways and show the short one. CSS
4
+ // picks (index.html, .chat-meta-long / .chat-meta-short). DOM-free.
5
+
6
+ export const CHAT_META_LONG_CLASS = 'chat-meta-long';
7
+ export const CHAT_META_SHORT_CLASS = 'chat-meta-short';
8
+
9
+ /** Both labels; the arguments are HTML the caller already escaped. */
10
+ export function chatMetaLabelHtml(longHtml, shortHtml) {
11
+ return `<span class="${CHAT_META_LONG_CLASS}">${longHtml}</span><span class="${CHAT_META_SHORT_CLASS}">${shortHtml}</span>`;
12
+ }
13
+
14
+ /**
15
+ * The last folder name of a path, for the phone's folder chip:
16
+ * "/home/dev/sample-repo/" → "sample-repo", "C:\\work\\api" → "api". A root
17
+ * ("/", "C:\\") is kept whole; '' for nothing.
18
+ */
19
+ export function cwdFolderName(path) {
20
+ const raw = String(path ?? '').trim();
21
+ if (!raw) return '';
22
+ const trimmed = raw.replace(/[\\/]+$/, '');
23
+ if (!trimmed) return raw.slice(0, 1);
24
+ if (/^[A-Za-z]:$/.test(trimmed)) return raw;
25
+ const parts = trimmed.split(/[\\/]+/).filter(Boolean);
26
+ return parts.length ? parts[parts.length - 1] : raw;
27
+ }
@@ -1,5 +1,6 @@
1
1
  // The composer's billing toggle (claude-billing-toggle.mjs) on the page: a
2
- // row above the composer, under the lock note, in Claude SDK chats. A press
2
+ // field of the ⚙ panel, under Model, in Claude SDK chats; the label row's
3
+ // billing pill (composer-settings-ui.js) shows its selected side. A press
3
4
  // moves the open chat between the subscription and the saved API key through
4
5
  // the relay's billing route; the relay's answer and its `conversation_billing`
5
6
  // events (usage-limit-ui.js keeps them per chat) redraw it.
@@ -7,7 +7,7 @@
7
7
  // and the server's `cloud` payload field), so the one sync function below is
8
8
  // idempotent and is simply called whenever the header is synced. A
9
9
  // conversation of any other provider gets every element back exactly as it was.
10
- // The ⋯ menu is not touched here: its entries are decided in one place
10
+ // The ︙ menu is not touched here: its entries are decided in one place
11
11
  // (chat-actions-menu-state.mjs), which reads the cloud state through
12
12
  // isCurrentConversationClaudeCloud() and currentCloudSessionUrl().
13
13
 
@@ -57,7 +57,7 @@ export function syncClaudeCloudConversationUi() {
57
57
  line.hidden = !lineHtml;
58
58
  }
59
59
 
60
- // The ⋯ menu entries (no Change CWD for a cloud chat, Open on claude.ai once
60
+ // The ︙ menu entries (no Change CWD for a cloud chat, Open on claude.ai once
61
61
  // there is a link) are decided with the rest of the menu in
62
62
  // chat-actions-menu-state.mjs, from `isCloud` and currentCloudSessionUrl().
63
63
 
@@ -0,0 +1,90 @@
1
+ // The composer's settings label ("🔒 Opus 5.5 · high · Auto · 1M") and
2
+ // its billing pill. Pure and DOM-free: composer-settings-ui.js reads the
3
+ // selects' shown option texts and hands them in.
4
+
5
+ export const COMPOSER_LABEL_SEPARATOR = ' · ';
6
+ export const COMPOSER_LOCK_GLYPH = '🔒';
7
+
8
+ // The effort select's placeholder when a model has no effort choice.
9
+ const UNAVAILABLE_EFFORT = 'unavailable';
10
+
11
+ // Values of the context-window select that are its default: the model's base
12
+ // window, or "auto" when the select is the image-size picker.
13
+ const DEFAULT_TIER_VALUES = new Set(['', 'default', 'auto']);
14
+
15
+ // Mode names the label shortens; the panel's select keeps the full name.
16
+ const SHORT_MODE_NAMES = new Map([['autopilot', 'Auto']]);
17
+
18
+ function clean(value) {
19
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
20
+ }
21
+
22
+ /**
23
+ * The label's parts, in order: model, effort, mode, context window.
24
+ * Each is `{ field, text }`; `field` names the panel field a tap on it opens
25
+ * ('model' | 'effort' | 'mode' | 'tier'). The model part carries `locked`.
26
+ *
27
+ * - `cloud`: a cloud chat has no mode, effort or context window.
28
+ * - an effort with no value or "Unavailable" is left out.
29
+ * - the context window shows only when it is not the default.
30
+ * - a hidden field (`hidden.mode` etc.) is left out.
31
+ */
32
+ export function composerSettingsLabelParts({
33
+ model = '',
34
+ locked = false,
35
+ effort = '',
36
+ effortValue = undefined,
37
+ mode = '',
38
+ tier = '',
39
+ tierValue = '',
40
+ cloud = false,
41
+ hidden = {},
42
+ } = {}) {
43
+ const parts = [];
44
+ const modelText = clean(model);
45
+ if (modelText && !hidden.model) {
46
+ parts.push({ field: 'model', text: modelText, locked: locked === true });
47
+ }
48
+ if (!cloud && !hidden.effort) {
49
+ const effortText = clean(effort);
50
+ const effortEmpty = effortValue !== undefined && clean(effortValue) === '';
51
+ if (effortText && !effortEmpty && effortText.toLowerCase() !== UNAVAILABLE_EFFORT) {
52
+ parts.push({ field: 'effort', text: effortText });
53
+ }
54
+ }
55
+ if (!cloud && !hidden.mode) {
56
+ const modeText = clean(mode);
57
+ if (modeText) parts.push({ field: 'mode', text: SHORT_MODE_NAMES.get(modeText.toLowerCase()) || modeText });
58
+ }
59
+ if (!cloud && !hidden.tier) {
60
+ const tierText = clean(tier);
61
+ const value = clean(tierValue).toLowerCase();
62
+ if (tierText && !DEFAULT_TIER_VALUES.has(value)) parts.push({ field: 'tier', text: tierText });
63
+ }
64
+ return parts;
65
+ }
66
+
67
+ /** A part as it reads in the label: the model gets the lock in front. */
68
+ export function composerSettingsPartText(part) {
69
+ if (!part) return '';
70
+ return part.field === 'model' && part.locked ? `${COMPOSER_LOCK_GLYPH} ${part.text}` : part.text;
71
+ }
72
+
73
+ /** The whole label as one string, e.g. "🔒 Opus 5.5 · high · Autopilot · 1M". */
74
+ export function composerSettingsLabelText(input = {}) {
75
+ const parts = Array.isArray(input) ? input : composerSettingsLabelParts(input);
76
+ return parts.map(composerSettingsPartText).filter(Boolean).join(COMPOSER_LABEL_SEPARATOR);
77
+ }
78
+
79
+ /**
80
+ * The billing pill beside the label, from the billing toggle's state:
81
+ * `{ visible, billing, text }`. Hidden when the toggle is hidden or says
82
+ * no billing.
83
+ */
84
+ export function composerBillingPill({ toggleVisible = false, billing = '' } = {}) {
85
+ const value = clean(billing).toLowerCase();
86
+ if (!toggleVisible || (value !== 'claude' && value !== 'claude-api')) {
87
+ return { visible: false, billing: '', text: '' };
88
+ }
89
+ return { visible: true, billing: value, text: value === 'claude-api' ? 'API key' : 'Subscription' };
90
+ }