@gotcos/glasses-server 6.99.16 → 6.99.25
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 +137 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +23 -1
- package/server/lib/ask-work-plan.ts +116 -0
- package/server/lib/batch-transcript-quality.ts +2 -0
- package/server/lib/drop-store.ts +16 -2
- package/server/lib/meeting-ask-runtime.ts +1 -1
- package/server/lib/meeting-batch-transcribe.ts +40 -1
- package/server/lib/ping-sources.ts +6 -2
- package/server/lib/prep-runtime.ts +915 -0
- package/server/lib/prep-store.ts +226 -0
- package/server/lib/stt-resolver.ts +26 -2
- package/server/lib/transcript-cleanup.ts +11 -2
- package/server/lib/web-control-static.ts +226 -0
- package/server/lib/web-memories-bridge.ts +9 -8
- package/server/lib/web-pet-choice.ts +225 -0
- package/server/lib/web-pet.ts +226 -0
- package/server/lib/work-lens-rounds.ts +29 -4
- package/server/lib/work-orchestrator/runtime.ts +8 -2
- package/server/routes/ask-work-plan.ts +114 -0
- package/server/routes/health.ts +8 -0
- package/server/routes/prep.ts +81 -0
- package/server/routes/web-control.ts +110 -7
- package/server/routes/welcome-context.ts +29 -1
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/routes/work-orchestrator.ts +57 -0
- package/server/web-control/app.css +24 -5
- package/server/web-control/app.js +646 -108
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +27 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +26 -0
- package/server/web-control/panes/cos-chat.js +641 -6
- package/server/web-control/panes/meetings.css +45 -2
- package/server/web-control/panes/meetings.js +230 -7
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.js +59 -21
- package/server/web-control/panes/settings.js +102 -4
- package/server/web-control/panes/today-actions.css +109 -0
- package/server/web-control/panes/today-actions.js +972 -0
- package/server/web-control/panes/work.css +54 -0
- package/server/web-control/panes/work.js +1037 -18
- package/server/web-control/shell.js +6 -2
- package/server/web-control/theme.js +0 -2
- package/shared/meeting-ask.ts +6 -2
- package/shared/prep.ts +317 -0
- package/server/web-control/memories/memories-app.js +0 -1561
- package/server/web-control/memories/memories-theme.css +0 -73
- package/server/web-control/memories/memories.html +0 -72
- package/server/web-control/memories/memory-stewardship.js +0 -63
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
/* COS Control Web: Today as a place to act (Miles, 11/06 23:14: "rather than always kicking the user off into the tab
|
|
2
|
+
... on click it opened up an overlay on the today panel that allowed me to see and interact with and make a
|
|
3
|
+
decision that pushed it inside of its actual tab").
|
|
4
|
+
|
|
5
|
+
A tap on Today opens the item in an overlay on Today: a bottom sheet on the folded phone, a side sheet from 600 px.
|
|
6
|
+
- Needs you: what the session waits on. A session question or approval is the same card questions.js draws, with the
|
|
7
|
+
same answer route (POST /api/session-questions/:id/answer), drawn here through its mount(). The session's own
|
|
8
|
+
status line and its last reply. Open session stays a link to the full reader.
|
|
9
|
+
- Running: a peek at what the session is doing now, read with the reader's own activity reducer (applyFeed) from the
|
|
10
|
+
same live stream, and a short note box. Open session goes to the reader.
|
|
11
|
+
What a reply or a note shares with the Sessions reader, and what is this file's own copy:
|
|
12
|
+
- Shared (app.js, through COSWeb.sessionKit): the attach, turn and queue path itself (continueSession and parkTurn,
|
|
13
|
+
run with this overlay as their view), the per-browser binding store, one key per draft, the attachability probe
|
|
14
|
+
(sxProbeAttach) and the live feed's reducer. The server's refusals arrive word for word.
|
|
15
|
+
- Copied here, kept in step by hand: the send gates (sendReply mirrors sendSessionTurn: one send at a time, the
|
|
16
|
+
caution confirm, a lost answer becomes Try again), the turn watch (watchTurn polls the same route for the same
|
|
17
|
+
three minutes as the reader's watchTurn), and the refusal box (paintVerdict mirrors renderVerdict, with Open session
|
|
18
|
+
in place of Fork and Refresh).
|
|
19
|
+
- From today's meetings: each suggestion with the decisions COS Control offers on Today (Create task, Dismiss, Open
|
|
20
|
+
in Work, Open card) and Work's Link for a suggested link, through Work's own route (POST
|
|
21
|
+
/api/work-intake/:id/resolve), one decision at a time as Control does. The Today card's count moves at once.
|
|
22
|
+
Close with the X, a tap outside, Escape, or Back (the overlay holds one history entry of its own). Focus moves in and
|
|
23
|
+
comes back to the row. The page behind is inert and does not scroll.
|
|
24
|
+
|
|
25
|
+
Also the summary COS gets when Miles asks from Today (summary(): app.js askCos passes it as the Ask's reference).
|
|
26
|
+
Loaded on the first tap or once the page is idle (app.js ensurePane, #lazy-panes). */
|
|
27
|
+
(function () {
|
|
28
|
+
'use strict';
|
|
29
|
+
var W = window.COSWeb;
|
|
30
|
+
if (!W || !W.today || !W.sessionKit) return;
|
|
31
|
+
var T = W.today, K = W.sessionKit, esc = W.esc, api = W.api;
|
|
32
|
+
|
|
33
|
+
var UNDO_SECONDS = 8; // TodayDismissals.undoSeconds
|
|
34
|
+
var STALE_MS = 60000; // a decision the server dated a minute before the tap was made elsewhere
|
|
35
|
+
var WATCH_MS = 180000; // follow a sent turn for three minutes, as the reader does
|
|
36
|
+
// The Today summary's cap, read by app.js askCos's callers and the tests (registered as summaryMax). The marker that
|
|
37
|
+
// says something was left out counts against it.
|
|
38
|
+
var SUMMARY_MAX = 4000;
|
|
39
|
+
var SUMMARY_MORE = '[More is on Today than fits here.]';
|
|
40
|
+
var X_ICON = '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></svg>';
|
|
41
|
+
// Work's words for an intake refusal, by the code the server sends: a verbatim copy of panes/work.js INTAKE_REFUSALS
|
|
42
|
+
// (Work loads lazily, so the overlay cannot read it there). today-actions.test.ts fails if the two differ.
|
|
43
|
+
// intake_not_found never shows: a suggestion that is gone reads as handled on another screen (GONE).
|
|
44
|
+
var REFUSALS = {
|
|
45
|
+
meeting_identity_changed: 'That meeting was renamed or merged after it was scored. Dismiss this item; the renamed meeting is checked again.',
|
|
46
|
+
meeting_unavailable: 'That meeting can’t be found; it may have been renamed or removed. Dismiss this item.',
|
|
47
|
+
intake_task_changed: 'That task changed or was closed since it was suggested. Dismiss this suggestion.',
|
|
48
|
+
meeting_links_full: 'That task already links eight meetings. Dismiss this suggestion.',
|
|
49
|
+
work_board_read_only: 'This server can’t write Work links yet.',
|
|
50
|
+
card_creation_unavailable: 'This server can’t create Work cards yet. Update the server.',
|
|
51
|
+
task_bridge_unavailable: 'The task bridge didn’t answer. Try again in a moment.',
|
|
52
|
+
intake_text_collision: 'The same words are on closed or archived work. Reopen that task instead, or dismiss this item.',
|
|
53
|
+
intake_busy: 'That item is already being saved.', intake_not_found: 'That item is no longer in Intake. Refresh.',
|
|
54
|
+
invalid_intake_action: 'Refresh Intake and try again.', invalid_intake_id: 'Refresh Intake and try again.',
|
|
55
|
+
intake_store_full: 'Intake is full. Dismiss older items, then try again.',
|
|
56
|
+
intake_store_closed: 'Intake is unavailable while the server restarts. Try again shortly.',
|
|
57
|
+
work_intake_unavailable: 'Intake is unavailable right now. Try again shortly.' };
|
|
58
|
+
var GONE = 'This suggestion is gone. It was handled on another screen.';
|
|
59
|
+
// The decision words, as COS Control's Today says them (todaySuggestionRow). One place each, so a wording change is
|
|
60
|
+
// one edit.
|
|
61
|
+
var CREATE_LABEL = 'Create task', DISMISS_LABEL = 'Dismiss';
|
|
62
|
+
|
|
63
|
+
var O = {
|
|
64
|
+
open: null, // { kind: 'need' | 'run' | 'meeting' | 'intake', key, all }
|
|
65
|
+
opener: null, openerSel: '',
|
|
66
|
+
pushed: false, ignorePops: 0,
|
|
67
|
+
dirty: false, // something was decided or sent: Today reloads on close
|
|
68
|
+
// From today's meetings
|
|
69
|
+
busy: '', // the one suggestion being decided (Control: one at a time)
|
|
70
|
+
armedAt: {}, // the one armed confirm: 'dismiss:<id>' | 'stop' | 'caution' -> when (confirmTap)
|
|
71
|
+
rows: {}, // id -> { state, note, undo, taskId, item, asked, until, undoing, undoNote }
|
|
72
|
+
shown: {}, // ids drawn in this overlay, so one that vanishes reads as handled elsewhere
|
|
73
|
+
loop: null, // the undo route exists on this server (GET /api/work-intake/decisions), null = not asked
|
|
74
|
+
// Needs you and Running
|
|
75
|
+
r: null, // the session object the reader's send path takes (sessionKit.session)
|
|
76
|
+
row: null, // the session's row in the session list
|
|
77
|
+
item: null, // the Needs you item
|
|
78
|
+
stream: null, clock: null, watch: null, qMount: null, cautionAsk: false, note: { text: '', kind: '' },
|
|
79
|
+
last: null, // { text, at } the session's last reply
|
|
80
|
+
lastState: 'idle', // idle | loading | ready | error | none
|
|
81
|
+
needGone: false
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function $(id) { return document.getElementById(id); }
|
|
85
|
+
// Per session, in this browser: the words in the box, and a send whose answer has not come ({text, key}), kept apart
|
|
86
|
+
// from the Sessions reader's store. A week old and they go.
|
|
87
|
+
var DRAFTS = 'cos.web.today.drafts', DRAFT_DAYS = 7;
|
|
88
|
+
function draftKey(r) { return r.provider + '|' + r.id; }
|
|
89
|
+
function draftOf(r) {
|
|
90
|
+
var d = W.readStore(DRAFTS)[draftKey(r)];
|
|
91
|
+
return d && typeof d === 'object' && Date.now() - (Number(d.at) || 0) < DRAFT_DAYS * 86400000 ? d : null;
|
|
92
|
+
}
|
|
93
|
+
// `unsettled` undefined keeps what is stored; null clears it.
|
|
94
|
+
function keepDraft(r, text, unsettled) {
|
|
95
|
+
var all = W.readStore(DRAFTS), k = draftKey(r), now = Date.now(), prev = all[k] || {};
|
|
96
|
+
Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) >= DRAFT_DAYS * 86400000) delete all[x]; });
|
|
97
|
+
var u = unsettled === undefined ? prev.unsettled || null : unsettled ? { text: unsettled.text, key: unsettled.key } : null;
|
|
98
|
+
var t = String(text || '');
|
|
99
|
+
if (!t.trim() && !u) delete all[k]; else all[k] = { text: t, unsettled: u, at: now };
|
|
100
|
+
W.writeStore(DRAFTS, all);
|
|
101
|
+
}
|
|
102
|
+
function str(v) { return typeof v === 'string' ? v : ''; }
|
|
103
|
+
function clip(text, n) { var s = String(text || '').replace(/\s+/g, ' ').trim(); return s.length <= n ? s : s.slice(0, n - 1).replace(/\s+$/, '') + '\u2026'; }
|
|
104
|
+
function lower(s) { return String(s || '').toLowerCase(); }
|
|
105
|
+
function phone() { return W.isPhone ? W.isPhone() : true; }
|
|
106
|
+
|
|
107
|
+
// ── the layer ──────────────────────────────────────────
|
|
108
|
+
var layer = null;
|
|
109
|
+
function build() {
|
|
110
|
+
if (layer) return layer;
|
|
111
|
+
layer = document.createElement('div');
|
|
112
|
+
layer.id = 'ta-layer';
|
|
113
|
+
layer.className = 'ta-layer';
|
|
114
|
+
layer.hidden = true;
|
|
115
|
+
layer.innerHTML = '<div class="ta-scrim" data-ta="close" aria-hidden="true"></div>' +
|
|
116
|
+
'<section class="ta-sheet" id="ta-sheet" role="dialog" aria-modal="true" aria-labelledby="ta-title" tabindex="-1">' +
|
|
117
|
+
'<div class="ta-grip" aria-hidden="true"></div>' +
|
|
118
|
+
'<header class="ta-head"><h2 class="ta-title" id="ta-title"></h2>' +
|
|
119
|
+
'<button type="button" class="ta-x" data-ta="close" aria-label="Close">' + X_ICON + '</button></header>' +
|
|
120
|
+
'<div class="ta-body" id="ta-body"></div>' +
|
|
121
|
+
'<div class="ta-foot" id="ta-foot"></div>' +
|
|
122
|
+
'</section>';
|
|
123
|
+
document.body.appendChild(layer);
|
|
124
|
+
// A tap that lands while the sheet is still opening is the second half of the tap that opened it (qa2-user B1: a
|
|
125
|
+
// double tap on a meeting row dismissed the first suggestion). Caught before anything inside sees it.
|
|
126
|
+
layer.addEventListener('click', function (e) { if (Date.now() < O.settleAt) { e.stopPropagation(); e.preventDefault(); } }, true);
|
|
127
|
+
layer.addEventListener('click', onClick);
|
|
128
|
+
layer.addEventListener('submit', onSubmit);
|
|
129
|
+
layer.addEventListener('focusin', onFocusIn);
|
|
130
|
+
layer.addEventListener('input', function (e) {
|
|
131
|
+
if (e.target && e.target.id === 'ta-input' && O.r) keepDraft(O.r, e.target.value, O.r.draft && O.r.draft.text === e.target.value.trim() ? O.r.draft : undefined);
|
|
132
|
+
});
|
|
133
|
+
// A drag on the dimmed page never scrolls what is behind it.
|
|
134
|
+
layer.addEventListener('touchmove', function (e) { if (e.target && e.target.classList && e.target.classList.contains('ta-scrim')) e.preventDefault(); }, { passive: false });
|
|
135
|
+
return layer;
|
|
136
|
+
}
|
|
137
|
+
// The layer follows the visible viewport, so the on-screen keyboard never covers the reply box or Send.
|
|
138
|
+
function fit() {
|
|
139
|
+
var vv = window.visualViewport;
|
|
140
|
+
if (!layer || !vv) return;
|
|
141
|
+
layer.style.setProperty('--ta-vh', Math.round(vv.height) + 'px');
|
|
142
|
+
layer.style.setProperty('--ta-vt', Math.round(vv.offsetTop) + 'px');
|
|
143
|
+
}
|
|
144
|
+
if (window.visualViewport) { window.visualViewport.addEventListener('resize', fit); window.visualViewport.addEventListener('scroll', fit); }
|
|
145
|
+
|
|
146
|
+
function openerSelector(t) {
|
|
147
|
+
if (!t || !t.getAttribute) return '';
|
|
148
|
+
var kind = t.getAttribute('data-today'), key = t.getAttribute('data-key');
|
|
149
|
+
if (!kind) return '';
|
|
150
|
+
var q = function (v) { return '"' + String(v).replace(/["\\]/g, '\\$&') + '"'; };
|
|
151
|
+
return '[data-today=' + q(kind) + ']' + (key != null ? '[data-key=' + q(key) + ']' : '');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function open(kind, key, opener) {
|
|
155
|
+
build();
|
|
156
|
+
if (O.open) stopSession();
|
|
157
|
+
O.open = { kind: kind, key: String(key || ''), all: kind === 'intake' };
|
|
158
|
+
O.opener = opener || document.activeElement; O.openerSel = openerSelector(opener);
|
|
159
|
+
O.note = { text: '', kind: '' }; O.cautionAsk = false; O.needGone = false; O.armedAt = {};
|
|
160
|
+
O.stopping = false; O.stopKey = '';
|
|
161
|
+
layer.hidden = false;
|
|
162
|
+
document.documentElement.classList.add('ta-open');
|
|
163
|
+
var shell = $('app-shell'); if (shell) shell.setAttribute('inert', '');
|
|
164
|
+
fit();
|
|
165
|
+
// The entry names what is open, so a reload can open it again and Back still closes it (restore below).
|
|
166
|
+
var entry = { cosTodayOverlay: 1, kind: kind, key: O.open.key };
|
|
167
|
+
try {
|
|
168
|
+
if (!O.pushed) { history.pushState(entry, ''); O.pushed = true; } else history.replaceState(entry, '');
|
|
169
|
+
} catch (_e) { /* no history API: Back simply leaves Today */ }
|
|
170
|
+
try {
|
|
171
|
+
if (kind === 'need' || kind === 'run' || kind === 'wait') openSession(kind, O.open.key);
|
|
172
|
+
else openIntake();
|
|
173
|
+
} catch (err) {
|
|
174
|
+
// A bug here must not leave a half-drawn sheet over an inert page: take it down, keep the history entry for
|
|
175
|
+
// app.js openTodayAction to replace, and let it say so and go where the tap went before.
|
|
176
|
+
close('leave');
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
var sheet = $('ta-sheet');
|
|
180
|
+
O.settleAt = Date.now() + settleMs(sheet);
|
|
181
|
+
if (sheet && sheet.focus) sheet.focus({ preventScroll: true });
|
|
182
|
+
}
|
|
183
|
+
// How long taps inside are ignored after opening: the longest open motion of the sheet and its backdrop (their computed
|
|
184
|
+
// animation and transition, delay included: today-actions.css ta-rise or ta-slide, 220 ms, and fade, 160 ms), plus
|
|
185
|
+
// the second half of a double tap. On the folded Duo a second tap 352 to 417 ms after the first still landed on the
|
|
186
|
+
// sheet's links with a 350 ms window (W1-r2), so the margin brings it to 500 ms, and it never goes under that, so
|
|
187
|
+
// reduced motion (no animation) keeps the same protection.
|
|
188
|
+
var SETTLE_EXTRA_MS = 280, SETTLE_MIN_MS = 500;
|
|
189
|
+
function msList(v) {
|
|
190
|
+
return String(v || '').split(',').map(function (x) { x = x.trim(); var n = parseFloat(x); return isFinite(n) ? (/ms$/.test(x) ? n : n * 1000) : 0; });
|
|
191
|
+
}
|
|
192
|
+
function longestMotion(el) {
|
|
193
|
+
var cs; try { cs = getComputedStyle(el); } catch (_e) { return null; }
|
|
194
|
+
if (!cs) return null;
|
|
195
|
+
var most = 0;
|
|
196
|
+
[['animationDuration', 'animationDelay'], ['transitionDuration', 'transitionDelay']].forEach(function (p) {
|
|
197
|
+
var d = msList(cs[p[0]]), w = msList(cs[p[1]]);
|
|
198
|
+
d.forEach(function (x, i) { most = Math.max(most, x + (w[i % (w.length || 1)] || 0)); });
|
|
199
|
+
});
|
|
200
|
+
return most;
|
|
201
|
+
}
|
|
202
|
+
function settleMs(sheet) {
|
|
203
|
+
var scrim = layer && layer.querySelector ? layer.querySelector('.ta-scrim') : null;
|
|
204
|
+
var a = longestMotion(sheet), b = scrim ? longestMotion(scrim) : null;
|
|
205
|
+
var motion = a == null && b == null ? 220 : Math.max(a || 0, b || 0); // no computed style: the CSS default
|
|
206
|
+
return Math.max(SETTLE_MIN_MS, motion + SETTLE_EXTRA_MS);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// how: 'user' (X, outside, Escape), 'pop' (Back), 'leave' (navigating to another pane)
|
|
210
|
+
function close(how) {
|
|
211
|
+
if (!O.open) return;
|
|
212
|
+
stopSession();
|
|
213
|
+
O.open = null;
|
|
214
|
+
if (layer) layer.hidden = true;
|
|
215
|
+
document.documentElement.classList.remove('ta-open');
|
|
216
|
+
var shell = $('app-shell'); if (shell) shell.removeAttribute('inert');
|
|
217
|
+
if (O.pushed && how !== 'pop' && how !== 'leave') { O.pushed = false; O.ignorePops++; try { history.back(); } catch (_e) { O.ignorePops--; } }
|
|
218
|
+
if (how === 'pop' || how === 'leave') O.pushed = false;
|
|
219
|
+
clearTimeout(O.expireTimer);
|
|
220
|
+
// Settled lines are done once the overlay closes; Today reads the server again if anything changed here.
|
|
221
|
+
Object.keys(O.rows).forEach(function (id) { var s = O.rows[id].state; if (s !== 'creating' && s !== 'dismissing') delete O.rows[id]; });
|
|
222
|
+
O.shown = {};
|
|
223
|
+
if (O.dirty) { O.dirty = false; T.refresh(); }
|
|
224
|
+
if (how !== 'leave') restoreFocus();
|
|
225
|
+
}
|
|
226
|
+
function restoreFocus() {
|
|
227
|
+
var el = O.opener;
|
|
228
|
+
if (!el || !el.isConnected) el = O.openerSel ? document.querySelector(O.openerSel) : null;
|
|
229
|
+
if (!el) el = document.querySelector('#pane-today .panel-title');
|
|
230
|
+
O.opener = null;
|
|
231
|
+
if (el && el.focus) { try { el.focus({ preventScroll: true }); } catch (_e) { el.focus(); } }
|
|
232
|
+
}
|
|
233
|
+
// Leaving for a tab from inside the overlay replaces the overlay's history entry, so Back from there returns to Today.
|
|
234
|
+
function leaveTo(pane, args) {
|
|
235
|
+
var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
|
|
236
|
+
var replace = O.pushed;
|
|
237
|
+
close('leave');
|
|
238
|
+
if (replace) location.replace(hash); else W.go(pane, args || []);
|
|
239
|
+
}
|
|
240
|
+
// A reload with the overlay open keeps its history entry, and the entry before it is Today at the same address, so
|
|
241
|
+
// Back from there showed nothing (measured in Chrome 11/07: a same-document popstate). On load, app.js hands that
|
|
242
|
+
// entry here: open the same thing again, so the first Back closes it as it did before the reload. What cannot be
|
|
243
|
+
// opened (gone, or Today left meanwhile) just clears the entry's mark.
|
|
244
|
+
var KINDS = { need: 1, run: 1, wait: 1, meeting: 1, intake: 1 };
|
|
245
|
+
function restore(st) {
|
|
246
|
+
if (O.open || !st || !st.cosTodayOverlay) return Promise.resolve(false);
|
|
247
|
+
var forget = function () { try { history.replaceState(null, ''); } catch (_e) {} return false; };
|
|
248
|
+
if (!KINDS[st.kind]) return Promise.resolve(forget());
|
|
249
|
+
var started = Date.now();
|
|
250
|
+
return new Promise(function (resolve) {
|
|
251
|
+
(function wait() {
|
|
252
|
+
var d = T.data(), ready = d.at > 0 && (st.kind === 'need' || st.kind === 'run' || st.kind === 'wait' || d.intakeLoaded);
|
|
253
|
+
if (W.parseRoute().pane !== 'today' || O.open) return resolve(forget());
|
|
254
|
+
if (!ready) { if (Date.now() - started > 15000) return resolve(forget()); return setTimeout(wait, 150); }
|
|
255
|
+
O.pushed = true; // this entry is the overlay's own
|
|
256
|
+
try { open(st.kind, String(st.key || ''), null); resolve(true); }
|
|
257
|
+
catch (err) { console.error('[today] could not reopen the overlay after a reload.', err); O.pushed = false; resolve(forget()); }
|
|
258
|
+
})();
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
window.addEventListener('popstate', function () {
|
|
262
|
+
if (O.ignorePops > 0) { O.ignorePops--; return; }
|
|
263
|
+
if (O.open) close('pop');
|
|
264
|
+
});
|
|
265
|
+
W.onRoute(function (r) { if (O.open && r.pane !== 'today') close('leave'); });
|
|
266
|
+
// Escape closes the overlay, unless the Ask sheet is open over it (app.js closes that first).
|
|
267
|
+
document.addEventListener('keydown', function (e) {
|
|
268
|
+
if (!O.open || e.key !== 'Escape') return;
|
|
269
|
+
var ask = $('ask-sheet');
|
|
270
|
+
if (ask && !ask.classList.contains('hidden')) return;
|
|
271
|
+
e.preventDefault();
|
|
272
|
+
close('user');
|
|
273
|
+
}, true);
|
|
274
|
+
|
|
275
|
+
// Every tap-then-tap-again in the overlay goes through here: the first tap arms (and disarms anything else), and the
|
|
276
|
+
// second counts only once the first has been on screen COSWeb.confirmReady's moment, so a double tap or a bounce
|
|
277
|
+
// arms and never confirms (W2-r2). A tap too soon leaves it armed.
|
|
278
|
+
function confirmReadyAt(at) { return W.confirmReady ? W.confirmReady(at) : typeof at === 'number' && Date.now() - at >= 400; }
|
|
279
|
+
function confirmTap(key, repaint) {
|
|
280
|
+
var at = O.armedAt[key];
|
|
281
|
+
if (at == null) { O.armedAt = {}; O.armedAt[key] = Date.now(); repaint(); return false; }
|
|
282
|
+
if (!confirmReadyAt(at)) return false;
|
|
283
|
+
O.armedAt = {};
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
function setHead(title) { var h = $('ta-title'); if (h) h.textContent = title; }
|
|
287
|
+
|
|
288
|
+
// ── clicks inside the overlay ─────────────────────────
|
|
289
|
+
function onClick(e) {
|
|
290
|
+
var b = e.target && e.target.closest ? e.target.closest('[data-ta]') : null;
|
|
291
|
+
if (!b || b.disabled) return;
|
|
292
|
+
var what = b.getAttribute('data-ta'), id = b.getAttribute('data-id') || '';
|
|
293
|
+
if (!/^(dismiss|stop|caution-send)$/.test(what) && Object.keys(O.armedAt).length) {
|
|
294
|
+
O.armedAt = {};
|
|
295
|
+
if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) paintIntake(); else paintLive();
|
|
296
|
+
}
|
|
297
|
+
if (what === 'close') return close('user');
|
|
298
|
+
if (what === 'create' || what === 'link') return decide(id, 'accept');
|
|
299
|
+
if (what === 'dismiss') {
|
|
300
|
+
// Without Undo on this server a Dismiss cannot be taken back, so it asks once: tap, then tap again.
|
|
301
|
+
if (O.loop !== true && !confirmTap('dismiss:' + id, paintIntake)) return;
|
|
302
|
+
return decide(id, 'dismiss');
|
|
303
|
+
}
|
|
304
|
+
if (what === 'undo') return undo(id);
|
|
305
|
+
if (what === 'show-all') { O.open.all = true; paintIntake(); return; }
|
|
306
|
+
if (what === 'open-work') return leaveTo('work', ['sort']);
|
|
307
|
+
if (what === 'open-card') return leaveTo('work', id ? [id] : []);
|
|
308
|
+
if (what === 'open-session') return O.r ? leaveTo('sessions', [O.r.provider, O.r.id]) : null;
|
|
309
|
+
if (what === 'send') return sendReply();
|
|
310
|
+
if (what === 'stop') {
|
|
311
|
+
// A tap arms it (Stop ends the agent's turn on your Mac); the second sends it.
|
|
312
|
+
if (!confirmTap('stop', paintLive)) return;
|
|
313
|
+
return stopRun();
|
|
314
|
+
}
|
|
315
|
+
if (what === 'retry') return sendReply();
|
|
316
|
+
if (what === 'caution-send') {
|
|
317
|
+
// Shown by the Send tap: the same tap twice must not answer it.
|
|
318
|
+
if (!confirmReadyAt(O.armedAt.caution)) return;
|
|
319
|
+
if (O.r) O.r.cautionAck = true; O.cautionAsk = false; O.armedAt = {}; return sendReply();
|
|
320
|
+
}
|
|
321
|
+
if (what === 'caution-cancel') { O.cautionAsk = false; return paintVerdict(); }
|
|
322
|
+
if (what === 'send-anyway') { var rev = O.r && O.r.sendAnyway; if (O.r) O.r.sendAnyway = null; return sendReply({ acknowledgedRevision: rev }); }
|
|
323
|
+
if (what === 'more') { var box = $('ta-last-text'); if (box) box.classList.remove('clamped'); b.remove(); }
|
|
324
|
+
}
|
|
325
|
+
function onSubmit(e) {
|
|
326
|
+
if (e.target && e.target.id === 'ta-form') { e.preventDefault(); sendReply(); }
|
|
327
|
+
}
|
|
328
|
+
// The keyboard rises over the bottom of the sheet: keep the box in view as it does.
|
|
329
|
+
function onFocusIn(e) {
|
|
330
|
+
if (!e.target || e.target.id !== 'ta-input') return;
|
|
331
|
+
setTimeout(function () { fit(); var f = $('ta-form'); if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' }); }, 250);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ═════════════ From today's meetings ═════════════
|
|
335
|
+
function intakeList() { return T.data().intake || []; }
|
|
336
|
+
function itemById(id) {
|
|
337
|
+
var hit = intakeList().filter(function (it) { return it.id === id; })[0];
|
|
338
|
+
return hit || (O.rows[id] && O.rows[id].item) || null;
|
|
339
|
+
}
|
|
340
|
+
function textOf(it) { return it.kind === 'link' ? str(it.task && it.task.text) || str(it.text) : str(it.text); }
|
|
341
|
+
// Control's source line (todaySuggestionRow): whose it is and which meeting.
|
|
342
|
+
function sourceOf(it) {
|
|
343
|
+
var meeting = str(it.meeting && it.meeting.title) || 'a meeting today';
|
|
344
|
+
if (it.kind === 'link') return 'Matches the Work card ' + clip(str(it.task && it.task.text) || str(it.pullTitle) || str(it.text), 90) + ', from ' + meeting;
|
|
345
|
+
if (it.ownerIsMiles) return 'Yours, from ' + meeting;
|
|
346
|
+
var owner = str(it.owner).trim();
|
|
347
|
+
return owner ? owner + '\u2019s, from ' + meeting : 'From ' + meeting;
|
|
348
|
+
}
|
|
349
|
+
function caps() { return T.data().intakeCaps || {}; }
|
|
350
|
+
// Control's suggestionAction: a link opens its card (and, as in Work's Sort, can be linked); an ask or a review
|
|
351
|
+
// becomes a task when the server can create cards, else only Open in Work.
|
|
352
|
+
function actionsFor(it) {
|
|
353
|
+
if (it.kind === 'link') return { accept: caps().linkWrites === true ? 'link' : '', acceptLabel: 'Link', card: str(it.task && it.task.workIdentity) };
|
|
354
|
+
return { accept: caps().cardCreation === true ? 'create' : '', acceptLabel: CREATE_LABEL, card: '' };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function openIntake() {
|
|
358
|
+
// Whether this server can undo decides whether Dismiss asks twice; ask before the first tap can land.
|
|
359
|
+
probeLoop().then(function () { if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) paintIntake(); });
|
|
360
|
+
paintIntake();
|
|
361
|
+
var foot = $('ta-foot');
|
|
362
|
+
if (foot) foot.innerHTML = '<div class="ta-links"><button type="button" class="link-btn" data-ta="open-work">Open Sort in Work</button></div>';
|
|
363
|
+
// Read Intake again: what was decided on another screen since Today loaded shows as handled, never as a phantom.
|
|
364
|
+
var asked = T.reloadIntake();
|
|
365
|
+
if (asked && asked.then) asked.then(function () { if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) { noteVanished(); paintIntake(); } });
|
|
366
|
+
}
|
|
367
|
+
function noteVanished() {
|
|
368
|
+
var open = {};
|
|
369
|
+
intakeList().forEach(function (it) { open[it.id] = 1; });
|
|
370
|
+
Object.keys(O.shown).forEach(function (id) {
|
|
371
|
+
if (open[id] || O.rows[id]) return;
|
|
372
|
+
O.rows[id] = { state: 'gone', note: GONE, item: O.shown[id] };
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
// What the overlay lists: open suggestions in the server's order, plus this overlay's settled lines in their place.
|
|
376
|
+
function visibleItems() {
|
|
377
|
+
var list = intakeList().slice();
|
|
378
|
+
var listed = {};
|
|
379
|
+
list.forEach(function (it) { listed[it.id] = 1; });
|
|
380
|
+
Object.keys(O.rows).forEach(function (id) { if (!listed[id] && O.rows[id].item) list.push(O.rows[id].item); });
|
|
381
|
+
list.sort(function (a, b) {
|
|
382
|
+
var x = str(a.meeting && a.meeting.filename) + '|' + a.id, y = str(b.meeting && b.meeting.filename) + '|' + b.id;
|
|
383
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
384
|
+
});
|
|
385
|
+
if (O.open && O.open.kind === 'meeting' && !O.open.all) list = list.filter(function (it) { return str(it.meeting && it.meeting.title) === O.open.key; });
|
|
386
|
+
return list;
|
|
387
|
+
}
|
|
388
|
+
function openCount() { return intakeList().length; }
|
|
389
|
+
|
|
390
|
+
// A row that has left the count: decided here, or gone. One being decided, or refused, still counts (qa2-user N4).
|
|
391
|
+
function settled(id) { var r = O.rows[id]; return !!r && /^(created|dismissed|stale|gone)$/.test(r.state); }
|
|
392
|
+
function rowHtml(it) {
|
|
393
|
+
var row = O.rows[it.id] || { state: 'ready' };
|
|
394
|
+
var acts = actionsFor(it), lock = !!O.busy;
|
|
395
|
+
var text = '<p class="ta-sugg-text">' + esc(textOf(it)) + '</p><p class="ta-sugg-src">' + esc(sourceOf(it)) + '</p>';
|
|
396
|
+
if (it.kind === 'ask' && it.pull && it.pull.title) text += '<p class="ta-sugg-src">' + esc('Your session may already cover this: ' + clip(it.pull.title, 90)) + '</p>';
|
|
397
|
+
var line = '';
|
|
398
|
+
switch (row.state) {
|
|
399
|
+
case 'creating': line = '<span class="ta-wait" role="status">Creating</span>'; break;
|
|
400
|
+
case 'dismissing': line = '<span class="ta-wait" role="status">Dismissing</span>'; break;
|
|
401
|
+
case 'created':
|
|
402
|
+
line = '<span class="ta-done">' + esc(row.linked ? 'Linked. It is in Work.' : 'Created. It is in Work.') + '</span>' +
|
|
403
|
+
'<button type="button" class="link-btn" data-ta="open-card" data-id="' + esc(row.taskId || acts.card) + '">Open card</button>' +
|
|
404
|
+
undoHtml(it, row);
|
|
405
|
+
break;
|
|
406
|
+
case 'dismissed':
|
|
407
|
+
return '<div class="ta-sugg settled" data-row="' + esc(it.id) + '"><span class="ta-done">Dismissed.</span>' + undoHtml(it, row) + '</div>';
|
|
408
|
+
case 'stale': case 'gone':
|
|
409
|
+
return '<div class="ta-sugg settled" data-row="' + esc(it.id) + '">' + text + '<p class="ta-note">' + esc(row.note || GONE) + '</p></div>';
|
|
410
|
+
default: {
|
|
411
|
+
var buttons = '';
|
|
412
|
+
if (acts.accept) buttons += '<button type="button" class="btn small primary" data-ta="' + acts.accept + '" data-id="' + esc(it.id) + '"' + (lock ? ' disabled' : '') + '>' + esc(row.state === 'failed' ? 'Try again' : acts.acceptLabel) + '</button>';
|
|
413
|
+
if (acts.card) buttons += '<button type="button" class="link-btn" data-ta="open-card" data-id="' + esc(acts.card) + '">Open card</button>';
|
|
414
|
+
if (!acts.accept || it.kind !== 'link') buttons += '<button type="button" class="link-btn" data-ta="open-work">Open in Work</button>';
|
|
415
|
+
var armed = O.armedAt['dismiss:' + it.id] != null;
|
|
416
|
+
buttons += '<button type="button" class="link-btn quiet' + (armed ? ' armed' : '') + '" data-ta="dismiss" data-id="' + esc(it.id) + '"' + (lock ? ' disabled' : '') + ' aria-label="' + esc((armed ? 'Tap again to dismiss: ' : 'Dismiss suggestion: ') + clip(textOf(it), 80)) + '" title="Not a task: it leaves Today and does not move to Work at midnight">' + (armed ? 'Tap again to dismiss' : DISMISS_LABEL) + '</button>';
|
|
417
|
+
line = buttons;
|
|
418
|
+
if (row.note) text += '<p class="ta-note" role="alert">' + esc(row.note) + '</p>';
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return '<div class="ta-sugg" data-row="' + esc(it.id) + '">' + text + '<div class="ta-acts">' + line + '</div></div>';
|
|
422
|
+
}
|
|
423
|
+
function undoHtml(it, row) {
|
|
424
|
+
if (O.loop !== true || !row.undo) return '';
|
|
425
|
+
if (row.undoing) return '<span class="ta-wait" role="status">Undoing</span>';
|
|
426
|
+
return '<button type="button" class="link-btn" data-ta="undo" data-id="' + esc(it.id) + '" aria-label="' + esc('Undo: ' + clip(textOf(it), 80)) + '">Undo</button>' +
|
|
427
|
+
(row.undoNote ? '<span class="ta-note">' + esc(row.undoNote) + '</span>' : '');
|
|
428
|
+
}
|
|
429
|
+
function intakeHtml() {
|
|
430
|
+
var list = visibleItems(), n = openCount();
|
|
431
|
+
var groups = [], byTitle = {};
|
|
432
|
+
list.forEach(function (it) {
|
|
433
|
+
var t = str(it.meeting && it.meeting.title) || 'A meeting';
|
|
434
|
+
if (!byTitle[t]) { byTitle[t] = { title: t, items: [] }; groups.push(byTitle[t]); }
|
|
435
|
+
byTitle[t].items.push(it);
|
|
436
|
+
O.shown[it.id] = it;
|
|
437
|
+
});
|
|
438
|
+
var single = O.open && O.open.kind === 'meeting' && !O.open.all;
|
|
439
|
+
var head = '<p class="ta-sub">' + esc(n === 0 ? 'Nothing from today\u2019s meetings is waiting. What you decided is in Work.'
|
|
440
|
+
: (single ? (function () { var k = list.filter(function (it) { return !settled(it.id); }).length; return k + ' from this meeting, ' + n + ' from today in all.'; })() : n + (n === 1 ? ' suggestion' : ' suggestions') + ' from today\u2019s meetings.') +
|
|
441
|
+
' They move to Work at midnight.') + '</p>';
|
|
442
|
+
var body = groups.map(function (g) {
|
|
443
|
+
return (single ? '' : '<h3 class="ta-group">' + esc(g.title) + '</h3>') + g.items.map(rowHtml).join('');
|
|
444
|
+
}).join('');
|
|
445
|
+
if (!list.length && n > 0 && single) body = '<p class="ta-sub">Nothing left from this meeting.</p>';
|
|
446
|
+
if (!T.data().intakeLoaded) body = '<p class="ta-sub">Reading today\u2019s suggestions\u2026</p>';
|
|
447
|
+
var more = single && n > list.filter(function (it) { return !settled(it.id); }).length ? '<button type="button" class="link-btn" data-ta="show-all">Show all ' + n + ' from today</button>' : '';
|
|
448
|
+
return head + '<div class="ta-list">' + body + '</div>' + more;
|
|
449
|
+
}
|
|
450
|
+
function paintIntake() {
|
|
451
|
+
if (!O.open || (O.open.kind !== 'meeting' && O.open.kind !== 'intake')) return;
|
|
452
|
+
var single = O.open.kind === 'meeting' && !O.open.all;
|
|
453
|
+
setHead(single ? O.open.key : 'From today\u2019s meetings');
|
|
454
|
+
var body = $('ta-body'); if (body) body.innerHTML = intakeHtml();
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// One decision at a time (Control: one resolve, its busy ids): every other button waits for it, and the same tap
|
|
458
|
+
// twice sends once. Accept and dismiss go through Work's own route.
|
|
459
|
+
function decide(id, action) {
|
|
460
|
+
if (O.busy || !id) return;
|
|
461
|
+
var it = itemById(id); if (!it) return;
|
|
462
|
+
var row = O.rows[id];
|
|
463
|
+
if (row && /^(created|dismissed|stale|gone)$/.test(row.state)) return;
|
|
464
|
+
if (action === 'accept' && !actionsFor(it).accept) return;
|
|
465
|
+
var asked = row && row.asked && row.action === action ? row.asked : Date.now();
|
|
466
|
+
O.busy = id;
|
|
467
|
+
O.rows[id] = { state: action === 'accept' ? 'creating' : 'dismissing', item: it, asked: asked, action: action };
|
|
468
|
+
paintIntake();
|
|
469
|
+
return api('/api/work-intake/' + encodeURIComponent(id) + '/resolve', { method: 'POST', body: { action: action }, timeoutMs: 75000 }).then(function (d) {
|
|
470
|
+
O.busy = '';
|
|
471
|
+
var got = (d && d.item) || {}, res = got.resolution || {};
|
|
472
|
+
var at = Date.parse(str(res.at));
|
|
473
|
+
var mine = res.action === action && !(isFinite(at) && at < asked - STALE_MS);
|
|
474
|
+
O.dirty = true;
|
|
475
|
+
T.markGone(id, true);
|
|
476
|
+
workChanged();
|
|
477
|
+
if (!res.action || mine) {
|
|
478
|
+
var undoId = res.at ? id + ':' + res.at : '';
|
|
479
|
+
if (action === 'accept') O.rows[id] = { state: 'created', item: it, taskId: str(res.taskId), linked: it.kind === 'link', undo: undoId };
|
|
480
|
+
else { O.rows[id] = { state: 'dismissed', item: it, undo: undoId, until: Date.now() + UNDO_SECONDS * 1000 }; expireLater(); }
|
|
481
|
+
} else {
|
|
482
|
+
O.rows[id] = { state: 'stale', item: it, note: res.action === 'accept' ? 'Already in Work. It was decided on another screen.' : 'Already dismissed on another screen.' };
|
|
483
|
+
}
|
|
484
|
+
return probeLoop().then(paintIntake);
|
|
485
|
+
}, function (err) {
|
|
486
|
+
O.busy = '';
|
|
487
|
+
var code = err && err.code;
|
|
488
|
+
if (code === 'intake_not_found' || (err && err.status === 404 && code !== 'card_creation_unavailable')) {
|
|
489
|
+
O.rows[id] = { state: 'gone', item: it, note: GONE };
|
|
490
|
+
O.dirty = true; T.markGone(id, true);
|
|
491
|
+
} else {
|
|
492
|
+
var why = (code && REFUSALS[code]) || (err && err.message) || 'That did not go through.';
|
|
493
|
+
if (err && err.code === 'timeout') why = 'Your Mac did not answer in time. It may still have saved it. Trying again is safe.';
|
|
494
|
+
O.rows[id] = { state: action === 'accept' ? 'failed' : 'ready', item: it, note: why, asked: asked, action: action };
|
|
495
|
+
}
|
|
496
|
+
paintIntake();
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
function expireLater() {
|
|
500
|
+
clearTimeout(O.expireTimer);
|
|
501
|
+
O.expireTimer = setTimeout(function () {
|
|
502
|
+
var now = Date.now(), left = false;
|
|
503
|
+
Object.keys(O.rows).forEach(function (id) {
|
|
504
|
+
var r = O.rows[id];
|
|
505
|
+
if (r.state !== 'dismissed' || r.undoing) return;
|
|
506
|
+
if (r.until <= now) delete O.rows[id]; else left = true;
|
|
507
|
+
});
|
|
508
|
+
paintIntake();
|
|
509
|
+
if (left) expireLater();
|
|
510
|
+
}, UNDO_SECONDS * 1000 + 250);
|
|
511
|
+
}
|
|
512
|
+
// Undo shows only on a server that has Work's undo route (Control's probeWorkLoopIfNeeded; panes/work.js probeLoop).
|
|
513
|
+
function probeLoop() {
|
|
514
|
+
if (O.loop !== null) return Promise.resolve();
|
|
515
|
+
return api('/api/work-intake/decisions').then(function () { O.loop = true; }, function (err) { if (err && err.status === 404) O.loop = false; });
|
|
516
|
+
}
|
|
517
|
+
function undo(id) {
|
|
518
|
+
var row = O.rows[id];
|
|
519
|
+
if (!row || !row.undo || row.undoing || O.busy) return;
|
|
520
|
+
row.undoing = true; row.undoNote = '';
|
|
521
|
+
paintIntake();
|
|
522
|
+
return api('/api/work-intake/undo', { method: 'POST', body: { batchId: row.undo } }).then(function () {
|
|
523
|
+
delete O.rows[id];
|
|
524
|
+
T.markGone(id, false);
|
|
525
|
+
O.dirty = true; workChanged();
|
|
526
|
+
return T.reloadIntake();
|
|
527
|
+
}, function (err) {
|
|
528
|
+
row.undoing = false;
|
|
529
|
+
if (err && err.status === 404) O.loop = false;
|
|
530
|
+
row.undoNote = err && err.status === 404 ? 'Undo needs a newer COS server. Change it in Work.' : (err && err.message) || 'Undo did not go through.';
|
|
531
|
+
if (row.state === 'dismissed') { row.until = Date.now() + UNDO_SECONDS * 1000; expireLater(); }
|
|
532
|
+
}).then(paintIntake);
|
|
533
|
+
}
|
|
534
|
+
// Work's own copy of Intake and the board are stale now: it reads them again the next time it opens.
|
|
535
|
+
function workChanged() {
|
|
536
|
+
var work = W.pane && W.pane('work');
|
|
537
|
+
if (work && work.intakeChanged) { try { work.intakeChanged(); } catch (_e) {} }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ═════════════ Needs you and Running ═════════════
|
|
541
|
+
function sessionRowFor(provider, id) {
|
|
542
|
+
var want = lower(id);
|
|
543
|
+
return (T.data().sessions || []).filter(function (s) {
|
|
544
|
+
var k = T.splitSessionId(s.provider, s.session_id || s.id);
|
|
545
|
+
return lower(k.id) === want && (!provider || k.provider === provider);
|
|
546
|
+
})[0] || null;
|
|
547
|
+
}
|
|
548
|
+
function needById(id) { return (T.data().needs || []).filter(function (it) { return it.id === id; })[0] || null; }
|
|
549
|
+
|
|
550
|
+
function openSession(kind, key) {
|
|
551
|
+
var provider = '', id = '';
|
|
552
|
+
O.item = null;
|
|
553
|
+
O.wait = null;
|
|
554
|
+
if (kind === 'need') {
|
|
555
|
+
O.item = needById(key);
|
|
556
|
+
if (O.item) { var k = T.splitSessionId(O.item.provider, O.item.sessionId); provider = k.provider; id = k.id; }
|
|
557
|
+
} else {
|
|
558
|
+
// A session waiting on you (a Needs you row): Today's own row gives the words, as it listed them.
|
|
559
|
+
if (kind === 'wait') { var nm = T.needs ? T.needs() : null; O.wait = nm ? nm.rows.filter(function (x) { return x.kind === 'session' && x.key === key; })[0] || null : null; }
|
|
560
|
+
var p = key.split('|'); provider = p[0]; id = p.slice(1).join('|');
|
|
561
|
+
}
|
|
562
|
+
O.row = id ? sessionRowFor(provider, id) : null;
|
|
563
|
+
O.r = id ? K.session(provider, id, VIEW) : null;
|
|
564
|
+
// Working only when the session is not waiting on you: a session paused on a question is not at work (W4-r2).
|
|
565
|
+
if (O.r) O.r.live = { working: !!(O.row && O.row.running_active && K.stateOf(O.row) !== 'waiting') };
|
|
566
|
+
// What was typed, and a send whose answer never came, survive a close, Back or reload: the same words resend under
|
|
567
|
+
// the same key (skeptic W3, qa2-user N1).
|
|
568
|
+
var kept = O.r ? draftOf(O.r) : null;
|
|
569
|
+
if (kept && kept.unsettled) O.r.draft = { text: kept.unsettled.text, key: kept.unsettled.key };
|
|
570
|
+
O.last = null; O.lastState = 'idle';
|
|
571
|
+
setHead(kind === 'need' ? (O.item ? str(O.item.sessionTitle) || str(O.item.title) || 'Handoff' : 'Needs you')
|
|
572
|
+
: kind === 'wait' ? (O.wait ? O.wait.title : 'A session is waiting for you') : (O.row ? K.title(O.row) : 'Session'));
|
|
573
|
+
var body = $('ta-body');
|
|
574
|
+
if (body) body.innerHTML = '<div id="ta-meta"></div><div id="ta-q" class="ta-q" hidden></div><div id="ta-live"></div><div id="ta-last"></div>';
|
|
575
|
+
var foot = $('ta-foot');
|
|
576
|
+
if (foot) foot.innerHTML = O.r ? composerHtml(kind) : '';
|
|
577
|
+
var input = $('ta-input');
|
|
578
|
+
if (input && kept && kept.text) { input.value = kept.text; W.autoGrow(input); }
|
|
579
|
+
paintMeta();
|
|
580
|
+
if (!O.r) return;
|
|
581
|
+
if (W.sessionQuestions && W.sessionQuestions.mount) {
|
|
582
|
+
// mount() draws at once and calls back before it returns, so the count comes from the callback, not O.qMount.
|
|
583
|
+
O.qMount = W.sessionQuestions.mount($('ta-q'), id, function (n) { O.qCount = n; paintMeta(); });
|
|
584
|
+
}
|
|
585
|
+
if (kind === 'run') { paintLive(); watchStream(O.r); }
|
|
586
|
+
loadLast(O.r);
|
|
587
|
+
K.probe(O.r);
|
|
588
|
+
if (kind === 'need') recheckNeed(key);
|
|
589
|
+
}
|
|
590
|
+
function stopSession() {
|
|
591
|
+
if (O.stream) { try { O.stream.close(); } catch (_e) {} O.stream = null; }
|
|
592
|
+
clearInterval(O.clock); O.clock = null;
|
|
593
|
+
clearInterval(O.watch); O.watch = null;
|
|
594
|
+
if (O.qMount) { O.qMount.unmount(); O.qMount = null; }
|
|
595
|
+
O.qCount = 0;
|
|
596
|
+
O.r = null; O.row = null; O.item = null;
|
|
597
|
+
}
|
|
598
|
+
// The Needs you list may be minutes old: read it again, and say so if this one is no longer waiting.
|
|
599
|
+
function recheckNeed(key) {
|
|
600
|
+
var r = O.r;
|
|
601
|
+
api('/api/work-board/your-move').then(function (d) {
|
|
602
|
+
if (O.r !== r) return;
|
|
603
|
+
var items = (d && d.items) || [];
|
|
604
|
+
if (d && d.available !== false && !items.some(function (it) { return it.id === key; })) {
|
|
605
|
+
O.needGone = true; paintMeta();
|
|
606
|
+
T.refresh(); // and Today drops its row
|
|
607
|
+
}
|
|
608
|
+
}, function () {});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function composerHtml(kind) {
|
|
612
|
+
var label = kind === 'run' ? 'Send a note to this session' : 'Reply to this session';
|
|
613
|
+
return '<div id="ta-verdict"></div>' +
|
|
614
|
+
'<form class="composer ta-composer" id="ta-form">' +
|
|
615
|
+
'<textarea id="ta-input" rows="1" placeholder="' + esc(label) + '" aria-label="' + esc(label) + '" enterkeyhint="send"></textarea>' +
|
|
616
|
+
'<div class="composer-actions"><button type="submit" class="btn primary small" id="ta-send">Send</button></div>' +
|
|
617
|
+
'</form>' +
|
|
618
|
+
'<p class="status-line" id="ta-status" role="status" aria-live="polite"></p>';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function needLine(it) {
|
|
622
|
+
var why = T.needWhy(it) + (it.domain ? ' in ' + W.domainLabel(it.domain) : '');
|
|
623
|
+
var age = W.ago(it.updatedAt || it.createdAt);
|
|
624
|
+
if (!age) return why;
|
|
625
|
+
if (age === 'now') return why + ', just now';
|
|
626
|
+
if (/ (min|h)$/.test(age)) return why + ', ' + age + ' ago';
|
|
627
|
+
return why + ', since ' + (age === 'Yesterday' ? 'yesterday' : age);
|
|
628
|
+
}
|
|
629
|
+
function stateWords(s) {
|
|
630
|
+
if (!s) return '';
|
|
631
|
+
var st = K.stateOf(s);
|
|
632
|
+
if (st === 'running') return 'Working now, for ' + T.span(s.state_since || s.last_activity_at);
|
|
633
|
+
if (st === 'waiting') { var l = K.stateLabel(s).replace(/^Waiting( · )?/, ''); return 'Waiting for you' + (l ? ': ' + l.replace(/ · /g, ', ') : ''); }
|
|
634
|
+
if (st === 'error') return K.stateLabel(s).replace(/ · /g, ': ');
|
|
635
|
+
if (st === 'stale') return 'Not running on your Mac';
|
|
636
|
+
return s.running ? 'Open and quiet for ' + T.span(s.last_activity_at || s.state_since) : 'Not running right now';
|
|
637
|
+
}
|
|
638
|
+
function runWords() {
|
|
639
|
+
var r = O.r, f = r && r.feed;
|
|
640
|
+
if (f && (f.connected || f.lastSeq > 0)) {
|
|
641
|
+
var w = K.feed.word(r), since = K.feed.elapsed(f);
|
|
642
|
+
if (w === 'Working') return 'Working now' + (since ? ', for ' + since : '');
|
|
643
|
+
if (w === 'Waiting') return 'Waiting for you' + (since ? ', for ' + since : '');
|
|
644
|
+
if (w === 'Failed') return 'Stopped with an error';
|
|
645
|
+
if (w === 'Done') return 'Finished';
|
|
646
|
+
return 'Idle' + (since ? ' for ' + since : '');
|
|
647
|
+
}
|
|
648
|
+
return O.row ? stateWords(O.row) : '';
|
|
649
|
+
}
|
|
650
|
+
function paintMeta() {
|
|
651
|
+
var box = $('ta-meta'); if (!box || !O.open) return;
|
|
652
|
+
var html = '';
|
|
653
|
+
var questions = O.qCount || 0;
|
|
654
|
+
if (O.open.kind === 'need') {
|
|
655
|
+
var it = O.item;
|
|
656
|
+
if (!it) { box.innerHTML = '<p class="ta-note">This is no longer waiting on you. It was handled on another screen.</p>'; return; }
|
|
657
|
+
// Gone: say so in place of the waiting line, never under it (qa2-user N3).
|
|
658
|
+
if (O.needGone) html += '<p class="ta-note" role="status">This is no longer waiting on you. It was handled on another screen.</p>';
|
|
659
|
+
else html += '<p class="ta-sub">' + esc(needLine(it)) + '</p>';
|
|
660
|
+
var card = str(it.title), session = str(it.sessionTitle);
|
|
661
|
+
if (card && card !== session) html += '<p class="ta-card-line">' + esc('Work card: ' + card) + '</p>';
|
|
662
|
+
var p = it.progress && !it.progress.unreadable ? it.progress : null;
|
|
663
|
+
if (p && p.evidence) html += '<blockquote class="ta-quote">' + esc(p.evidence) + '</blockquote>';
|
|
664
|
+
} else if (O.open.kind === 'wait') {
|
|
665
|
+
// A session waiting on you: what it waits on, its question to answer here when the Mac holds one for this page,
|
|
666
|
+
// else its last message and the reply box below. No live peek and no Stop: it is not at work (W4-r2).
|
|
667
|
+
html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : 'claude') + ' <span>' + esc(O.wait ? O.wait.why : 'Waiting for you') + '</span></p>';
|
|
668
|
+
if (!questions) html += '<p class="ta-sub">' + esc('Answer it in a reply below, or open the session.') + '</p>';
|
|
669
|
+
} else if (O.row || O.r) {
|
|
670
|
+
// Once the live feed speaks, the line says what it says, so the header and the peek never disagree (qa2-user N3).
|
|
671
|
+
html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : O.row.provider) + ' <span id="ta-run-state">' + esc(runWords()) + '</span></p>';
|
|
672
|
+
}
|
|
673
|
+
if (O.row && O.open.kind === 'need') html += '<p class="ta-sub">' + esc(stateWords(O.row)) + '</p>';
|
|
674
|
+
if (questions) html += '<p class="ta-ask-line">' + esc(questions === 1 ? 'It is asking you this now. Answer here.' : 'It is asking you ' + questions + ' things now. Answer here.') + '</p>';
|
|
675
|
+
var links = '';
|
|
676
|
+
if (O.r) links += '<button type="button" class="link-btn" data-ta="open-session">Open session</button>';
|
|
677
|
+
if (O.open.kind === 'need' && O.item && O.item.workIdentity) links += '<button type="button" class="link-btn" data-ta="open-card" data-id="' + esc(O.item.workIdentity) + '">Open card in Work</button>';
|
|
678
|
+
if (links) html += '<div class="ta-links">' + links + '</div>';
|
|
679
|
+
if (!O.r && O.open.kind === 'need') html += '<p class="ta-sub">This handoff has no session to reply to. Open it in Work.</p>';
|
|
680
|
+
box.innerHTML = html;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// The session's last reply, from the same read the reader makes (?turns=N, lib/agent-session-turns.ts).
|
|
684
|
+
function loadLast(r) {
|
|
685
|
+
O.lastState = 'loading'; paintLast();
|
|
686
|
+
return K.answer(K.path(r.provider, r.id, '?turns=6'), { timeoutMs: 20000 }).then(function (a) {
|
|
687
|
+
if (O.r !== r) return;
|
|
688
|
+
if (a.status === 404) { O.lastState = 'none'; O.lastWhy = 'This session is not on your Mac any more.'; paintLast(); return; }
|
|
689
|
+
if (a.status !== 200) { O.lastState = 'error'; paintLast(); return; }
|
|
690
|
+
var d = a.body || {};
|
|
691
|
+
if (!O.row) { O.row = d; paintMeta(); if (O.open && O.open.kind === 'run') setHead(K.title(d)); }
|
|
692
|
+
var turns = Array.isArray(d.recent_turns) ? d.recent_turns : [];
|
|
693
|
+
var lastA = turns.filter(function (t) { return lower(t.role) !== 'user' && String(t.text || t.content || '').trim(); }).pop();
|
|
694
|
+
O.last = lastA ? { text: String(lastA.text || lastA.content).trim(), at: lastA.at } : null;
|
|
695
|
+
O.lastState = O.last ? 'ready' : 'none';
|
|
696
|
+
O.lastWhy = '';
|
|
697
|
+
paintLast();
|
|
698
|
+
}, function () { if (O.r === r) { O.lastState = 'error'; paintLast(); } });
|
|
699
|
+
}
|
|
700
|
+
function paintLast() {
|
|
701
|
+
var box = $('ta-last'); if (!box) return;
|
|
702
|
+
if (O.lastState === 'loading') { box.innerHTML = '<div class="ta-last"><div class="skeleton" style="width:74%"></div><div class="skeleton" style="width:52%;margin-top:8px"></div></div>'; return; }
|
|
703
|
+
if (O.lastState === 'error') { box.innerHTML = '<p class="ta-sub">The last reply did not load. Open the session to read it.</p>'; return; }
|
|
704
|
+
if (O.lastState === 'none') { box.innerHTML = O.lastWhy ? '<p class="ta-sub">' + esc(O.lastWhy) + '</p>' : ''; return; }
|
|
705
|
+
if (!O.last) { box.innerHTML = ''; return; }
|
|
706
|
+
var at = W.toDate(O.last.at), long = O.last.text.length > 700;
|
|
707
|
+
box.innerHTML = '<section class="ta-last" aria-label="Last reply"><p class="ta-last-head">' + esc('Last reply' + (at ? ', ' + W.clock(at) : '')) + '</p>' +
|
|
708
|
+
'<div class="sx-md ta-last-text' + (long ? ' clamped' : '') + '" id="ta-last-text">' + K.markdown(O.last.text.slice(0, 12000)) + '</div>' +
|
|
709
|
+
(long ? '<button type="button" class="link-btn" data-ta="more">Show all of it</button>' : '') + '</section>';
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Running: the same live stream the reader follows, read with its own reducer (applyFeed, sxFeedWord and friends).
|
|
713
|
+
function watchStream(r) {
|
|
714
|
+
if (typeof EventSource !== 'function') { r.feed.fallback = 'unsupported'; paintLive(); return; }
|
|
715
|
+
var es;
|
|
716
|
+
try { es = new EventSource(K.path(r.provider, r.id, '/stream?seed=turn')); } catch (_e) { r.feed.fallback = 'unsupported'; paintLive(); return; }
|
|
717
|
+
O.stream = es;
|
|
718
|
+
es.onmessage = function (ev) {
|
|
719
|
+
var e; try { e = JSON.parse(ev.data); } catch (_e) { return; }
|
|
720
|
+
if (O.r !== r) return;
|
|
721
|
+
K.feed.apply(r, e);
|
|
722
|
+
r.live = r.live || {};
|
|
723
|
+
if (e.kind === 'status') {
|
|
724
|
+
// The agent's own state wins: a process that is up while it waits on you is waiting, not working (W4-r2).
|
|
725
|
+
var working = e.agent_state ? e.agent_state === 'running' : e.state === 'working';
|
|
726
|
+
// The stream's status trails a Stop by a few seconds (the reader's rule): do not let that echo reopen it.
|
|
727
|
+
if (working && r.stoppedAt && Date.now() - r.stoppedAt < 8000) working = false;
|
|
728
|
+
if (r.live.working && !working) loadLast(r);
|
|
729
|
+
r.live.working = working;
|
|
730
|
+
if (!working) r.live.prose = null;
|
|
731
|
+
} else if (e.kind === 'prose' && e.text) r.live.prose = String(e.text);
|
|
732
|
+
paintLive();
|
|
733
|
+
};
|
|
734
|
+
es.onerror = function () {
|
|
735
|
+
if (O.r !== r || O.stream !== es) return;
|
|
736
|
+
if (es.readyState === 2) {
|
|
737
|
+
if (r.feed.lastSeq > 0) { r.feed.connected = false; r.feed.fallback = 'stream_lost'; paintLive(); }
|
|
738
|
+
else probeStream(r);
|
|
739
|
+
} else { r.feed.fallback = 'reconnecting'; paintLive(); }
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
// EventSource hides why a stream was refused: one GET reads the status for the fallback line (the reader's sxProbeStream).
|
|
743
|
+
function probeStream(r) {
|
|
744
|
+
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
745
|
+
fetch(K.path(r.provider, r.id, '/stream?seed=turn'), { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', signal: ctrl ? ctrl.signal : undefined })
|
|
746
|
+
.then(function (res) { var code = res.status; if (ctrl) ctrl.abort(); return code; }, function () { return 0; })
|
|
747
|
+
.then(function (code) {
|
|
748
|
+
if (O.r !== r || r.feed.lastSeq > 0) return;
|
|
749
|
+
r.feed.fallback = code === 200 ? 'stream_lost' : code ? 'http_' + code : 'unreachable';
|
|
750
|
+
paintLive();
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
function paintLive() {
|
|
754
|
+
var box = $('ta-live'); if (!box || !O.r || !O.open || O.open.kind !== 'run') return;
|
|
755
|
+
var r = O.r, f = r.feed, live = r.live || {};
|
|
756
|
+
var on = f.connected || f.lastSeq > 0 || live.working;
|
|
757
|
+
var html;
|
|
758
|
+
if (on) {
|
|
759
|
+
// Idle: the reader's line repeats the last reply, which the peek shows in full just below.
|
|
760
|
+
var word = K.feed.word(r), detail = word === 'Idle' ? '' : K.feed.detail(f, word);
|
|
761
|
+
html = '<div class="sx-act-state"><span class="sx-dot ' + word.toLowerCase() + '" aria-hidden="true"></span><strong>' + esc(word) + '</strong>' +
|
|
762
|
+
'<span class="sx-act-detail">' + esc(detail) + '</span><span class="sx-act-clock" id="ta-clock">' + esc(K.feed.elapsed(f)) + '</span>' +
|
|
763
|
+
(live.working || O.stopping ? '<button type="button" class="cx-btn quiet sx-act-stop' + (O.armedAt.stop != null ? ' danger' : '') + '" data-ta="stop"' + (O.stopping ? ' disabled' : '') + '>' +
|
|
764
|
+
(O.stopping ? 'Stopping' : O.armedAt.stop != null ? 'Tap again to stop' : 'Stop') + '</button>' : '') + '</div>' +
|
|
765
|
+
(f.prompt ? '<p class="ta-act-prompt">' + esc('Working on: ' + clip(f.prompt, 240)) + '</p>' : '') +
|
|
766
|
+
'<div class="ta-act-tail">' + f.tools.slice(-5).map(function (t) { return '<div class="sx-act-tool">' + esc(K.feed.tool(t)) + '</div>'; }).join('') +
|
|
767
|
+
(live.working && live.prose ? '<div class="sx-act-prose">' + esc(String(live.prose).slice(-500)) + '</div>' : '') + '</div>';
|
|
768
|
+
// The feed stopped or was refused: say why under the last state it showed.
|
|
769
|
+
if (f.fallback && !f.connected) html += '<p class="ta-sub ta-feed-note">' + esc(K.feed.fallback(f.fallback)) + '</p>';
|
|
770
|
+
} else html = '<p class="ta-sub">' + esc(f.fallback ? K.feed.fallback(f.fallback) : 'Connecting to the live feed\u2026') + '</p>';
|
|
771
|
+
box.innerHTML = '<div class="sx-activity ta-activity" aria-live="polite">' + html + '</div>';
|
|
772
|
+
var rs = $('ta-run-state'); if (rs) rs.textContent = runWords();
|
|
773
|
+
clearInterval(O.clock); O.clock = null;
|
|
774
|
+
var w = on ? K.feed.word(r) : '';
|
|
775
|
+
if (w === 'Working' || w === 'Waiting') {
|
|
776
|
+
O.clock = setInterval(function () {
|
|
777
|
+
if (O.r !== r) { clearInterval(O.clock); return; }
|
|
778
|
+
var c = $('ta-clock'); if (c) c.textContent = K.feed.elapsed(r.feed);
|
|
779
|
+
var rs = $('ta-run-state'); if (rs) rs.textContent = runWords();
|
|
780
|
+
}, 1000);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// ── sending: the reader's attach, turn and queue path ──
|
|
785
|
+
// continueSession and parkTurn run as they do in the reader and report through this view (app.js READER_VIEW's
|
|
786
|
+
// methods). The gates in sendReply, watchTurn and paintVerdict below are this file's copies of the reader's (see the
|
|
787
|
+
// header). The overlay never holds a message for later: when COS's own reply is still running it says so, and the
|
|
788
|
+
// words stay in the box, under the same key, so Send again cannot post twice.
|
|
789
|
+
var VIEW = {
|
|
790
|
+
status: function (r, text, kind, actionHtml) { if (r !== O.r) return; O.note = { text: text || '', kind: kind || '' }; paintNote(); },
|
|
791
|
+
pending: function (r, text, note) {
|
|
792
|
+
if (r !== O.r) return;
|
|
793
|
+
O.dirty = true;
|
|
794
|
+
O.note = { text: note === 'waiting in line' ? 'Waiting in line. It sends on its own when the session is free.' : note === 'already sent' ? 'Already sent.' : 'Sent.', kind: '' };
|
|
795
|
+
paintNote();
|
|
796
|
+
},
|
|
797
|
+
clear: function (r, text) {
|
|
798
|
+
// The Mac took it: the send is settled, and the kept draft goes with the words that were sent.
|
|
799
|
+
if (r.draft && r.draft.text === text) r.draft = null;
|
|
800
|
+
var box = r === O.r ? $('ta-input') : null;
|
|
801
|
+
var left = box ? (box.value.trim() === String(text).trim() ? '' : box.value) : '';
|
|
802
|
+
if (box && !left) { box.value = ''; W.autoGrow(box); }
|
|
803
|
+
keepDraft(r, left, null);
|
|
804
|
+
},
|
|
805
|
+
verdict: function (r) { if (r === O.r) paintVerdict(); },
|
|
806
|
+
hold: function (r) {
|
|
807
|
+
if (r !== O.r) return;
|
|
808
|
+
O.note = { text: 'COS is still answering in this session. Send again when it finishes; it will not post twice. Or open the session, where it sends on its own.', kind: 'error' };
|
|
809
|
+
paintNote();
|
|
810
|
+
},
|
|
811
|
+
watch: function (r, bindingId, key) { watchTurn(r, bindingId, key); },
|
|
812
|
+
queue: function () {}
|
|
813
|
+
};
|
|
814
|
+
function paintNote() { W.setStatus($('ta-status'), O.note.text, O.note.kind === 'error'); }
|
|
815
|
+
// A copy of the reader's renderVerdict (app.js): the same boxes and words, with Open session where the reader offers
|
|
816
|
+
// Fork and Refresh, which live only in the reader.
|
|
817
|
+
function paintVerdict() {
|
|
818
|
+
var box = $('ta-verdict'), r = O.r; if (!box || !r) return;
|
|
819
|
+
var v = r.verdict, html = '';
|
|
820
|
+
if (O.cautionAsk) {
|
|
821
|
+
html = '<div class="sx-box confirm" role="alertdialog" aria-labelledby="ta-caution-title"><strong id="ta-caution-title">Send into an open session?</strong>' +
|
|
822
|
+
'<p>Another app on this Mac has this session open. It looks idle right now, but a reply will run with that session\u2019s own permissions.</p>' +
|
|
823
|
+
'<div class="sx-box-actions"><button type="button" class="cx-btn danger" data-ta="caution-send">Send anyway</button><button type="button" class="cx-btn quiet" data-ta="caution-cancel">Cancel</button></div></div>';
|
|
824
|
+
} else if (r.refusal) {
|
|
825
|
+
var f = r.refusal;
|
|
826
|
+
html = '<div class="sx-box refusal" role="status"><p>' + esc(f.copy) + '</p>' + (f.supplement ? '<p class="sx-box-sub">' + esc(f.supplement) + '</p>' : '') +
|
|
827
|
+
'<div class="sx-box-actions">' +
|
|
828
|
+
(f.changed ? '<button type="button" class="cx-btn quiet" data-ta="send-anyway">Continue anyway</button>' : '') +
|
|
829
|
+
(f.retry ? '<button type="button" class="cx-btn quiet" data-ta="retry">Try again</button>' : '') +
|
|
830
|
+
((f.changed || f.fork) ? '<button type="button" class="cx-btn quiet" data-ta="open-session">' + (f.fork ? 'Open session to fork it' : 'Open session') + '</button>' : '') +
|
|
831
|
+
'</div></div>';
|
|
832
|
+
} else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
|
|
833
|
+
html = '<p class="sx-park">' + esc(K.parkCopy(r.provider)) + '</p>';
|
|
834
|
+
} else if (v && v.attachable === false && v.reasonCopy) {
|
|
835
|
+
html = '<div class="sx-box refusal" role="status"><p>' + esc(v.reasonCopy) + '</p></div>';
|
|
836
|
+
} else if (v && v.caution) {
|
|
837
|
+
html = '<p class="sx-park">' + esc(r.provider === 'codex' ? 'The Codex app has this thread open. COS asks before the first send, then hands your message to the app\u2019s own queue.'
|
|
838
|
+
: 'Another app on this Mac has this session open. COS asks before the first send.') + '</p>';
|
|
839
|
+
}
|
|
840
|
+
box.innerHTML = html;
|
|
841
|
+
}
|
|
842
|
+
function setSending(on) {
|
|
843
|
+
var send = $('ta-send'), input = $('ta-input');
|
|
844
|
+
if (send) { send.disabled = on; send.textContent = on ? 'Sending' : 'Send'; }
|
|
845
|
+
if (input) input.readOnly = on;
|
|
846
|
+
}
|
|
847
|
+
function sendReply(opts) {
|
|
848
|
+
var r = O.r; if (!r) return;
|
|
849
|
+
var input = $('ta-input'), text = input ? input.value.trim() : '';
|
|
850
|
+
if (!text) { O.note = { text: 'Write a message first.', kind: 'error' }; paintNote(); return; }
|
|
851
|
+
// Control's caution: another app holds this session and looks idle, so the first send asks.
|
|
852
|
+
if (r.verdict && r.verdict.caution && !r.cautionAck) { O.cautionAsk = true; O.armedAt = { caution: Date.now() }; paintVerdict(); return; }
|
|
853
|
+
if (r.sending) return;
|
|
854
|
+
if (r.refusal || O.cautionAsk) { r.refusal = null; O.cautionAsk = false; paintVerdict(); }
|
|
855
|
+
r.sending = true; setSending(true);
|
|
856
|
+
var done = function () { r.sending = false; if (O.r === r) setSending(false); };
|
|
857
|
+
var sending = K.send(r, text, opts || {});
|
|
858
|
+
// continueSession picks the key at once (draftFor): keep it until the Mac takes the words.
|
|
859
|
+
if (r.draft) keepDraft(r, text, r.draft);
|
|
860
|
+
return sending.then(function () { done(); }, function (err) {
|
|
861
|
+
done();
|
|
862
|
+
if (O.r !== r) return;
|
|
863
|
+
var why = err && err.name === 'AbortError'
|
|
864
|
+
? 'No answer from your Mac yet. It may still land. Sending again is safe; it will not post twice.'
|
|
865
|
+
: (err && err.message) || 'Could not reach your Mac.';
|
|
866
|
+
O.note = { text: '', kind: '' }; paintNote();
|
|
867
|
+
r.refusal = { copy: why, retry: true };
|
|
868
|
+
paintVerdict();
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
// Stop, as the reader's Stop sends it (app.js stopSession): POST .../cancel with one clientCancelId per attempt, so a
|
|
872
|
+
// retry after a lost answer replays instead of stopping twice. The server's own words say what happened.
|
|
873
|
+
function stopRun() {
|
|
874
|
+
var r = O.r; if (!r || O.stopping) return;
|
|
875
|
+
O.stopping = true; O.armedAt = {};
|
|
876
|
+
if (!O.stopKey) O.stopKey = K.newKey();
|
|
877
|
+
O.note = { text: 'Stopping', kind: '' }; paintNote(); paintLive();
|
|
878
|
+
return K.answer(K.path(r.provider, r.id, '/cancel'), { method: 'POST', timeoutMs: 15000, body: { clientCancelId: O.stopKey } }).then(function (c) {
|
|
879
|
+
O.stopping = false;
|
|
880
|
+
if (O.r !== r) return;
|
|
881
|
+
var b = c.body || {};
|
|
882
|
+
O.stopKey = '';
|
|
883
|
+
O.note = { text: b.reasonCopy || (b.cancelled ? 'Stopped.' : 'COS could not stop it.'), kind: b.cancelled ? '' : 'error' };
|
|
884
|
+
if (b.cancelled) { r.stoppedAt = Date.now(); r.live = r.live || {}; r.live.working = false; r.live.prose = null; O.dirty = true; setTimeout(function () { if (O.r === r) loadLast(r); }, 1500); }
|
|
885
|
+
paintNote(); paintLive();
|
|
886
|
+
}, function (err) {
|
|
887
|
+
O.stopping = false;
|
|
888
|
+
if (O.r !== r) return;
|
|
889
|
+
O.note = { text: 'No answer from your Mac' + (err && err.message ? ': ' + err.message : '') + '. Stopping again is safe; it will not stop twice.', kind: 'error' };
|
|
890
|
+
paintNote(); paintLive();
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
// A copy of the reader's watchTurn (app.js): the same route, outcomes and three minutes, for as long as the overlay
|
|
894
|
+
// shows this session. The reader's own watch is tied to its page, so it cannot be reused here.
|
|
895
|
+
function watchTurn(r, bindingId, key) {
|
|
896
|
+
clearInterval(O.watch);
|
|
897
|
+
var started = Date.now();
|
|
898
|
+
O.watch = setInterval(function () {
|
|
899
|
+
if (O.r !== r || Date.now() - started > WATCH_MS) { clearInterval(O.watch); return; }
|
|
900
|
+
K.answer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
|
|
901
|
+
if (O.r !== r) return;
|
|
902
|
+
var o = s.body && s.body.outcome;
|
|
903
|
+
if (o === 'completed') { clearInterval(O.watch); O.note = { text: 'Done. The reply is below.', kind: '' }; paintNote(); loadLast(r); }
|
|
904
|
+
else if (o === 'refused') { clearInterval(O.watch); O.note = { text: s.body.reasonCopy || 'The session refused that message.', kind: 'error' }; paintNote(); }
|
|
905
|
+
else if (o === 'ambiguous') { clearInterval(O.watch); O.note = { text: s.body.reasonCopy || 'COS cannot tell whether that landed. Look at the session before sending it again.', kind: 'error' }; paintNote(); }
|
|
906
|
+
}).catch(function () {});
|
|
907
|
+
}, 3000);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ═════════════ what COS is told about Today ═════════════
|
|
911
|
+
// A short plain summary of what the Today cards show, for an Ask made from Today (app.js askCos). Titles and words
|
|
912
|
+
// only: no ids, no tokens. At most SUMMARY_MAX characters, the left-out marker included.
|
|
913
|
+
function end(s) { s = String(s || '').trim(); return /[.!?\u2026]$/.test(s) ? s : s + '.'; }
|
|
914
|
+
function summary() {
|
|
915
|
+
var d = T.data();
|
|
916
|
+
if (!d.at) return '';
|
|
917
|
+
var lines = [], now = new Date();
|
|
918
|
+
lines.push('Today, ' + now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + W.clock(now) + '.');
|
|
919
|
+
// Needs you as Today lists it (app.js needsModel: waiting sessions, Work handoffs, voices, memories), and what it
|
|
920
|
+
// could not read. Before that model exists, the Work handoffs alone.
|
|
921
|
+
var m = T.needs ? T.needs() : null;
|
|
922
|
+
var rows = m ? m.rows : (d.needs || []).map(function (it) { return { kind: 'work', item: it }; });
|
|
923
|
+
var later = m ? m.later.length : 0;
|
|
924
|
+
lines.push(rows.length ? 'Needs you (' + rows.length + (later ? ', ' + later + ' of them can wait until tomorrow' : '') + '):'
|
|
925
|
+
: m && !m.settled ? 'Needs you: nothing found so far.' : 'Needs you: nothing.');
|
|
926
|
+
rows.slice(0, 6).forEach(function (r) {
|
|
927
|
+
if (r.kind !== 'work') { lines.push('- ' + end(clip(r.title, 140)) + (r.why ? ' ' + end(clip(r.why, 160)) : '')); return; }
|
|
928
|
+
var it = r.item;
|
|
929
|
+
var title = str(it.sessionTitle) || str(it.title) || 'Handoff';
|
|
930
|
+
var bits = [end(clip(title, 120)) + ' ' + end(needLine(it))];
|
|
931
|
+
if (str(it.title) && str(it.title) !== str(it.sessionTitle)) bits.push(end('Work card: ' + clip(it.title, 100)));
|
|
932
|
+
var p = it.progress && !it.progress.unreadable ? it.progress : null;
|
|
933
|
+
if (p && p.evidence) bits.push('The session said: "' + clip(p.evidence, 200) + '"');
|
|
934
|
+
lines.push('- ' + bits.join(' '));
|
|
935
|
+
});
|
|
936
|
+
if (rows.length > 6) lines.push('- and ' + (rows.length - 6) + ' more.');
|
|
937
|
+
if (m && m.failed.length) lines.push('Today could not read ' + m.failed.join(', ') + ', so this may not be everything.');
|
|
938
|
+
var working = d.working || [], open = d.open || [];
|
|
939
|
+
lines.push('Running: ' + working.length + ' working' + (open.length ? ', ' + open.length + ' more open and quiet' : '') + (working.length ? ':' : '.'));
|
|
940
|
+
working.slice(0, 6).forEach(function (s) {
|
|
941
|
+
var k = T.splitSessionId(s.provider, s.session_id);
|
|
942
|
+
lines.push('- ' + K.providerLabel(k.provider) + ': ' + clip(T.sessionTitle(s), 120) + ', working for ' + T.span(s.state_since || s.last_activity_at) + '.');
|
|
943
|
+
});
|
|
944
|
+
var items = d.intake || [];
|
|
945
|
+
if (items.length) {
|
|
946
|
+
lines.push('From today\u2019s meetings: ' + items.length + (items.length === 1 ? ' suggestion' : ' suggestions') + ' waiting for a decision (they move to Work at midnight):');
|
|
947
|
+
var groups = [], byTitle = {};
|
|
948
|
+
items.forEach(function (it) { var t = str(it.meeting && it.meeting.title) || 'A meeting'; if (!byTitle[t]) { byTitle[t] = []; groups.push(t); } byTitle[t].push(it); });
|
|
949
|
+
groups.slice(0, 8).forEach(function (t) {
|
|
950
|
+
var list = byTitle[t];
|
|
951
|
+
lines.push('- ' + clip(t, 100) + ' (' + list.length + '): ' + list.slice(0, 4).map(function (it) { return '"' + clip(textOf(it), 110) + '"' + (it.ownerIsMiles ? '' : str(it.owner) ? ' (' + clip(it.owner, 40) + '\u2019s)' : ''); }).join('; ') + (list.length > 4 ? '; and ' + (list.length - 4) + ' more' : '') + '.');
|
|
952
|
+
});
|
|
953
|
+
if (groups.length > 8) lines.push('- and ' + (groups.length - 8) + ' more meetings.');
|
|
954
|
+
}
|
|
955
|
+
var n = d.welcome && d.welcome.nextEvent, nd = n && W.toDate(n.time);
|
|
956
|
+
if (nd && n.title) lines.push('Next up: ' + clip(n.title, 120) + ' at ' + W.clock(nd) + ' ' + W.dayWord(nd) + '.');
|
|
957
|
+
var out = '', i;
|
|
958
|
+
for (i = 0; i < lines.length; i++) {
|
|
959
|
+
if (out.length + lines[i].length + 1 > SUMMARY_MAX - SUMMARY_MORE.length) { out += SUMMARY_MORE; break; }
|
|
960
|
+
out += lines[i] + '\n';
|
|
961
|
+
}
|
|
962
|
+
return out.trim();
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
W.registerPane('todayActions', {
|
|
966
|
+
load: function () {},
|
|
967
|
+
open: open, close: close, restore: restore, summary: summary, summaryMax: SUMMARY_MAX,
|
|
968
|
+
isOpen: function () { return !!O.open; },
|
|
969
|
+
_state: O, _decide: decide, _undo: undo, _intakeHtml: intakeHtml, _send: sendReply, _view: VIEW, _paintVerdict: paintVerdict,
|
|
970
|
+
_paintLive: paintLive, _paintMeta: paintMeta, _onClick: onClick, _expire: expireLater
|
|
971
|
+
});
|
|
972
|
+
})();
|