@oar-sh/oar 0.9.14 → 0.9.16
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 +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +613 -56
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/oar-cli-helpers.mjs +12 -4
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2334
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Relay schedules in the browser: the "scheduled" marker on a conversation's
|
|
2
|
+
// sidebar row and the header's "N scheduled" chip with its list and Cancel
|
|
3
|
+
// buttons.
|
|
4
|
+
//
|
|
5
|
+
// DOM-free on purpose, like relay-event-view.mjs: the callers own the
|
|
6
|
+
// elements, this module owns the decisions and the markup.
|
|
7
|
+
//
|
|
8
|
+
// The interval words mirror shared/relay-schedules.mjs, which the browser
|
|
9
|
+
// cannot import (only server/public is served); relay-schedule-view.test.mjs
|
|
10
|
+
// holds the two together.
|
|
11
|
+
|
|
12
|
+
export const RELAY_SCHEDULE_SOCKET_EVENT = 'relay_schedules';
|
|
13
|
+
export const RELAY_SCHEDULE_MESSAGE_KIND = 'relay-schedule';
|
|
14
|
+
|
|
15
|
+
function defaultEscape(value) {
|
|
16
|
+
return String(value ?? '')
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultFormatTime(iso) {
|
|
25
|
+
const at = new Date(iso);
|
|
26
|
+
if (!Number.isFinite(at.getTime())) return '';
|
|
27
|
+
const sameDay = new Date().toDateString() === at.toDateString();
|
|
28
|
+
return at.toLocaleString([], sameDay
|
|
29
|
+
? { hour: '2-digit', minute: '2-digit' }
|
|
30
|
+
: { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function activeList(schedules) {
|
|
34
|
+
return (Array.isArray(schedules) ? schedules : []).filter((schedule) => schedule && schedule.id && !schedule.endedAt);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** "every 30 min", "every 2 h", "every day", "every 7 days". */
|
|
38
|
+
export function describeScheduleInterval(everyMinutes) {
|
|
39
|
+
const minutes = Number(everyMinutes);
|
|
40
|
+
if (!(minutes > 0)) return '';
|
|
41
|
+
if (minutes % 1440 === 0) return minutes === 1440 ? 'every day' : `every ${minutes / 1440} days`;
|
|
42
|
+
if (minutes % 60 === 0) return minutes === 60 ? 'every hour' : `every ${minutes / 60} h`;
|
|
43
|
+
return `every ${minutes} min`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The schedules that fire into a conversation. */
|
|
47
|
+
export function schedulesInto(schedules, conversationId) {
|
|
48
|
+
const id = String(conversationId || '').trim();
|
|
49
|
+
if (!id) return [];
|
|
50
|
+
return activeList(schedules).filter((schedule) => schedule.session?.id === id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The schedules that fire into a conversation or that it set for another. */
|
|
54
|
+
export function schedulesOf(schedules, conversationId) {
|
|
55
|
+
const id = String(conversationId || '').trim();
|
|
56
|
+
if (!id) return [];
|
|
57
|
+
return activeList(schedules).filter((schedule) => schedule.session?.id === id || schedule.setBy?.id === id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** "every 30 min · next 10:30", or "once · 06:30". */
|
|
61
|
+
export function describeScheduleWhen(schedule, { formatTime = defaultFormatTime } = {}) {
|
|
62
|
+
const next = schedule?.nextFireAt ? formatTime(schedule.nextFireAt) : '';
|
|
63
|
+
const every = schedule?.once ? '' : describeScheduleInterval(schedule?.everyMinutes);
|
|
64
|
+
if (every) return next ? `${every} · next ${next}` : every;
|
|
65
|
+
return next ? `once · ${next}` : 'once';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The earliest next time among the schedules, or null. */
|
|
69
|
+
export function nextScheduledAt(schedules) {
|
|
70
|
+
const times = activeList(schedules).map((schedule) => Date.parse(schedule.nextFireAt || '')).filter(Number.isFinite);
|
|
71
|
+
return times.length ? new Date(Math.min(...times)).toISOString() : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function scheduleChipText(schedules) {
|
|
75
|
+
const count = activeList(schedules).length;
|
|
76
|
+
return count ? `${count} scheduled` : '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function conversationTitle(conversations, id, fallback = '') {
|
|
80
|
+
const title = String(conversations?.[id]?.title || fallback || '').trim();
|
|
81
|
+
return title || `session ${String(id).slice(0, 8)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The sidebar's marker for a conversation a schedule fires into, or ''. */
|
|
85
|
+
export function renderScheduledBadgeHtml(schedules, conversationId, { escHtml = defaultEscape, formatTime = defaultFormatTime } = {}) {
|
|
86
|
+
const into = schedulesInto(schedules, conversationId);
|
|
87
|
+
if (!into.length) return '';
|
|
88
|
+
const next = nextScheduledAt(into);
|
|
89
|
+
const hint = `${into.length} schedule${into.length === 1 ? '' : 's'}${next ? ` · next ${formatTime(next)}` : ''}`;
|
|
90
|
+
return `<span class="conv-scheduled-badge" title="${escHtml(hint)}">scheduled</span>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The chip's list for the conversation on screen: one row per schedule, what
|
|
95
|
+
* it is called and sends, where it goes or who set it, when it fires next,
|
|
96
|
+
* and its Cancel button.
|
|
97
|
+
*/
|
|
98
|
+
export function renderSchedulePanelHtml(schedules, conversationId, { conversations = null, escHtml = defaultEscape, formatTime = defaultFormatTime } = {}) {
|
|
99
|
+
const id = String(conversationId || '').trim();
|
|
100
|
+
const rows = schedulesOf(schedules, id).map((schedule) => {
|
|
101
|
+
const name = String(schedule.title || '').trim() || String(schedule.text || '').trim().split(/\r?\n/)[0] || schedule.id;
|
|
102
|
+
const where = schedule.session?.id !== id
|
|
103
|
+
? `for ${conversationTitle(conversations, schedule.session?.id, schedule.session?.title)}`
|
|
104
|
+
: (schedule.setBy?.id ? `set by ${conversationTitle(conversations, schedule.setBy.id, schedule.setBy.title)}` : '');
|
|
105
|
+
// Held: its setter's agent has prompted other agents as often as it may
|
|
106
|
+
// before you write in that conversation.
|
|
107
|
+
const held = schedule.heldForApproval
|
|
108
|
+
? `waiting until you write in ${conversationTitle(conversations, schedule.setBy?.id, schedule.setBy?.title)}`
|
|
109
|
+
: '';
|
|
110
|
+
const details = [held || describeScheduleWhen(schedule, { formatTime }), where, schedule.skippedCount ? `${schedule.skippedCount} skipped` : '']
|
|
111
|
+
.filter(Boolean).join(' · ');
|
|
112
|
+
return '<li class="relay-watch-item relay-schedule-item">'
|
|
113
|
+
+ `<span class="relay-watch-what" title="${escHtml(schedule.text || '')}">${escHtml(name)}</span>`
|
|
114
|
+
+ `<span class="relay-watch-events">${escHtml(details)}</span>`
|
|
115
|
+
+ `<button type="button" class="relay-schedule-cancel" data-schedule-id="${escHtml(schedule.id)}">Cancel</button>`
|
|
116
|
+
+ '</li>';
|
|
117
|
+
}).join('');
|
|
118
|
+
return `<ul class="relay-watch-list">${rows}</ul>`;
|
|
119
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Relay schedules on the page: the header's "N scheduled" chip with its list
|
|
2
|
+
// and Cancel buttons (on a conversation a schedule fires into or that set
|
|
3
|
+
// one), and the "scheduled" marker on the sidebar row of a conversation a
|
|
4
|
+
// schedule fires into. The relay sends the whole active list on every change
|
|
5
|
+
// (socket `relay_schedules`); the page loads it once at start.
|
|
6
|
+
|
|
7
|
+
import { apiFetch } from './api-client.js';
|
|
8
|
+
import { conversations, currentConvId, escHtml, IS_SHARED_VIEW, showTransientRelayNotice } from './store.js';
|
|
9
|
+
import {
|
|
10
|
+
nextScheduledAt,
|
|
11
|
+
renderScheduledBadgeHtml,
|
|
12
|
+
renderSchedulePanelHtml,
|
|
13
|
+
scheduleChipText,
|
|
14
|
+
schedulesOf,
|
|
15
|
+
} from './relay-schedule-view.mjs';
|
|
16
|
+
|
|
17
|
+
let schedules = [];
|
|
18
|
+
let renderedChipKey = '';
|
|
19
|
+
let panelOpen = false;
|
|
20
|
+
let panelConversationId = '';
|
|
21
|
+
const changeListeners = new Set();
|
|
22
|
+
|
|
23
|
+
/** Runs after every change of the list (the sidebar re-renders its markers). */
|
|
24
|
+
export function onRelaySchedulesChanged(listener) {
|
|
25
|
+
changeListeners.add(listener);
|
|
26
|
+
return () => changeListeners.delete(listener);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyRelaySchedules(list) {
|
|
30
|
+
schedules = Array.isArray(list) ? list.filter((schedule) => schedule && typeof schedule === 'object') : [];
|
|
31
|
+
syncRelayScheduleHeader();
|
|
32
|
+
for (const listener of changeListeners) {
|
|
33
|
+
try { listener(schedules); } catch {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadRelaySchedules() {
|
|
38
|
+
if (IS_SHARED_VIEW) return;
|
|
39
|
+
const response = await apiFetch('/api/relay-schedules');
|
|
40
|
+
if (response && Array.isArray(response.schedules)) applyRelaySchedules(response.schedules);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The sidebar's marker for one conversation. */
|
|
44
|
+
export function relayScheduleMarkerHtml(conversationId) {
|
|
45
|
+
if (IS_SHARED_VIEW) return '';
|
|
46
|
+
return renderScheduledBadgeHtml(schedules, conversationId, { escHtml });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function cancelSchedule(scheduleId, button) {
|
|
50
|
+
if (!scheduleId) return;
|
|
51
|
+
if (button) button.disabled = true;
|
|
52
|
+
const response = await apiFetch(`/api/relay-schedules/${encodeURIComponent(scheduleId)}`, { method: 'DELETE' });
|
|
53
|
+
if (!response?.ok) {
|
|
54
|
+
if (button) button.disabled = false;
|
|
55
|
+
showTransientRelayNotice?.('The schedule could not be cancelled. Try again in a moment.');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// The socket brings the new list; this page does not wait for it.
|
|
59
|
+
applyRelaySchedules(schedules.filter((schedule) => schedule.id !== scheduleId));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A fixed popover on <body>, placed under the chip, like the subscriptions'
|
|
63
|
+
// list: the title slot clips its overflow.
|
|
64
|
+
function ensurePanel() {
|
|
65
|
+
let panel = document.getElementById('relay-schedule-panel');
|
|
66
|
+
if (panel) return panel;
|
|
67
|
+
panel = document.createElement('div');
|
|
68
|
+
panel.id = 'relay-schedule-panel';
|
|
69
|
+
panel.className = 'relay-watch-panel';
|
|
70
|
+
panel.hidden = true;
|
|
71
|
+
document.body.appendChild(panel);
|
|
72
|
+
panel.addEventListener('click', (event) => {
|
|
73
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
74
|
+
const cancelButton = target?.closest?.('.relay-schedule-cancel');
|
|
75
|
+
if (!cancelButton) return;
|
|
76
|
+
void cancelSchedule(cancelButton.getAttribute('data-schedule-id'), cancelButton);
|
|
77
|
+
});
|
|
78
|
+
document.addEventListener('click', (event) => {
|
|
79
|
+
if (!panelOpen) return;
|
|
80
|
+
// composedPath: a Cancel click may have re-rendered the panel already.
|
|
81
|
+
const path = event.composedPath();
|
|
82
|
+
if (path.includes(panel) || path.some((node) => node instanceof Element && node.classList.contains('relay-schedule-chip'))) return;
|
|
83
|
+
setPanelOpen(false);
|
|
84
|
+
});
|
|
85
|
+
document.addEventListener('keydown', (event) => {
|
|
86
|
+
if (!panelOpen || event.key !== 'Escape') return;
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
setPanelOpen(false);
|
|
89
|
+
document.querySelector('#chat-title-relay-schedules .relay-schedule-chip')?.focus?.();
|
|
90
|
+
}, true);
|
|
91
|
+
window.addEventListener('resize', () => { if (panelOpen) placePanel(); });
|
|
92
|
+
return panel;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function setPanelOpen(open) {
|
|
96
|
+
panelOpen = Boolean(open);
|
|
97
|
+
renderedChipKey = '';
|
|
98
|
+
syncRelayScheduleHeader();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function placePanel() {
|
|
102
|
+
const panel = document.getElementById('relay-schedule-panel');
|
|
103
|
+
const chip = document.querySelector('#chat-title-relay-schedules .relay-schedule-chip');
|
|
104
|
+
if (!panel || panel.hidden) return;
|
|
105
|
+
const rect = chip?.getBoundingClientRect?.();
|
|
106
|
+
if (!rect || (!rect.width && !rect.height)) {
|
|
107
|
+
panel.hidden = true;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const margin = 8;
|
|
111
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
112
|
+
const top = Math.round(rect.bottom + 4);
|
|
113
|
+
panel.style.top = `${top}px`;
|
|
114
|
+
panel.style.maxHeight = `${Math.max(120, window.innerHeight - top - margin)}px`;
|
|
115
|
+
panel.style.left = '0px';
|
|
116
|
+
const width = panel.offsetWidth;
|
|
117
|
+
const left = Math.max(margin, Math.min(rect.left, viewportWidth - width - margin));
|
|
118
|
+
panel.style.left = `${Math.round(left)}px`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function ensureHeaderElements() {
|
|
122
|
+
const slot = document.getElementById('chat-title-slot');
|
|
123
|
+
if (!slot) return null;
|
|
124
|
+
let line = document.getElementById('chat-title-relay-schedules');
|
|
125
|
+
if (!line) {
|
|
126
|
+
line = document.createElement('span');
|
|
127
|
+
line.id = 'chat-title-relay-schedules';
|
|
128
|
+
line.hidden = true;
|
|
129
|
+
const after = document.getElementById('chat-title-relay-events') || document.getElementById('chat-title-origin');
|
|
130
|
+
if (after?.nextSibling) slot.insertBefore(line, after.nextSibling);
|
|
131
|
+
else slot.appendChild(line);
|
|
132
|
+
line.addEventListener('click', (event) => {
|
|
133
|
+
const target = event.target instanceof Element ? event.target : null;
|
|
134
|
+
if (target?.closest?.('.relay-schedule-chip')) {
|
|
135
|
+
event.stopPropagation();
|
|
136
|
+
setPanelOpen(!panelOpen);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return line;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The header's chip for the conversation on screen. Idempotent. */
|
|
144
|
+
export function syncRelayScheduleHeader() {
|
|
145
|
+
const line = ensureHeaderElements();
|
|
146
|
+
if (!line) return;
|
|
147
|
+
const panel = ensurePanel();
|
|
148
|
+
const id = String(currentConvId || '').trim();
|
|
149
|
+
// Another conversation on screen closes the list.
|
|
150
|
+
if (id !== panelConversationId) panelOpen = false;
|
|
151
|
+
panelConversationId = id;
|
|
152
|
+
const mine = IS_SHARED_VIEW || !id ? [] : schedulesOf(schedules, id);
|
|
153
|
+
const chipText = scheduleChipText(mine);
|
|
154
|
+
if (!chipText) panelOpen = false;
|
|
155
|
+
const key = JSON.stringify([id, chipText, panelOpen, mine.map((schedule) => [schedule.id, schedule.nextFireAt, schedule.skippedCount || 0])]);
|
|
156
|
+
if (key === renderedChipKey) return;
|
|
157
|
+
renderedChipKey = key;
|
|
158
|
+
if (!chipText) {
|
|
159
|
+
line.hidden = true;
|
|
160
|
+
line.innerHTML = '';
|
|
161
|
+
panel.hidden = true;
|
|
162
|
+
panel.innerHTML = '';
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const next = nextScheduledAt(mine);
|
|
166
|
+
const nextText = next ? new Date(next).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
|
167
|
+
line.innerHTML = `<button type="button" class="relay-watch-chip relay-schedule-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-schedule-panel" title="Prompts the relay queues at set times (agents set them with remote_relay schedule)">⏰ ${escHtml(chipText)}${nextText ? ` <span class="relay-watch-hint">next ${escHtml(nextText)}</span>` : ''}</button>`;
|
|
168
|
+
line.hidden = false;
|
|
169
|
+
panel.innerHTML = panelOpen ? renderSchedulePanelHtml(schedules, id, { conversations, escHtml }) : '';
|
|
170
|
+
panel.hidden = !panelOpen;
|
|
171
|
+
if (panelOpen) placePanel();
|
|
172
|
+
}
|
|
@@ -1502,6 +1502,11 @@ const WINDOWS_BOOT_CUSTOMISATION_LABELS = Object.freeze({
|
|
|
1502
1502
|
'boot-delay': 'boot delay',
|
|
1503
1503
|
launcher: 'launcher edited or from an older OAR',
|
|
1504
1504
|
'launcher-missing': 'launcher missing',
|
|
1505
|
+
user: 'account',
|
|
1506
|
+
'run-level': 'run level',
|
|
1507
|
+
settings: 'settings',
|
|
1508
|
+
other: 'other parts',
|
|
1509
|
+
unreadable: 'unreadable',
|
|
1505
1510
|
});
|
|
1506
1511
|
|
|
1507
1512
|
function windowsAutostartStatusText() {
|
|
@@ -1511,21 +1516,30 @@ function windowsAutostartStatusText() {
|
|
|
1511
1516
|
}
|
|
1512
1517
|
if (boot?.lastError) return boot.lastError;
|
|
1513
1518
|
if (boot?.readError) return `The boot task could not be read: ${boot.readError}`;
|
|
1519
|
+
const [startupEntry] = windowsAutostartState?.signinHandMade || [];
|
|
1520
|
+
if (startupEntry) {
|
|
1521
|
+
const starts = startupEntry.otherInstall ? ` (it starts the OAR install in ${startupEntry.otherInstall})` : '';
|
|
1522
|
+
return `A Startup entry OAR did not make is there: ${startupEntry.path}${starts}. OAR leaves it as it is; to replace it, keeping a copy: oar autostart signin --replace.`;
|
|
1523
|
+
}
|
|
1514
1524
|
if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
|
|
1515
1525
|
return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
|
|
1516
1526
|
}
|
|
1527
|
+
// OAR never deletes or writes over an entry it did not make (as `oar autostart`).
|
|
1528
|
+
const replaceHint = 'OAR leaves it as it is; to replace it, keeping a copy: oar autostart boot --replace';
|
|
1517
1529
|
if (windowsAutostartState?.mode === 'boot' && boot?.customised) {
|
|
1518
1530
|
const labels = (boot.customisations || []).map((name) => WINDOWS_BOOT_CUSTOMISATION_LABELS[name] || name);
|
|
1519
|
-
return
|
|
1531
|
+
return boot.handMade?.length
|
|
1532
|
+
? `The boot task or its launcher was changed by hand (${labels.join(', ')}). ${replaceHint}.`
|
|
1533
|
+
: `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates it.`;
|
|
1520
1534
|
}
|
|
1521
1535
|
if (windowsAutostartState?.mode === 'boot' && boot?.launcherBackupPath) {
|
|
1522
1536
|
return `The previous launcher was replaced and kept as ${boot.launcherBackupPath}.`;
|
|
1523
1537
|
}
|
|
1524
1538
|
if (boot?.taskStatus === 'foreign') {
|
|
1525
|
-
return `A task named "${boot.taskName}" exists but was not created by OAR.
|
|
1539
|
+
return `A task named "${boot.taskName}" exists but was not created by OAR. ${replaceHint}.`;
|
|
1526
1540
|
}
|
|
1527
1541
|
if (boot?.legacyTaskPresent && windowsAutostartState?.mode !== 'boot') {
|
|
1528
|
-
return
|
|
1542
|
+
return `A pre-rebrand boot task exists. ${replaceHint}.`;
|
|
1529
1543
|
}
|
|
1530
1544
|
return '';
|
|
1531
1545
|
}
|
|
@@ -38,6 +38,9 @@ import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
|
|
|
38
38
|
import { clearOpenConversation, renderConvList, refreshConversations, scheduleLoadedConversationsRefresh, applyConversationGroupEvent, openConversation } from './journal-view.js';
|
|
39
39
|
import { applyBackgroundTasksToConversation, applyTurnStatusToConversation } from './conversation-turn-state.mjs';
|
|
40
40
|
import { applyRelayEventSubscriptions, loadRelayEventSubscriptions } from './relay-event-subscriptions-ui.js';
|
|
41
|
+
import { applyRelaySchedules, loadRelaySchedules } from './relay-schedules-ui.js';
|
|
42
|
+
import { noteInboxQuestionsChanged, noteRelayAskUpdated, refreshInbox } from './inbox-ui.js';
|
|
43
|
+
import { RELAY_ASK_SOCKET_EVENT } from './inbox-view.mjs';
|
|
41
44
|
import {
|
|
42
45
|
upsertRelayQuestion,
|
|
43
46
|
loadRelayQuestions,
|
|
@@ -368,6 +371,10 @@ export async function connectSocket(overrideDeps) {
|
|
|
368
371
|
void handleTaskConsoleReconnect();
|
|
369
372
|
// Subscriptions that changed while away.
|
|
370
373
|
void loadRelayEventSubscriptions();
|
|
374
|
+
// Schedules that changed or fired while away.
|
|
375
|
+
void loadRelaySchedules();
|
|
376
|
+
// Questions asked or answered while away.
|
|
377
|
+
void refreshInbox();
|
|
371
378
|
// Shares made or stopped while away.
|
|
372
379
|
void refreshShares().catch(() => {});
|
|
373
380
|
});
|
|
@@ -618,11 +625,20 @@ export async function connectSocket(overrideDeps) {
|
|
|
618
625
|
noteTranscriptLiveRow();
|
|
619
626
|
keepLiveRow(conversationId, message, messageId);
|
|
620
627
|
});
|
|
621
|
-
socket.on('relay_question', ({ question }) =>
|
|
622
|
-
|
|
628
|
+
socket.on('relay_question', ({ question }) => {
|
|
629
|
+
upsertRelayQuestion(question);
|
|
630
|
+
noteInboxQuestionsChanged();
|
|
631
|
+
});
|
|
632
|
+
socket.on('relay_question_updated', ({ question }) => {
|
|
633
|
+
upsertRelayQuestion(question);
|
|
634
|
+
noteInboxQuestionsChanged();
|
|
635
|
+
});
|
|
623
636
|
socket.on('relay_question_changed', () => {
|
|
624
637
|
loadRelayQuestions(currentConvId);
|
|
638
|
+
noteInboxQuestionsChanged();
|
|
625
639
|
});
|
|
640
|
+
// Questions that don't block: one ask, after every change of it.
|
|
641
|
+
socket.on(RELAY_ASK_SOCKET_EVENT, (payload) => noteRelayAskUpdated(payload));
|
|
626
642
|
socket.on('relay_board', ({ board }) => upsertRelayBoard(board));
|
|
627
643
|
socket.on('relay_board_updated', ({ board }) => upsertRelayBoard(board));
|
|
628
644
|
socket.on('relay_board_changed', () => {
|
|
@@ -632,6 +648,10 @@ export async function connectSocket(overrideDeps) {
|
|
|
632
648
|
socket.on('relay_event_subscriptions', ({ subscriptions }) => {
|
|
633
649
|
applyRelayEventSubscriptions(subscriptions);
|
|
634
650
|
});
|
|
651
|
+
// Relay schedules: the whole active list, after every change.
|
|
652
|
+
socket.on('relay_schedules', ({ schedules }) => {
|
|
653
|
+
applyRelaySchedules(schedules);
|
|
654
|
+
});
|
|
635
655
|
socket.on('background_tasks', ({ conversationId, tasks, endedTasks }) => {
|
|
636
656
|
setConversationBackgroundTasks(conversationId, tasks, { endedTasks, event: true });
|
|
637
657
|
// The sidebar's row (and its group's head) count background work as
|
|
@@ -799,7 +819,7 @@ export async function connectSocket(overrideDeps) {
|
|
|
799
819
|
applyConversationTitleUpdate(conversationId, title, updatedAt);
|
|
800
820
|
syncChatTitleControls();
|
|
801
821
|
});
|
|
802
|
-
socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, senderClientId }) => {
|
|
822
|
+
socket.on('conversation_preferences_updated', ({ conversationId, preferredRelayMode, preferredModel, preferredReasoningEffort, autoCompactWindow, thinkingEnabled, thinkingDisplay, mayCreateSessions, senderClientId }) => {
|
|
803
823
|
if (senderClientId && senderClientId === CLIENT_ID) return;
|
|
804
824
|
const id = String(conversationId || '').trim();
|
|
805
825
|
if (!id || !conversations[id]) return;
|
|
@@ -821,6 +841,10 @@ export async function connectSocket(overrideDeps) {
|
|
|
821
841
|
thinkingDisplay: thinkingDisplay === undefined
|
|
822
842
|
? (conversations[id].thinkingDisplay ?? null)
|
|
823
843
|
: (thinkingDisplay ?? null),
|
|
844
|
+
// The owner's "agents may start sessions"; an older relay omits it.
|
|
845
|
+
mayCreateSessions: typeof mayCreateSessions === 'boolean'
|
|
846
|
+
? mayCreateSessions
|
|
847
|
+
: conversations[id].mayCreateSessions === true,
|
|
824
848
|
};
|
|
825
849
|
if (String(currentConvId || '').trim() === id) {
|
|
826
850
|
applyConversationPreferencesForConversation(id, {
|
|
@@ -222,6 +222,16 @@ export function setCurrentConv(id) {
|
|
|
222
222
|
updateCompactButton();
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Forgets the kept empty groups before the list's first page names the ones
|
|
227
|
+
* still kept (one the owner dissolved meanwhile is not named again).
|
|
228
|
+
*/
|
|
229
|
+
export function forgetKeptEmptyConversationGroups() {
|
|
230
|
+
for (const [id, group] of Object.entries(conversationGroups)) {
|
|
231
|
+
if (group?.keptEmptyAt) delete conversationGroups[id];
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
225
235
|
/** Merges a list payload's `groups` (by id) into the known session groups. */
|
|
226
236
|
export function mergeConversationGroups(groups) {
|
|
227
237
|
if (!groups || typeof groups !== 'object') return;
|
|
@@ -240,13 +250,15 @@ export function forgetConversationGroup(groupId) {
|
|
|
240
250
|
/**
|
|
241
251
|
* Keeps only the groups that a loaded row still belongs to: after a list
|
|
242
252
|
* refresh a dissolved group has no row left, and the picker must not offer
|
|
243
|
-
* it.
|
|
253
|
+
* it. An owner's group an agent emptied (`keptEmptyAt`) has no row and
|
|
254
|
+
* stays: the list's first page names it while it is kept. Returns the ids
|
|
255
|
+
* dropped.
|
|
244
256
|
*/
|
|
245
257
|
export function pruneConversationGroups(keepGroupIds) {
|
|
246
258
|
const keep = new Set((Array.isArray(keepGroupIds) ? keepGroupIds : []).map((id) => String(id || '').trim()).filter(Boolean));
|
|
247
259
|
const dropped = [];
|
|
248
260
|
for (const id of Object.keys(conversationGroups)) {
|
|
249
|
-
if (keep.has(id)) continue;
|
|
261
|
+
if (keep.has(id) || conversationGroups[id]?.keptEmptyAt) continue;
|
|
250
262
|
delete conversationGroups[id];
|
|
251
263
|
dropped.push(id);
|
|
252
264
|
}
|
|
@@ -1126,9 +1138,10 @@ function measureSidebarHeaderMinWidthPx() {
|
|
|
1126
1138
|
sectionCount += 1;
|
|
1127
1139
|
}
|
|
1128
1140
|
|
|
1129
|
-
const
|
|
1130
|
-
|
|
1131
|
-
|
|
1141
|
+
for (const id of ['status-btn', 'inbox-btn']) {
|
|
1142
|
+
const headerButton = document.getElementById(id);
|
|
1143
|
+
if (!isVisibleElement(headerButton)) continue;
|
|
1144
|
+
const buttonStyle = window.getComputedStyle(headerButton);
|
|
1132
1145
|
neededWidth += Math.ceil(parseCssPx(buttonStyle.minWidth));
|
|
1133
1146
|
sectionCount += 1;
|
|
1134
1147
|
}
|
package/server/public/index.html
CHANGED
|
@@ -788,6 +788,151 @@
|
|
|
788
788
|
}
|
|
789
789
|
.status-empty { color: var(--muted); text-align: center; }
|
|
790
790
|
|
|
791
|
+
/* The Inbox: the sidebar button with its count, the view in the
|
|
792
|
+
transcript's place (status-view's frame), and the card a question that
|
|
793
|
+
does not block shows in its session. Every text in it is an agent's,
|
|
794
|
+
set as plain text. */
|
|
795
|
+
#inbox-btn {
|
|
796
|
+
flex: 0 0 auto;
|
|
797
|
+
display: inline-flex;
|
|
798
|
+
align-items: center;
|
|
799
|
+
gap: 6px;
|
|
800
|
+
min-width: 72px;
|
|
801
|
+
padding: 10px 12px;
|
|
802
|
+
background: var(--bg3);
|
|
803
|
+
color: var(--text);
|
|
804
|
+
border: 1px solid var(--border);
|
|
805
|
+
border-radius: 8px;
|
|
806
|
+
cursor: pointer;
|
|
807
|
+
font-weight: 600;
|
|
808
|
+
font-size: var(--fs-meta-md);
|
|
809
|
+
white-space: nowrap;
|
|
810
|
+
}
|
|
811
|
+
#inbox-btn:hover, #inbox-btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
|
|
812
|
+
.inbox-count {
|
|
813
|
+
min-width: 18px;
|
|
814
|
+
padding: 1px 6px;
|
|
815
|
+
border-radius: 999px;
|
|
816
|
+
background: var(--accent);
|
|
817
|
+
color: var(--bg);
|
|
818
|
+
font-size: var(--fs-meta-xs);
|
|
819
|
+
font-variant-numeric: tabular-nums;
|
|
820
|
+
text-align: center;
|
|
821
|
+
}
|
|
822
|
+
.inbox-count:empty { display: none; }
|
|
823
|
+
body.inbox-view-active #input-area { display: none; }
|
|
824
|
+
body.inbox-view-active #pending-question-banner { display: none; }
|
|
825
|
+
.inbox-view { display: grid; gap: 14px; padding: 4px 0 24px; }
|
|
826
|
+
.inbox-filter { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
827
|
+
.inbox-filter-option {
|
|
828
|
+
padding: 6px 12px;
|
|
829
|
+
border: 1px solid var(--border);
|
|
830
|
+
border-radius: 999px;
|
|
831
|
+
background: var(--bg2);
|
|
832
|
+
color: var(--text);
|
|
833
|
+
cursor: pointer;
|
|
834
|
+
font-size: var(--fs-meta-sm);
|
|
835
|
+
}
|
|
836
|
+
.inbox-filter-option.active { border-color: var(--accent); color: var(--accent); }
|
|
837
|
+
.inbox-relay { display: grid; gap: 10px; }
|
|
838
|
+
.inbox-relay-name { margin: 6px 0 0; font-size: var(--fs-meta-md); color: var(--muted); font-weight: 600; }
|
|
839
|
+
.inbox-relay-unreachable, .inbox-empty { margin: 0; color: var(--muted); font-size: var(--fs-meta-sm); }
|
|
840
|
+
.inbox-divider {
|
|
841
|
+
margin-top: 6px;
|
|
842
|
+
padding-top: 8px;
|
|
843
|
+
border-top: 1px dashed var(--border);
|
|
844
|
+
color: var(--muted);
|
|
845
|
+
font-size: var(--fs-meta-xs);
|
|
846
|
+
text-transform: uppercase;
|
|
847
|
+
letter-spacing: 0.04em;
|
|
848
|
+
}
|
|
849
|
+
.inbox-item {
|
|
850
|
+
display: grid;
|
|
851
|
+
gap: 8px;
|
|
852
|
+
min-width: 0;
|
|
853
|
+
padding: 12px 14px;
|
|
854
|
+
border: 1px solid var(--border);
|
|
855
|
+
border-radius: 10px;
|
|
856
|
+
background: var(--bg2);
|
|
857
|
+
}
|
|
858
|
+
.inbox-item[data-status="answered"], .inbox-item[data-status="dismissed"] { opacity: 0.75; }
|
|
859
|
+
.inbox-item-head { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; color: var(--muted); font-size: var(--fs-meta-xs); }
|
|
860
|
+
.inbox-item-kind { font-weight: 600; color: var(--accent); }
|
|
861
|
+
.inbox-item-urgent { color: var(--danger); font-weight: 600; text-transform: uppercase; }
|
|
862
|
+
.inbox-item-relay { padding: 0 6px; border: 1px solid var(--border); border-radius: 999px; }
|
|
863
|
+
.inbox-item-session { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; font-size: var(--fs-meta-sm); }
|
|
864
|
+
.inbox-open-session {
|
|
865
|
+
padding: 0;
|
|
866
|
+
border: 0;
|
|
867
|
+
background: none;
|
|
868
|
+
color: var(--accent);
|
|
869
|
+
cursor: pointer;
|
|
870
|
+
font: inherit;
|
|
871
|
+
text-align: left;
|
|
872
|
+
text-decoration: underline;
|
|
873
|
+
}
|
|
874
|
+
.inbox-open-there { color: var(--accent); }
|
|
875
|
+
.inbox-item-asker { color: var(--muted); }
|
|
876
|
+
.inbox-item-header { font-weight: 600; font-size: var(--fs-meta-sm); }
|
|
877
|
+
.inbox-item-question, .inbox-item-recommendation { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
878
|
+
.inbox-item-recommendation { color: var(--muted); font-size: var(--fs-meta-sm); }
|
|
879
|
+
.inbox-item-detail summary { cursor: pointer; color: var(--muted); font-size: var(--fs-meta-sm); }
|
|
880
|
+
.inbox-item-detail-text {
|
|
881
|
+
margin: 6px 0 0;
|
|
882
|
+
padding: 8px 10px;
|
|
883
|
+
max-height: 320px;
|
|
884
|
+
overflow: auto;
|
|
885
|
+
border-radius: 8px;
|
|
886
|
+
background: var(--bg3);
|
|
887
|
+
white-space: pre-wrap;
|
|
888
|
+
overflow-wrap: anywhere;
|
|
889
|
+
font-size: var(--fs-meta-sm);
|
|
890
|
+
}
|
|
891
|
+
.inbox-item-outcome { color: var(--success); font-size: var(--fs-meta-sm); overflow-wrap: anywhere; }
|
|
892
|
+
.inbox-item-options, .inbox-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
893
|
+
.inbox-item-reply { display: grid; gap: 8px; }
|
|
894
|
+
.inbox-answer-text {
|
|
895
|
+
width: 100%;
|
|
896
|
+
box-sizing: border-box;
|
|
897
|
+
padding: 8px 10px;
|
|
898
|
+
border: 1px solid var(--border);
|
|
899
|
+
border-radius: 8px;
|
|
900
|
+
background: var(--bg);
|
|
901
|
+
color: var(--text);
|
|
902
|
+
font: inherit;
|
|
903
|
+
resize: vertical;
|
|
904
|
+
}
|
|
905
|
+
.inbox-choice, .inbox-answer, .inbox-dismiss, .inbox-open-session-button, .inbox-open-there-button {
|
|
906
|
+
min-height: 36px;
|
|
907
|
+
padding: 6px 14px;
|
|
908
|
+
border: 1px solid var(--border);
|
|
909
|
+
border-radius: 8px;
|
|
910
|
+
background: var(--bg3);
|
|
911
|
+
color: var(--text);
|
|
912
|
+
cursor: pointer;
|
|
913
|
+
font: inherit;
|
|
914
|
+
text-decoration: none;
|
|
915
|
+
display: inline-flex;
|
|
916
|
+
align-items: center;
|
|
917
|
+
gap: 6px;
|
|
918
|
+
}
|
|
919
|
+
.inbox-choice.recommended { border-color: var(--accent); }
|
|
920
|
+
.inbox-recommended-tag { color: var(--accent); font-size: var(--fs-meta-xs); }
|
|
921
|
+
.inbox-answer { border-color: var(--accent); color: var(--accent); }
|
|
922
|
+
.inbox-dismiss { color: var(--muted); }
|
|
923
|
+
.inbox-view button:disabled, .relay-ask-container button:disabled { opacity: 0.5; cursor: wait; }
|
|
924
|
+
.relay-ask-container { max-width: 760px; }
|
|
925
|
+
/* The phone: one column, buttons the full width. */
|
|
926
|
+
@media (max-width: 680px) {
|
|
927
|
+
#inbox-btn { min-width: 64px; padding: 9px 10px; }
|
|
928
|
+
.inbox-item-options, .inbox-item-actions { flex-direction: column; }
|
|
929
|
+
.inbox-choice, .inbox-answer, .inbox-dismiss, .inbox-open-session-button, .inbox-open-there-button {
|
|
930
|
+
width: 100%;
|
|
931
|
+
justify-content: center;
|
|
932
|
+
}
|
|
933
|
+
.inbox-item { padding: 12px; }
|
|
934
|
+
}
|
|
935
|
+
|
|
791
936
|
#conv-list { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
|
|
792
937
|
.conv-list-footer {
|
|
793
938
|
padding: 10px 12px 6px;
|
|
@@ -2508,6 +2653,9 @@
|
|
|
2508
2653
|
.ctx-attribution-select { font-size: var(--fs-meta-sm); max-width: 100%; }
|
|
2509
2654
|
.ctx-attribution-note { margin-top: 6px; font-size: var(--fs-meta-sm); color: var(--muted); line-height: 1.4; overflow-wrap: anywhere; }
|
|
2510
2655
|
.ctx-attribution-note code { font-size: inherit; }
|
|
2656
|
+
.ctx-agent-sessions { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 6px; }
|
|
2657
|
+
.ctx-agent-sessions-row { display: flex; align-items: flex-start; gap: 8px; font-size: 0.85rem; cursor: pointer; }
|
|
2658
|
+
.ctx-agent-sessions-row input { margin-top: 2px; }
|
|
2511
2659
|
.ctx-thinking {
|
|
2512
2660
|
margin-top: 14px;
|
|
2513
2661
|
padding-top: 12px;
|
|
@@ -3789,11 +3937,17 @@
|
|
|
3789
3937
|
font-size: var(--fs-meta-xs); line-height: 1.2; color: var(--muted); position: relative;
|
|
3790
3938
|
}
|
|
3791
3939
|
#chat-title-relay-events[hidden] { display: none; }
|
|
3940
|
+
#chat-title-relay-schedules {
|
|
3941
|
+
display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 2px;
|
|
3942
|
+
font-size: var(--fs-meta-xs); line-height: 1.2; color: var(--muted); position: relative;
|
|
3943
|
+
}
|
|
3944
|
+
#chat-title-relay-schedules[hidden] { display: none; }
|
|
3792
3945
|
.relay-watch-panel[hidden] { display: none; }
|
|
3793
3946
|
.relay-watch-chip {
|
|
3794
3947
|
font: inherit; color: var(--accent); background: transparent; cursor: pointer;
|
|
3795
3948
|
border: 1px solid var(--border); border-radius: 10px; padding: 2px 8px;
|
|
3796
3949
|
}
|
|
3950
|
+
.relay-watch-hint { font-size: var(--fs-meta-xs); color: var(--muted); }
|
|
3797
3951
|
.relay-watch-panel {
|
|
3798
3952
|
position: fixed; top: 0; left: 0; z-index: 60; overflow-y: auto; box-sizing: border-box;
|
|
3799
3953
|
min-width: min(260px, calc(100vw - 16px)); max-width: min(calc(100vw - 16px), 480px);
|
|
@@ -3809,11 +3963,13 @@
|
|
|
3809
3963
|
.relay-watch-item:last-child { border-bottom: none; }
|
|
3810
3964
|
.relay-watch-what { color: var(--text, inherit); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
3811
3965
|
.relay-watch-events { grid-column: 1; font-size: var(--fs-ui-chip); }
|
|
3812
|
-
.relay-watch-unsubscribe
|
|
3966
|
+
.relay-watch-unsubscribe,
|
|
3967
|
+
.relay-schedule-cancel {
|
|
3813
3968
|
grid-column: 2; grid-row: 1 / span 2; font: inherit; cursor: pointer;
|
|
3814
3969
|
color: var(--danger); background: transparent; border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px;
|
|
3815
3970
|
}
|
|
3816
|
-
.conv-watched-badge
|
|
3971
|
+
.conv-watched-badge,
|
|
3972
|
+
.conv-scheduled-badge {
|
|
3817
3973
|
flex: 0 0 auto; font-size: var(--fs-ui-chip); color: var(--accent);
|
|
3818
3974
|
border: 1px solid var(--border); border-radius: 8px; padding: 0 5px;
|
|
3819
3975
|
}
|
|
@@ -6876,6 +7032,7 @@
|
|
|
6876
7032
|
<span id="cli-dot" title="CLI status"></span>
|
|
6877
7033
|
<button id="new-conv-btn" onclick="newConversation()">+ New Chat</button>
|
|
6878
7034
|
<button id="status-btn" type="button" onclick="window.toggleStatusView()">Status</button>
|
|
7035
|
+
<button id="inbox-btn" type="button" onclick="window.toggleInboxView()" aria-label="Inbox" aria-pressed="false">Inbox<span id="inbox-count" class="inbox-count"></span></button>
|
|
6879
7036
|
<div id="sidebar-actions">
|
|
6880
7037
|
<button id="install-btn" class="header-icon-btn" onclick="promptInstallApp()" title="Install app to home screen" aria-label="Install app">⬇</button>
|
|
6881
7038
|
</div>
|