@gotcos/glasses-server 6.99.20 → 6.99.29
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 +257 -0
- package/docs/work-orchestrator.md +20 -0
- package/package.json +1 -1
- package/server/index.ts +48 -5
- package/server/lib/agent-session-output-images.ts +280 -0
- package/server/lib/agent-session-store.ts +3 -1
- package/server/lib/agent-session-turns.ts +18 -7
- package/server/lib/client-liveness.ts +38 -3
- package/server/lib/codex-subagent-activity.ts +36 -0
- package/server/lib/g2-turn-provenance.ts +679 -0
- package/server/lib/media-store.ts +41 -2
- package/server/lib/permission-broker.ts +94 -17
- package/server/lib/queue-turn-evidence.ts +19 -2
- package/server/lib/session-halt.ts +15 -1
- package/server/lib/session-stream-events.ts +4 -0
- package/server/lib/session-subagents.ts +155 -0
- package/server/lib/session-turn-attachments.ts +245 -0
- package/server/lib/thread-turn-queue-deliver.ts +17 -2
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +731 -0
- package/server/lib/turn-file-store.ts +202 -0
- package/server/lib/turn-footer.ts +81 -0
- 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/routes/agent-session-bindings.ts +164 -16
- package/server/routes/agent-sessions.ts +77 -8
- package/server/routes/client-instance.ts +12 -1
- package/server/routes/cursor-stop-followup.ts +30 -1
- package/server/routes/g2-turns.ts +46 -0
- package/server/routes/health.ts +9 -0
- package/server/routes/media.ts +32 -8
- package/server/routes/permission-broker.ts +47 -4
- package/server/routes/thread-turn-queue.ts +148 -8
- package/server/routes/today-brief.ts +148 -0
- package/server/routes/web-control.ts +110 -7
- package/server/routes/work-lens-rounds.ts +19 -5
- package/server/web-control/app.css +26 -5
- package/server/web-control/app.js +1351 -177
- package/server/web-control/control.css +1 -1
- package/server/web-control/index.html +31 -13
- package/server/web-control/memories/memory-workspace.js +1 -1
- package/server/web-control/panes/cos-chat.css +22 -0
- package/server/web-control/panes/cos-chat.js +264 -48
- package/server/web-control/panes/meetings.css +15 -2
- package/server/web-control/panes/meetings.js +20 -9
- package/server/web-control/panes/memories.css +291 -0
- package/server/web-control/panes/memories.js +2208 -0
- package/server/web-control/panes/questions.css +39 -1
- package/server/web-control/panes/questions.js +419 -64
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +2 -0
- package/server/web-control/panes/settings.js +136 -6
- package/server/web-control/panes/today-actions.css +167 -0
- package/server/web-control/panes/today-actions.js +1303 -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/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,1303 @@
|
|
|
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
|
+
- A waiting session (6.99.26): its question cards when this page holds the question; when the Mac's own prompt has
|
|
12
|
+
it, that prompt read-only with Miles's words for it, and no reply box (a reply never answers a prompt); a reply box
|
|
13
|
+
only where a reply is the answer (Codex and Cursor).
|
|
14
|
+
- The pet (6.99.26, Miles 11/07: "Open what matters"): one session in play opens its sheet straight away (app.js
|
|
15
|
+
petTap); several open the short list here, waiting on you first, then running, then done today, each row opening
|
|
16
|
+
its own sheet.
|
|
17
|
+
What a reply or a note shares with the Sessions reader, and what is this file's own copy:
|
|
18
|
+
- Shared (app.js, through COSWeb.sessionKit): the attach, turn and queue path itself (continueSession and parkTurn,
|
|
19
|
+
run with this overlay as their view), the per-browser binding store, one key per draft, the attachability probe
|
|
20
|
+
(sxProbeAttach) and the live feed's reducer. The server's refusals arrive word for word.
|
|
21
|
+
- Copied here, kept in step by hand: the send gates (sendReply mirrors sendSessionTurn: one send at a time, the
|
|
22
|
+
caution confirm, a lost answer becomes Try again), the turn watch (watchTurn polls the same route for the same
|
|
23
|
+
three minutes as the reader's watchTurn), and the refusal box (paintVerdict mirrors renderVerdict, with Open session
|
|
24
|
+
in place of Fork and Refresh).
|
|
25
|
+
- From today's meetings: each suggestion with the decisions COS Control offers on Today (Create task, Dismiss, Open
|
|
26
|
+
in Work, Open card) and Work's Link for a suggested link, through Work's own route (POST
|
|
27
|
+
/api/work-intake/:id/resolve), one decision at a time as Control does. The Today card's count moves at once.
|
|
28
|
+
Close with the X, a tap outside, Escape, or Back (the overlay holds one history entry of its own). Focus moves in and
|
|
29
|
+
comes back to the row. The page behind is inert and does not scroll.
|
|
30
|
+
|
|
31
|
+
Files with a reply or a note (11/07, Miles: "Sometimes I need to pass context via a screenshot, photo or file"): the
|
|
32
|
+
COS chat's own clip, chips, limits and upload (W.cosChat.attach), shown only where the Mac says a session turn takes
|
|
33
|
+
files (threadTurnAttachments). The ids go with the words through the same send path; the server writes where each
|
|
34
|
+
file is on the Mac under the words (lib/session-turn-attachments.ts). Send waits for every upload, and a failed one has
|
|
35
|
+
to be retried or removed first. The files are part of the draft's key, so other files are another message.
|
|
36
|
+
|
|
37
|
+
Also the summary COS gets when Miles asks from Today (summary(): app.js askCos passes it as the Ask's reference).
|
|
38
|
+
Loaded on the first tap or once the page is idle (app.js ensurePane, #lazy-panes). */
|
|
39
|
+
(function () {
|
|
40
|
+
'use strict';
|
|
41
|
+
var W = window.COSWeb;
|
|
42
|
+
if (!W || !W.today || !W.sessionKit) return;
|
|
43
|
+
var T = W.today, K = W.sessionKit, esc = W.esc, api = W.api;
|
|
44
|
+
|
|
45
|
+
var UNDO_SECONDS = 8; // TodayDismissals.undoSeconds
|
|
46
|
+
var STALE_MS = 60000; // a decision the server dated a minute before the tap was made elsewhere
|
|
47
|
+
var WATCH_MS = 180000; // follow a sent turn for three minutes, as the reader does
|
|
48
|
+
// The Today summary's cap, read by app.js askCos's callers and the tests (registered as summaryMax). The marker that
|
|
49
|
+
// says something was left out counts against it.
|
|
50
|
+
var SUMMARY_MAX = 4000;
|
|
51
|
+
var SUMMARY_MORE = '[More is on Today than fits here.]';
|
|
52
|
+
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>';
|
|
53
|
+
// Work's words for an intake refusal, by the code the server sends: a verbatim copy of panes/work.js INTAKE_REFUSALS
|
|
54
|
+
// (Work loads lazily, so the overlay cannot read it there). today-actions.test.ts fails if the two differ.
|
|
55
|
+
// intake_not_found never shows: a suggestion that is gone reads as handled on another screen (GONE).
|
|
56
|
+
var REFUSALS = {
|
|
57
|
+
meeting_identity_changed: 'That meeting was renamed or merged after it was scored. Dismiss this item; the renamed meeting is checked again.',
|
|
58
|
+
meeting_unavailable: 'That meeting can’t be found; it may have been renamed or removed. Dismiss this item.',
|
|
59
|
+
intake_task_changed: 'That task changed or was closed since it was suggested. Dismiss this suggestion.',
|
|
60
|
+
meeting_links_full: 'That task already links eight meetings. Dismiss this suggestion.',
|
|
61
|
+
work_board_read_only: 'This server can’t write Work links yet.',
|
|
62
|
+
card_creation_unavailable: 'This server can’t create Work cards yet. Update the server.',
|
|
63
|
+
task_bridge_unavailable: 'The task bridge didn’t answer. Try again in a moment.',
|
|
64
|
+
intake_text_collision: 'The same words are on closed or archived work. Reopen that task instead, or dismiss this item.',
|
|
65
|
+
intake_busy: 'That item is already being saved.', intake_not_found: 'That item is no longer in Intake. Refresh.',
|
|
66
|
+
invalid_intake_action: 'Refresh Intake and try again.', invalid_intake_id: 'Refresh Intake and try again.',
|
|
67
|
+
intake_store_full: 'Intake is full. Dismiss older items, then try again.',
|
|
68
|
+
intake_store_closed: 'Intake is unavailable while the server restarts. Try again shortly.',
|
|
69
|
+
work_intake_unavailable: 'Intake is unavailable right now. Try again shortly.' };
|
|
70
|
+
var GONE = 'This suggestion is gone. It was handled on another screen.';
|
|
71
|
+
// The decision words, as COS Control's Today says them (todaySuggestionRow). One place each, so a wording change is
|
|
72
|
+
// one edit.
|
|
73
|
+
var CREATE_LABEL = 'Create task', DISMISS_LABEL = 'Dismiss';
|
|
74
|
+
|
|
75
|
+
var O = {
|
|
76
|
+
open: null, // { kind: 'need' | 'run' | 'wait' | 'pet' | 'meeting' | 'intake', key, all }
|
|
77
|
+
opener: null, openerSel: '',
|
|
78
|
+
pushed: false, ignorePops: 0,
|
|
79
|
+
depth: 1, // history entries the overlay holds: 2 while a session opened from the pet's list shows
|
|
80
|
+
back: null, // { scroll } the pet's list that session came from, for Back to sessions
|
|
81
|
+
dirty: false, // something was decided or sent: Today reloads on close
|
|
82
|
+
// From today's meetings
|
|
83
|
+
busy: '', // the one suggestion being decided (Control: one at a time)
|
|
84
|
+
armedAt: {}, // the one armed confirm: 'dismiss:<id>' | 'stop' | 'caution' -> when (confirmTap)
|
|
85
|
+
rows: {}, // id -> { state, note, undo, taskId, item, asked, until, undoing, undoNote }
|
|
86
|
+
shown: {}, // ids drawn in this overlay, so one that vanishes reads as handled elsewhere
|
|
87
|
+
loop: null, // the undo route exists on this server (GET /api/work-intake/decisions), null = not asked
|
|
88
|
+
// Needs you and Running
|
|
89
|
+
r: null, // the session object the reader's send path takes (sessionKit.session)
|
|
90
|
+
row: null, // the session's row in the session list
|
|
91
|
+
item: null, // the Needs you item
|
|
92
|
+
stream: null, clock: null, watch: null, qMount: null, qCount: 0, macCount: 0, cautionAsk: false, note: { text: '', kind: '' },
|
|
93
|
+
last: null, // { text, at } the session's last reply
|
|
94
|
+
lastState: 'idle', // idle | loading | ready | error | none
|
|
95
|
+
needGone: false
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
function $(id) { return document.getElementById(id); }
|
|
99
|
+
// Per session, in this browser: the words in the box, and a send whose answer has not come ({text, key}), kept apart
|
|
100
|
+
// from the Sessions reader's store. A week old and they go.
|
|
101
|
+
var DRAFTS = 'cos.web.today.drafts', DRAFT_DAYS = 7;
|
|
102
|
+
function draftKey(r) { return r.provider + '|' + r.id; }
|
|
103
|
+
function draftOf(r) {
|
|
104
|
+
var d = W.readStore(DRAFTS)[draftKey(r)];
|
|
105
|
+
return d && typeof d === 'object' && Date.now() - (Number(d.at) || 0) < DRAFT_DAYS * 86400000 ? d : null;
|
|
106
|
+
}
|
|
107
|
+
// `unsettled` undefined keeps what is stored; null clears it.
|
|
108
|
+
function keepDraft(r, text, unsettled) {
|
|
109
|
+
var all = W.readStore(DRAFTS), k = draftKey(r), now = Date.now(), prev = all[k] || {};
|
|
110
|
+
Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) >= DRAFT_DAYS * 86400000) delete all[x]; });
|
|
111
|
+
var u = unsettled === undefined ? prev.unsettled || null : unsettled ? { text: unsettled.text, key: unsettled.key } : null;
|
|
112
|
+
// A send with files that never settled keeps their ids, so after a reload it goes again exactly as it was.
|
|
113
|
+
if (u && unsettled && unsettled.ids && unsettled.ids.length) u.ids = unsettled.ids.slice();
|
|
114
|
+
var t = String(text || '');
|
|
115
|
+
if (!t.trim() && !u) delete all[k]; else all[k] = { text: t, unsettled: u, at: now };
|
|
116
|
+
W.writeStore(DRAFTS, all);
|
|
117
|
+
}
|
|
118
|
+
function str(v) { return typeof v === 'string' ? v : ''; }
|
|
119
|
+
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'; }
|
|
120
|
+
function lower(s) { return String(s || '').toLowerCase(); }
|
|
121
|
+
function phone() { return W.isPhone ? W.isPhone() : true; }
|
|
122
|
+
|
|
123
|
+
// ── the layer ──────────────────────────────────────────
|
|
124
|
+
var layer = null;
|
|
125
|
+
function build() {
|
|
126
|
+
if (layer) return layer;
|
|
127
|
+
layer = document.createElement('div');
|
|
128
|
+
layer.id = 'ta-layer';
|
|
129
|
+
layer.className = 'ta-layer';
|
|
130
|
+
layer.hidden = true;
|
|
131
|
+
layer.innerHTML = '<div class="ta-scrim" data-ta="close" aria-hidden="true"></div>' +
|
|
132
|
+
'<section class="ta-sheet" id="ta-sheet" role="dialog" aria-modal="true" aria-labelledby="ta-title" tabindex="-1">' +
|
|
133
|
+
'<div class="ta-grip" aria-hidden="true"></div>' +
|
|
134
|
+
'<header class="ta-head" id="ta-head"><div class="ta-head-main"><button type="button" class="link-btn ta-back" id="ta-back" data-ta="back" hidden>Back to sessions</button>' +
|
|
135
|
+
'<h2 class="ta-title" id="ta-title"></h2></div>' +
|
|
136
|
+
'<button type="button" class="ta-x" data-ta="close" aria-label="Close">' + X_ICON + '</button></header>' +
|
|
137
|
+
'<div class="ta-body" id="ta-body"></div>' +
|
|
138
|
+
'<div class="ta-foot" id="ta-foot"></div>' +
|
|
139
|
+
'</section>';
|
|
140
|
+
document.body.appendChild(layer);
|
|
141
|
+
// A tap that lands while the sheet is still opening is the second half of the tap that opened it (qa2-user B1: a
|
|
142
|
+
// double tap on a meeting row dismissed the first suggestion). Caught before anything inside sees it.
|
|
143
|
+
layer.addEventListener('click', function (e) { if (Date.now() < O.settleAt) { e.stopPropagation(); e.preventDefault(); } }, true);
|
|
144
|
+
layer.addEventListener('click', onClick);
|
|
145
|
+
layer.addEventListener('submit', onSubmit);
|
|
146
|
+
layer.addEventListener('focusin', onFocusIn);
|
|
147
|
+
layer.addEventListener('input', function (e) {
|
|
148
|
+
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);
|
|
149
|
+
});
|
|
150
|
+
// Files: the clip, a pasted screenshot in the box, and files dropped on the reply box.
|
|
151
|
+
layer.addEventListener('change', function (e) {
|
|
152
|
+
if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-ta-file')) { addFiles(e.target.files); e.target.value = ''; }
|
|
153
|
+
});
|
|
154
|
+
layer.addEventListener('paste', function (e) {
|
|
155
|
+
if (!e.target || e.target.id !== 'ta-input' || !filesOn()) return;
|
|
156
|
+
var files = KIT().pasted(e);
|
|
157
|
+
if (files) { e.preventDefault(); addFiles(files); }
|
|
158
|
+
});
|
|
159
|
+
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) {
|
|
160
|
+
layer.addEventListener(type, function (e) {
|
|
161
|
+
var form = $('ta-form');
|
|
162
|
+
if (!form || !filesOn() || !e.target || !e.target.closest || !e.target.closest('#ta-form')) return;
|
|
163
|
+
var files = KIT().dragged(e, form);
|
|
164
|
+
if (files) addFiles(files);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
// A drag on the dimmed page never scrolls what is behind it.
|
|
168
|
+
// The bleed below the bottom edge is the layer's own ::before (today-actions.css): a drag there is the layer's.
|
|
169
|
+
layer.addEventListener('touchmove', function (e) { if (e.target && (e.target === layer || (e.target.classList && e.target.classList.contains('ta-scrim')))) e.preventDefault(); }, { passive: false });
|
|
170
|
+
return layer;
|
|
171
|
+
}
|
|
172
|
+
// With the keyboard up the layer follows the visible viewport, so the keyboard never covers the reply box or Send.
|
|
173
|
+
// With it closed the layer is the whole fixed viewport (today-actions.css): --ta-vh and --ta-vt are set but unused, so
|
|
174
|
+
// a visible viewport that reports less than the screen (IMG_0503, 11/07 12:48: about 106 px) never leaves a band.
|
|
175
|
+
// 6.99.29 (Miles's unfolded Duo, 11/07 11:50): on a wide screen the side sheet kept its tall header and the reply box
|
|
176
|
+
// went under the keyboard while the conversation shrank to one line. With the keyboard up, at any width, the layer is
|
|
177
|
+
// marked ta-kb (today-actions.css): the sheet fills the visible viewport, the header folds to one compact row, the
|
|
178
|
+
// reply box stays pinned at the sheet's bottom just above the keyboard, and the conversation takes what is left. If
|
|
179
|
+
// that leaves under about three lines, ta-kb-tight also hides the "Working right now" note until the keyboard closes.
|
|
180
|
+
// Keyboard: the visible viewport, at its zoom, is KB_MIN_PX or more shorter than the layout one (a form bar alone is
|
|
181
|
+
// about 45 px). Read on visualViewport resize and scroll, at most once a frame, only while the overlay is open.
|
|
182
|
+
var KB_MIN_PX = 120;
|
|
183
|
+
var BODY_MIN_PX = 72; // about three lines of the conversation (15 px text at 1.5)
|
|
184
|
+
var KB = { on: false, tight: false, frame: 0, listening: false };
|
|
185
|
+
function keyboardInset(vv) {
|
|
186
|
+
var layoutH = window.innerHeight || (document.documentElement && document.documentElement.clientHeight) || 0;
|
|
187
|
+
var inset = Math.round(layoutH - vv.height * (vv.scale || 1));
|
|
188
|
+
return inset > 0 ? inset : 0;
|
|
189
|
+
}
|
|
190
|
+
function tall(el) { return (el && el.offsetHeight) || 0; }
|
|
191
|
+
// What the conversation gets once the compact header and the reply box have theirs.
|
|
192
|
+
function bodyRoom(vv) {
|
|
193
|
+
var sheet = $('ta-sheet'), grip = sheet && sheet.querySelector ? sheet.querySelector('.ta-grip') : null;
|
|
194
|
+
return vv.height - tall($('ta-head')) - tall($('ta-foot')) - tall(grip);
|
|
195
|
+
}
|
|
196
|
+
function applyFit() {
|
|
197
|
+
KB.frame = 0;
|
|
198
|
+
var vv = window.visualViewport;
|
|
199
|
+
if (!layer || !vv) return;
|
|
200
|
+
layer.style.setProperty('--ta-vh', Math.round(vv.height) + 'px');
|
|
201
|
+
layer.style.setProperty('--ta-vt', Math.round(vv.offsetTop) + 'px');
|
|
202
|
+
var inset = keyboardInset(vv), on = inset >= KB_MIN_PX;
|
|
203
|
+
layer.style.setProperty('--ta-kb', (on ? inset : 0) + 'px');
|
|
204
|
+
if (on !== KB.on) { KB.on = on; KB.tight = false; layer.classList.toggle('ta-kb', on); }
|
|
205
|
+
// Measured with the compact header in place. Once tight it stays so while the keyboard is up: showing the note
|
|
206
|
+
// again would take the room back and flicker.
|
|
207
|
+
if (on && !KB.tight) KB.tight = bodyRoom(vv) < BODY_MIN_PX;
|
|
208
|
+
layer.classList.toggle('ta-kb-tight', on && KB.tight);
|
|
209
|
+
}
|
|
210
|
+
function fit() {
|
|
211
|
+
if (KB.frame) return;
|
|
212
|
+
if (!window.requestAnimationFrame) return applyFit(); // every browser with visualViewport has frames
|
|
213
|
+
KB.frame = window.requestAnimationFrame(applyFit);
|
|
214
|
+
}
|
|
215
|
+
function listenViewport(on) {
|
|
216
|
+
var vv = window.visualViewport;
|
|
217
|
+
if (!vv || KB.listening === on) return;
|
|
218
|
+
KB.listening = on;
|
|
219
|
+
var m = on ? 'addEventListener' : 'removeEventListener';
|
|
220
|
+
if (typeof vv[m] === 'function') { vv[m]('resize', fit); vv[m]('scroll', fit); }
|
|
221
|
+
}
|
|
222
|
+
function unfit() {
|
|
223
|
+
listenViewport(false);
|
|
224
|
+
if (KB.frame && window.cancelAnimationFrame) window.cancelAnimationFrame(KB.frame);
|
|
225
|
+
KB.frame = 0; KB.on = false; KB.tight = false;
|
|
226
|
+
if (!layer) return;
|
|
227
|
+
layer.classList.remove('ta-kb'); layer.classList.remove('ta-kb-tight');
|
|
228
|
+
layer.style.setProperty('--ta-kb', '0px');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function openerSelector(t) {
|
|
232
|
+
if (!t || !t.getAttribute) return '';
|
|
233
|
+
var kind = t.getAttribute('data-today'), key = t.getAttribute('data-key');
|
|
234
|
+
if (!kind) return '';
|
|
235
|
+
var q = function (v) { return '"' + String(v).replace(/["\\]/g, '\\$&') + '"'; };
|
|
236
|
+
return '[data-today=' + q(kind) + ']' + (key != null ? '[data-key=' + q(key) + ']' : '');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// opt.from: a session opened from the pet's list ({ scroll }), which gets Back to sessions and its own history entry,
|
|
240
|
+
// so the phone's Back returns to the list too (Miles's iPhone, 11/07). opt.again: the list shown again from there,
|
|
241
|
+
// its entry already current.
|
|
242
|
+
function open(kind, key, opener, opt) {
|
|
243
|
+
opt = opt || {};
|
|
244
|
+
build();
|
|
245
|
+
if (O.open) stopSession();
|
|
246
|
+
O.back = opt.from || null;
|
|
247
|
+
O.open = { kind: kind, key: String(key || ''), all: kind === 'intake' };
|
|
248
|
+
O.opener = opener || document.activeElement; O.openerSel = openerSelector(opener);
|
|
249
|
+
O.note = { text: '', kind: '' }; O.cautionAsk = false; O.needGone = false; O.armedAt = {};
|
|
250
|
+
O.stopping = false; O.stopKey = '';
|
|
251
|
+
layer.hidden = false;
|
|
252
|
+
document.documentElement.classList.add('ta-open');
|
|
253
|
+
var shell = $('app-shell'); if (shell) shell.setAttribute('inert', '');
|
|
254
|
+
listenViewport(true); applyFit();
|
|
255
|
+
// The entry names what is open, so a reload can open it again and Back still closes it (restore below).
|
|
256
|
+
var entry = { cosTodayOverlay: 1, kind: kind, key: O.open.key };
|
|
257
|
+
if (O.back) entry.from = 'pet';
|
|
258
|
+
try {
|
|
259
|
+
if (opt.again) { /* the list's own entry is current again */ }
|
|
260
|
+
else if (!O.pushed) { history.pushState(entry, ''); O.pushed = true; O.depth = 1; }
|
|
261
|
+
else if (opt.from && O.depth === 1) { history.pushState(entry, ''); O.depth = 2; }
|
|
262
|
+
else history.replaceState(entry, '');
|
|
263
|
+
} catch (_e) { /* no history API: Back simply leaves Today */ }
|
|
264
|
+
var backBtn = $('ta-back'); if (backBtn) backBtn.hidden = !O.back;
|
|
265
|
+
try {
|
|
266
|
+
if (kind === 'need' || kind === 'run' || kind === 'wait') openSession(kind, O.open.key);
|
|
267
|
+
else if (kind === 'pet') openPet();
|
|
268
|
+
else openIntake();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
// A bug here must not leave a half-drawn sheet over an inert page: take it down, keep the history entry for
|
|
271
|
+
// app.js openTodayAction to replace, and let it say so and go where the tap went before.
|
|
272
|
+
close('leave');
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
var sheet = $('ta-sheet');
|
|
276
|
+
O.settleAt = Date.now() + settleMs(sheet);
|
|
277
|
+
if (sheet && sheet.focus) sheet.focus({ preventScroll: true });
|
|
278
|
+
}
|
|
279
|
+
// How long taps inside are ignored after opening: the longest open motion of the sheet and its backdrop (their computed
|
|
280
|
+
// animation and transition, delay included: today-actions.css ta-rise or ta-slide, 220 ms, and fade, 160 ms), plus
|
|
281
|
+
// 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
|
|
282
|
+
// 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
|
|
283
|
+
// reduced motion (no animation) keeps the same protection.
|
|
284
|
+
var SETTLE_EXTRA_MS = 280, SETTLE_MIN_MS = 500;
|
|
285
|
+
function msList(v) {
|
|
286
|
+
return String(v || '').split(',').map(function (x) { x = x.trim(); var n = parseFloat(x); return isFinite(n) ? (/ms$/.test(x) ? n : n * 1000) : 0; });
|
|
287
|
+
}
|
|
288
|
+
function longestMotion(el) {
|
|
289
|
+
var cs; try { cs = getComputedStyle(el); } catch (_e) { return null; }
|
|
290
|
+
if (!cs) return null;
|
|
291
|
+
var most = 0;
|
|
292
|
+
[['animationDuration', 'animationDelay'], ['transitionDuration', 'transitionDelay']].forEach(function (p) {
|
|
293
|
+
var d = msList(cs[p[0]]), w = msList(cs[p[1]]);
|
|
294
|
+
d.forEach(function (x, i) { most = Math.max(most, x + (w[i % (w.length || 1)] || 0)); });
|
|
295
|
+
});
|
|
296
|
+
return most;
|
|
297
|
+
}
|
|
298
|
+
function settleMs(sheet) {
|
|
299
|
+
var scrim = layer && layer.querySelector ? layer.querySelector('.ta-scrim') : null;
|
|
300
|
+
var a = longestMotion(sheet), b = scrim ? longestMotion(scrim) : null;
|
|
301
|
+
var motion = a == null && b == null ? 220 : Math.max(a || 0, b || 0); // no computed style: the CSS default
|
|
302
|
+
return Math.max(SETTLE_MIN_MS, motion + SETTLE_EXTRA_MS);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// how: 'user' (X, outside, Escape), 'pop' (Back), 'leave' (navigating to another pane)
|
|
306
|
+
function close(how) {
|
|
307
|
+
if (!O.open) return;
|
|
308
|
+
stopSession();
|
|
309
|
+
O.open = null;
|
|
310
|
+
if (layer) layer.hidden = true;
|
|
311
|
+
unfit();
|
|
312
|
+
document.documentElement.classList.remove('ta-open');
|
|
313
|
+
var shell = $('app-shell'); if (shell) shell.removeAttribute('inert');
|
|
314
|
+
// X from a session opened off the pet's list unwinds both entries in one step, so the next Back leaves Today as before.
|
|
315
|
+
if (O.pushed && how !== 'pop' && how !== 'leave') {
|
|
316
|
+
var n = O.depth > 1 ? O.depth : 1;
|
|
317
|
+
O.pushed = false; O.ignorePops++;
|
|
318
|
+
try { if (n > 1 && typeof history.go === 'function') history.go(-n); else history.back(); } catch (_e) { O.ignorePops--; }
|
|
319
|
+
}
|
|
320
|
+
if (how === 'pop' || how === 'leave') O.pushed = false;
|
|
321
|
+
clearTimeout(O.expireTimer);
|
|
322
|
+
// Settled lines are done once the overlay closes; Today reads the server again if anything changed here.
|
|
323
|
+
Object.keys(O.rows).forEach(function (id) { var s = O.rows[id].state; if (s !== 'creating' && s !== 'dismissing') delete O.rows[id]; });
|
|
324
|
+
O.shown = {};
|
|
325
|
+
if (O.dirty) { O.dirty = false; T.refresh(); }
|
|
326
|
+
if (how !== 'leave') restoreFocus();
|
|
327
|
+
}
|
|
328
|
+
function restoreFocus() {
|
|
329
|
+
var el = O.opener;
|
|
330
|
+
if (!el || !el.isConnected) el = O.openerSel ? document.querySelector(O.openerSel) : null;
|
|
331
|
+
if (!el) el = document.querySelector('#pane-today .panel-title');
|
|
332
|
+
O.opener = null;
|
|
333
|
+
if (el && el.focus) { try { el.focus({ preventScroll: true }); } catch (_e) { el.focus(); } }
|
|
334
|
+
}
|
|
335
|
+
// Leaving for a tab from inside the overlay replaces the overlay's history entry, so Back from there returns to Today.
|
|
336
|
+
function leaveTo(pane, args) {
|
|
337
|
+
var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
|
|
338
|
+
var replace = O.pushed;
|
|
339
|
+
close('leave');
|
|
340
|
+
if (replace) location.replace(hash); else W.go(pane, args || []);
|
|
341
|
+
}
|
|
342
|
+
// A reload with the overlay open keeps its history entry, and the entry before it is Today at the same address, so
|
|
343
|
+
// Back from there showed nothing (measured in Chrome 11/07: a same-document popstate). On load, app.js hands that
|
|
344
|
+
// entry here: open the same thing again, so the first Back closes it as it did before the reload. What cannot be
|
|
345
|
+
// opened (gone, or Today left meanwhile) just clears the entry's mark.
|
|
346
|
+
var KINDS = { need: 1, run: 1, wait: 1, pet: 1, meeting: 1, intake: 1 };
|
|
347
|
+
function restore(st) {
|
|
348
|
+
if (O.open || !st || !st.cosTodayOverlay) return Promise.resolve(false);
|
|
349
|
+
var forget = function () { try { history.replaceState(null, ''); } catch (_e) {} return false; };
|
|
350
|
+
if (!KINDS[st.kind]) return Promise.resolve(forget());
|
|
351
|
+
var started = Date.now();
|
|
352
|
+
return new Promise(function (resolve) {
|
|
353
|
+
(function wait() {
|
|
354
|
+
var d = T.data(), ready = d.at > 0 && (st.kind === 'need' || st.kind === 'run' || st.kind === 'wait' || st.kind === 'pet' || d.intakeLoaded);
|
|
355
|
+
if (W.parseRoute().pane !== 'today' || O.open) return resolve(forget());
|
|
356
|
+
if (!ready) { if (Date.now() - started > 15000) return resolve(forget()); return setTimeout(wait, 150); }
|
|
357
|
+
O.pushed = true; // this entry is the overlay's own
|
|
358
|
+
// A session opened off the pet's list keeps its Back to sessions: the list's entry is still under it.
|
|
359
|
+
if (st.from === 'pet' && (st.kind === 'wait' || st.kind === 'run')) O.depth = 2;
|
|
360
|
+
try { open(st.kind, String(st.key || ''), null, st.from === 'pet' && O.depth === 2 ? { from: { scroll: 0 }, again: true } : undefined); resolve(true); }
|
|
361
|
+
catch (err) { console.error('[today] could not reopen the overlay after a reload.', err); O.pushed = false; resolve(forget()); }
|
|
362
|
+
})();
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
window.addEventListener('popstate', function () {
|
|
366
|
+
if (O.ignorePops > 0) { O.ignorePops--; return; }
|
|
367
|
+
// The phone's Back from a session opened off the pet's list goes back to that list, as Back to sessions does.
|
|
368
|
+
if (O.open && O.back) { backToList(); return; }
|
|
369
|
+
if (O.open) close('pop');
|
|
370
|
+
});
|
|
371
|
+
// The pet's list again, where it was scrolled to. From the button the session's entry is taken off history too.
|
|
372
|
+
function backToList(fromButton) {
|
|
373
|
+
var b = O.back;
|
|
374
|
+
if (!b) return;
|
|
375
|
+
if (fromButton && O.depth > 1) { O.ignorePops++; try { history.back(); } catch (_e) { O.ignorePops--; } }
|
|
376
|
+
O.depth = 1;
|
|
377
|
+
open('pet', '', O.opener, { again: true });
|
|
378
|
+
var body = $('ta-body'); if (body) body.scrollTop = b.scroll || 0;
|
|
379
|
+
}
|
|
380
|
+
W.onRoute(function (r) { if (O.open && r.pane !== 'today') close('leave'); });
|
|
381
|
+
// Escape closes the overlay, unless the Ask sheet is open over it (app.js closes that first).
|
|
382
|
+
document.addEventListener('keydown', function (e) {
|
|
383
|
+
if (!O.open || e.key !== 'Escape' || (W.readerMedia && W.readerMedia.isOpen())) return;
|
|
384
|
+
var ask = $('ask-sheet');
|
|
385
|
+
if (ask && !ask.classList.contains('hidden')) return;
|
|
386
|
+
e.preventDefault();
|
|
387
|
+
close('user');
|
|
388
|
+
}, true);
|
|
389
|
+
|
|
390
|
+
// Every tap-then-tap-again in the overlay goes through here: the first tap arms (and disarms anything else), and the
|
|
391
|
+
// second counts only once the first has been on screen COSWeb.confirmReady's moment, so a double tap or a bounce
|
|
392
|
+
// arms and never confirms (W2-r2). A tap too soon leaves it armed.
|
|
393
|
+
function confirmReadyAt(at) { return W.confirmReady ? W.confirmReady(at) : typeof at === 'number' && Date.now() - at >= 400; }
|
|
394
|
+
function confirmTap(key, repaint) {
|
|
395
|
+
var at = O.armedAt[key];
|
|
396
|
+
if (at == null) { O.armedAt = {}; O.armedAt[key] = Date.now(); repaint(); return false; }
|
|
397
|
+
if (!confirmReadyAt(at)) return false;
|
|
398
|
+
O.armedAt = {};
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
function setHead(title) { var h = $('ta-title'); if (h) h.textContent = title; }
|
|
402
|
+
|
|
403
|
+
// ── clicks inside the overlay ─────────────────────────
|
|
404
|
+
function onClick(e) {
|
|
405
|
+
var b = e.target && e.target.closest ? e.target.closest('[data-ta]') : null;
|
|
406
|
+
if (!b || b.disabled) return;
|
|
407
|
+
var what = b.getAttribute('data-ta'), id = b.getAttribute('data-id') || '';
|
|
408
|
+
if (!/^(dismiss|stop|caution-send)$/.test(what) && Object.keys(O.armedAt).length) {
|
|
409
|
+
O.armedAt = {};
|
|
410
|
+
if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) paintIntake(); else paintLive();
|
|
411
|
+
}
|
|
412
|
+
if (what === 'close') return close('user');
|
|
413
|
+
if (what === 'back') return backToList(true);
|
|
414
|
+
if (what === 'create' || what === 'link') return decide(id, 'accept');
|
|
415
|
+
if (what === 'dismiss') {
|
|
416
|
+
// Without Undo on this server a Dismiss cannot be taken back, so it asks once: tap, then tap again.
|
|
417
|
+
if (O.loop !== true && !confirmTap('dismiss:' + id, paintIntake)) return;
|
|
418
|
+
return decide(id, 'dismiss');
|
|
419
|
+
}
|
|
420
|
+
if (what === 'undo') return undo(id);
|
|
421
|
+
if (what === 'show-all') { O.open.all = true; paintIntake(); return; }
|
|
422
|
+
if (what === 'open-work') return leaveTo('work', ['sort']);
|
|
423
|
+
if (what === 'open-card') return leaveTo('work', id ? [id] : []);
|
|
424
|
+
if (what === 'open-session') return O.r ? leaveTo('sessions', [O.r.provider, O.r.id]) : null;
|
|
425
|
+
if (what === 'open-sessions') return leaveTo('sessions', []);
|
|
426
|
+
// A row of the pet's list opens that session's own sheet in its place, with Back to sessions (and the phone's Back)
|
|
427
|
+
// returning to the list where it was scrolled; X still closes the overlay.
|
|
428
|
+
if (what === 'pet-open') {
|
|
429
|
+
var pk = b.getAttribute('data-kind'), lb = $('ta-body');
|
|
430
|
+
return open(pk === 'wait' ? 'wait' : 'run', id, O.opener, { from: { scroll: lb ? lb.scrollTop || 0 : 0 } });
|
|
431
|
+
}
|
|
432
|
+
if (what === 'send') return sendReply();
|
|
433
|
+
if (what === 'att-remove' || what === 'att-retry') {
|
|
434
|
+
var fb = filesBox(); if (!fb) return;
|
|
435
|
+
O.note = { text: '', kind: '' }; paintNote();
|
|
436
|
+
if (what === 'att-remove') KIT().remove(fb, b.getAttribute('data-att')); else KIT().retry(fb, b.getAttribute('data-att'));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (what === 'stop') {
|
|
440
|
+
// A tap arms it (Stop ends the agent's turn on your Mac); the second sends it.
|
|
441
|
+
if (!confirmTap('stop', paintLive)) return;
|
|
442
|
+
return stopRun();
|
|
443
|
+
}
|
|
444
|
+
if (what === 'retry') return sendReply();
|
|
445
|
+
if (what === 'caution-send') {
|
|
446
|
+
// Shown by the Send tap: the same tap twice must not answer it.
|
|
447
|
+
if (!confirmReadyAt(O.armedAt.caution)) return;
|
|
448
|
+
if (O.r) O.r.cautionAck = true; O.cautionAsk = false; O.armedAt = {}; return sendReply();
|
|
449
|
+
}
|
|
450
|
+
if (what === 'caution-cancel') { O.cautionAsk = false; return paintVerdict(); }
|
|
451
|
+
if (what === 'send-anyway') { var rev = O.r && O.r.sendAnyway; if (O.r) O.r.sendAnyway = null; return sendReply({ acknowledgedRevision: rev }); }
|
|
452
|
+
if (what === 'more') { var box = $('ta-last-text'); if (box) box.classList.remove('clamped'); b.remove(); }
|
|
453
|
+
}
|
|
454
|
+
function onSubmit(e) {
|
|
455
|
+
if (e.target && e.target.id === 'ta-form') { e.preventDefault(); sendReply(); }
|
|
456
|
+
}
|
|
457
|
+
// The keyboard rises over the bottom of the sheet: keep the box in view as it does.
|
|
458
|
+
function onFocusIn(e) {
|
|
459
|
+
if (!e.target || e.target.id !== 'ta-input') return;
|
|
460
|
+
fit();
|
|
461
|
+
setTimeout(function () { if (!O.open) return; applyFit(); var f = $('ta-form'); if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' }); }, 250);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ═════════════ From today's meetings ═════════════
|
|
465
|
+
function intakeList() { return T.data().intake || []; }
|
|
466
|
+
function itemById(id) {
|
|
467
|
+
var hit = intakeList().filter(function (it) { return it.id === id; })[0];
|
|
468
|
+
return hit || (O.rows[id] && O.rows[id].item) || null;
|
|
469
|
+
}
|
|
470
|
+
function textOf(it) { return it.kind === 'link' ? str(it.task && it.task.text) || str(it.text) : str(it.text); }
|
|
471
|
+
// Control's source line (todaySuggestionRow): whose it is and which meeting.
|
|
472
|
+
function sourceOf(it) {
|
|
473
|
+
var meeting = str(it.meeting && it.meeting.title) || 'a meeting today';
|
|
474
|
+
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;
|
|
475
|
+
if (it.ownerIsMiles) return 'Yours, from ' + meeting;
|
|
476
|
+
var owner = str(it.owner).trim();
|
|
477
|
+
return owner ? owner + '\u2019s, from ' + meeting : 'From ' + meeting;
|
|
478
|
+
}
|
|
479
|
+
function caps() { return T.data().intakeCaps || {}; }
|
|
480
|
+
// Control's suggestionAction: a link opens its card (and, as in Work's Sort, can be linked); an ask or a review
|
|
481
|
+
// becomes a task when the server can create cards, else only Open in Work.
|
|
482
|
+
function actionsFor(it) {
|
|
483
|
+
if (it.kind === 'link') return { accept: caps().linkWrites === true ? 'link' : '', acceptLabel: 'Link', card: str(it.task && it.task.workIdentity) };
|
|
484
|
+
return { accept: caps().cardCreation === true ? 'create' : '', acceptLabel: CREATE_LABEL, card: '' };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function openIntake() {
|
|
488
|
+
// Whether this server can undo decides whether Dismiss asks twice; ask before the first tap can land.
|
|
489
|
+
probeLoop().then(function () { if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) paintIntake(); });
|
|
490
|
+
paintIntake();
|
|
491
|
+
var foot = $('ta-foot');
|
|
492
|
+
// A waiting sheet opened before may have hidden the foot (its reply box); this one always shows (QA round 1).
|
|
493
|
+
if (foot) { foot.innerHTML = '<div class="ta-links"><button type="button" class="link-btn" data-ta="open-work">Open Sort in Work</button></div>'; foot.hidden = false; }
|
|
494
|
+
// Read Intake again: what was decided on another screen since Today loaded shows as handled, never as a phantom.
|
|
495
|
+
var asked = T.reloadIntake();
|
|
496
|
+
if (asked && asked.then) asked.then(function () { if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) { noteVanished(); paintIntake(); } });
|
|
497
|
+
}
|
|
498
|
+
function noteVanished() {
|
|
499
|
+
var open = {};
|
|
500
|
+
intakeList().forEach(function (it) { open[it.id] = 1; });
|
|
501
|
+
Object.keys(O.shown).forEach(function (id) {
|
|
502
|
+
if (open[id] || O.rows[id]) return;
|
|
503
|
+
O.rows[id] = { state: 'gone', note: GONE, item: O.shown[id] };
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
// What the overlay lists: open suggestions in the server's order, plus this overlay's settled lines in their place.
|
|
507
|
+
function visibleItems() {
|
|
508
|
+
var list = intakeList().slice();
|
|
509
|
+
var listed = {};
|
|
510
|
+
list.forEach(function (it) { listed[it.id] = 1; });
|
|
511
|
+
Object.keys(O.rows).forEach(function (id) { if (!listed[id] && O.rows[id].item) list.push(O.rows[id].item); });
|
|
512
|
+
list.sort(function (a, b) {
|
|
513
|
+
var x = str(a.meeting && a.meeting.filename) + '|' + a.id, y = str(b.meeting && b.meeting.filename) + '|' + b.id;
|
|
514
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
515
|
+
});
|
|
516
|
+
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; });
|
|
517
|
+
return list;
|
|
518
|
+
}
|
|
519
|
+
function openCount() { return intakeList().length; }
|
|
520
|
+
|
|
521
|
+
// A row that has left the count: decided here, or gone. One being decided, or refused, still counts (qa2-user N4).
|
|
522
|
+
function settled(id) { var r = O.rows[id]; return !!r && /^(created|dismissed|stale|gone)$/.test(r.state); }
|
|
523
|
+
function rowHtml(it) {
|
|
524
|
+
var row = O.rows[it.id] || { state: 'ready' };
|
|
525
|
+
var acts = actionsFor(it), lock = !!O.busy;
|
|
526
|
+
var text = '<p class="ta-sugg-text">' + esc(textOf(it)) + '</p><p class="ta-sugg-src">' + esc(sourceOf(it)) + '</p>';
|
|
527
|
+
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>';
|
|
528
|
+
var line = '';
|
|
529
|
+
switch (row.state) {
|
|
530
|
+
case 'creating': line = '<span class="ta-wait" role="status">Creating</span>'; break;
|
|
531
|
+
case 'dismissing': line = '<span class="ta-wait" role="status">Dismissing</span>'; break;
|
|
532
|
+
case 'created':
|
|
533
|
+
line = '<span class="ta-done">' + esc(row.linked ? 'Linked. It is in Work.' : 'Created. It is in Work.') + '</span>' +
|
|
534
|
+
'<button type="button" class="link-btn" data-ta="open-card" data-id="' + esc(row.taskId || acts.card) + '">Open card</button>' +
|
|
535
|
+
undoHtml(it, row);
|
|
536
|
+
break;
|
|
537
|
+
case 'dismissed':
|
|
538
|
+
return '<div class="ta-sugg settled" data-row="' + esc(it.id) + '"><span class="ta-done">Dismissed.</span>' + undoHtml(it, row) + '</div>';
|
|
539
|
+
case 'stale': case 'gone':
|
|
540
|
+
return '<div class="ta-sugg settled" data-row="' + esc(it.id) + '">' + text + '<p class="ta-note">' + esc(row.note || GONE) + '</p></div>';
|
|
541
|
+
default: {
|
|
542
|
+
var buttons = '';
|
|
543
|
+
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>';
|
|
544
|
+
if (acts.card) buttons += '<button type="button" class="link-btn" data-ta="open-card" data-id="' + esc(acts.card) + '">Open card</button>';
|
|
545
|
+
if (!acts.accept || it.kind !== 'link') buttons += '<button type="button" class="link-btn" data-ta="open-work">Open in Work</button>';
|
|
546
|
+
var armed = O.armedAt['dismiss:' + it.id] != null;
|
|
547
|
+
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>';
|
|
548
|
+
line = buttons;
|
|
549
|
+
if (row.note) text += '<p class="ta-note" role="alert">' + esc(row.note) + '</p>';
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return '<div class="ta-sugg" data-row="' + esc(it.id) + '">' + text + '<div class="ta-acts">' + line + '</div></div>';
|
|
553
|
+
}
|
|
554
|
+
function undoHtml(it, row) {
|
|
555
|
+
if (O.loop !== true || !row.undo) return '';
|
|
556
|
+
if (row.undoing) return '<span class="ta-wait" role="status">Undoing</span>';
|
|
557
|
+
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>' +
|
|
558
|
+
(row.undoNote ? '<span class="ta-note">' + esc(row.undoNote) + '</span>' : '');
|
|
559
|
+
}
|
|
560
|
+
function intakeHtml() {
|
|
561
|
+
var list = visibleItems(), n = openCount();
|
|
562
|
+
var groups = [], byTitle = {};
|
|
563
|
+
list.forEach(function (it) {
|
|
564
|
+
var t = str(it.meeting && it.meeting.title) || 'A meeting';
|
|
565
|
+
if (!byTitle[t]) { byTitle[t] = { title: t, items: [] }; groups.push(byTitle[t]); }
|
|
566
|
+
byTitle[t].items.push(it);
|
|
567
|
+
O.shown[it.id] = it;
|
|
568
|
+
});
|
|
569
|
+
var single = O.open && O.open.kind === 'meeting' && !O.open.all;
|
|
570
|
+
var head = '<p class="ta-sub">' + esc(n === 0 ? 'Nothing from today\u2019s meetings is waiting. What you decided is in Work.'
|
|
571
|
+
: (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.') +
|
|
572
|
+
' They move to Work at midnight.') + '</p>';
|
|
573
|
+
var body = groups.map(function (g) {
|
|
574
|
+
return (single ? '' : '<h3 class="ta-group">' + esc(g.title) + '</h3>') + g.items.map(rowHtml).join('');
|
|
575
|
+
}).join('');
|
|
576
|
+
if (!list.length && n > 0 && single) body = '<p class="ta-sub">Nothing left from this meeting.</p>';
|
|
577
|
+
if (!T.data().intakeLoaded) body = '<p class="ta-sub">Reading today\u2019s suggestions\u2026</p>';
|
|
578
|
+
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>' : '';
|
|
579
|
+
return head + '<div class="ta-list">' + body + '</div>' + more;
|
|
580
|
+
}
|
|
581
|
+
function paintIntake() {
|
|
582
|
+
if (!O.open || (O.open.kind !== 'meeting' && O.open.kind !== 'intake')) return;
|
|
583
|
+
var single = O.open.kind === 'meeting' && !O.open.all;
|
|
584
|
+
setHead(single ? O.open.key : 'From today\u2019s meetings');
|
|
585
|
+
var body = $('ta-body'); if (body) body.innerHTML = intakeHtml();
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// One decision at a time (Control: one resolve, its busy ids): every other button waits for it, and the same tap
|
|
589
|
+
// twice sends once. Accept and dismiss go through Work's own route.
|
|
590
|
+
function decide(id, action) {
|
|
591
|
+
if (O.busy || !id) return;
|
|
592
|
+
var it = itemById(id); if (!it) return;
|
|
593
|
+
var row = O.rows[id];
|
|
594
|
+
if (row && /^(created|dismissed|stale|gone)$/.test(row.state)) return;
|
|
595
|
+
if (action === 'accept' && !actionsFor(it).accept) return;
|
|
596
|
+
var asked = row && row.asked && row.action === action ? row.asked : Date.now();
|
|
597
|
+
O.busy = id;
|
|
598
|
+
O.rows[id] = { state: action === 'accept' ? 'creating' : 'dismissing', item: it, asked: asked, action: action };
|
|
599
|
+
paintIntake();
|
|
600
|
+
return api('/api/work-intake/' + encodeURIComponent(id) + '/resolve', { method: 'POST', body: { action: action }, timeoutMs: 75000 }).then(function (d) {
|
|
601
|
+
O.busy = '';
|
|
602
|
+
var got = (d && d.item) || {}, res = got.resolution || {};
|
|
603
|
+
var at = Date.parse(str(res.at));
|
|
604
|
+
var mine = res.action === action && !(isFinite(at) && at < asked - STALE_MS);
|
|
605
|
+
O.dirty = true;
|
|
606
|
+
T.markGone(id, true);
|
|
607
|
+
workChanged();
|
|
608
|
+
if (!res.action || mine) {
|
|
609
|
+
var undoId = res.at ? id + ':' + res.at : '';
|
|
610
|
+
if (action === 'accept') O.rows[id] = { state: 'created', item: it, taskId: str(res.taskId), linked: it.kind === 'link', undo: undoId };
|
|
611
|
+
else { O.rows[id] = { state: 'dismissed', item: it, undo: undoId, until: Date.now() + UNDO_SECONDS * 1000 }; expireLater(); }
|
|
612
|
+
} else {
|
|
613
|
+
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.' };
|
|
614
|
+
}
|
|
615
|
+
return probeLoop().then(paintIntake);
|
|
616
|
+
}, function (err) {
|
|
617
|
+
O.busy = '';
|
|
618
|
+
var code = err && err.code;
|
|
619
|
+
if (code === 'intake_not_found' || (err && err.status === 404 && code !== 'card_creation_unavailable')) {
|
|
620
|
+
O.rows[id] = { state: 'gone', item: it, note: GONE };
|
|
621
|
+
O.dirty = true; T.markGone(id, true);
|
|
622
|
+
} else {
|
|
623
|
+
var why = (code && REFUSALS[code]) || (err && err.message) || 'That did not go through.';
|
|
624
|
+
if (err && err.code === 'timeout') why = 'Your Mac did not answer in time. It may still have saved it. Trying again is safe.';
|
|
625
|
+
O.rows[id] = { state: action === 'accept' ? 'failed' : 'ready', item: it, note: why, asked: asked, action: action };
|
|
626
|
+
}
|
|
627
|
+
paintIntake();
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
function expireLater() {
|
|
631
|
+
clearTimeout(O.expireTimer);
|
|
632
|
+
O.expireTimer = setTimeout(function () {
|
|
633
|
+
var now = Date.now(), left = false;
|
|
634
|
+
Object.keys(O.rows).forEach(function (id) {
|
|
635
|
+
var r = O.rows[id];
|
|
636
|
+
if (r.state !== 'dismissed' || r.undoing) return;
|
|
637
|
+
if (r.until <= now) delete O.rows[id]; else left = true;
|
|
638
|
+
});
|
|
639
|
+
paintIntake();
|
|
640
|
+
if (left) expireLater();
|
|
641
|
+
}, UNDO_SECONDS * 1000 + 250);
|
|
642
|
+
}
|
|
643
|
+
// Undo shows only on a server that has Work's undo route (Control's probeWorkLoopIfNeeded; panes/work.js probeLoop).
|
|
644
|
+
function probeLoop() {
|
|
645
|
+
if (O.loop !== null) return Promise.resolve();
|
|
646
|
+
return api('/api/work-intake/decisions').then(function () { O.loop = true; }, function (err) { if (err && err.status === 404) O.loop = false; });
|
|
647
|
+
}
|
|
648
|
+
function undo(id) {
|
|
649
|
+
var row = O.rows[id];
|
|
650
|
+
if (!row || !row.undo || row.undoing || O.busy) return;
|
|
651
|
+
row.undoing = true; row.undoNote = '';
|
|
652
|
+
paintIntake();
|
|
653
|
+
return api('/api/work-intake/undo', { method: 'POST', body: { batchId: row.undo } }).then(function () {
|
|
654
|
+
delete O.rows[id];
|
|
655
|
+
T.markGone(id, false);
|
|
656
|
+
O.dirty = true; workChanged();
|
|
657
|
+
return T.reloadIntake();
|
|
658
|
+
}, function (err) {
|
|
659
|
+
row.undoing = false;
|
|
660
|
+
if (err && err.status === 404) O.loop = false;
|
|
661
|
+
row.undoNote = err && err.status === 404 ? 'Undo needs a newer COS server. Change it in Work.' : (err && err.message) || 'Undo did not go through.';
|
|
662
|
+
if (row.state === 'dismissed') { row.until = Date.now() + UNDO_SECONDS * 1000; expireLater(); }
|
|
663
|
+
}).then(paintIntake);
|
|
664
|
+
}
|
|
665
|
+
// Work's own copy of Intake and the board are stale now: it reads them again the next time it opens.
|
|
666
|
+
function workChanged() {
|
|
667
|
+
var work = W.pane && W.pane('work');
|
|
668
|
+
if (work && work.intakeChanged) { try { work.intakeChanged(); } catch (_e) {} }
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ═════════════ Needs you and Running ═════════════
|
|
672
|
+
function sessionRowFor(provider, id) {
|
|
673
|
+
var want = lower(id);
|
|
674
|
+
return (T.data().sessions || []).filter(function (s) {
|
|
675
|
+
var k = T.splitSessionId(s.provider, s.session_id || s.id);
|
|
676
|
+
return lower(k.id) === want && (!provider || k.provider === provider);
|
|
677
|
+
})[0] || null;
|
|
678
|
+
}
|
|
679
|
+
function needById(id) { return (T.data().needs || []).filter(function (it) { return it.id === id; })[0] || null; }
|
|
680
|
+
|
|
681
|
+
function openSession(kind, key) {
|
|
682
|
+
var provider = '', id = '';
|
|
683
|
+
O.item = null;
|
|
684
|
+
O.wait = null;
|
|
685
|
+
if (kind === 'need') {
|
|
686
|
+
O.item = needById(key);
|
|
687
|
+
if (O.item) { var k = T.splitSessionId(O.item.provider, O.item.sessionId); provider = k.provider; id = k.id; }
|
|
688
|
+
} else {
|
|
689
|
+
// A session waiting on you (a Needs you row): Today's own row gives the words, as it listed them.
|
|
690
|
+
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; }
|
|
691
|
+
var p = key.split('|'); provider = p[0]; id = p.slice(1).join('|');
|
|
692
|
+
}
|
|
693
|
+
O.row = id ? sessionRowFor(provider, id) : null;
|
|
694
|
+
O.r = id ? K.session(provider, id, VIEW) : null;
|
|
695
|
+
// Working only when the session is not waiting on you: a session paused on a question is not at work (W4-r2).
|
|
696
|
+
// 6.99.29: the row is one signal of the card's one state (app.js sxTruth), timed by its own evidence.
|
|
697
|
+
if (O.r) { O.r.live = {}; K.feed.noteList(O.r, O.row); K.feed.settle(O.r); }
|
|
698
|
+
// What was typed, and a send whose answer never came, survive a close, Back or reload: the same words resend under
|
|
699
|
+
// the same key (skeptic W3, qa2-user N1).
|
|
700
|
+
var kept = O.r ? draftOf(O.r) : null;
|
|
701
|
+
if (kept && kept.unsettled) {
|
|
702
|
+
O.r.draft = { text: kept.unsettled.text, key: kept.unsettled.key };
|
|
703
|
+
if (Array.isArray(kept.unsettled.ids) && kept.unsettled.ids.length) { O.r.draft.ids = kept.unsettled.ids.slice(); O.r.draft.restored = true; }
|
|
704
|
+
}
|
|
705
|
+
O.last = null; O.lastState = 'idle';
|
|
706
|
+
setHead(kind === 'need' ? (O.item ? str(O.item.sessionTitle) || str(O.item.title) || 'Handoff' : 'Needs you')
|
|
707
|
+
: kind === 'wait' ? (O.wait ? O.wait.title : 'A session is waiting for you') : (O.row ? K.title(O.row) : 'Session'));
|
|
708
|
+
var body = $('ta-body');
|
|
709
|
+
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>';
|
|
710
|
+
var foot = $('ta-foot');
|
|
711
|
+
if (foot) { foot.innerHTML = O.r ? composerHtml(kind) : ''; foot.hidden = false; }
|
|
712
|
+
var input = $('ta-input');
|
|
713
|
+
if (input && kept && kept.text) { input.value = kept.text; W.autoGrow(input); }
|
|
714
|
+
if (O.r) {
|
|
715
|
+
paintFiles();
|
|
716
|
+
// The Mac's answer about files may not be in yet: ask once, then draw the clip.
|
|
717
|
+
var kit = KIT(), r0 = O.r;
|
|
718
|
+
if (kit && !kit.turnFiles()) kit.ensureCaps().then(function () { if (O.r === r0) paintFiles(); }, function () {});
|
|
719
|
+
// Files of a send a reload cut off go again with the same words; say so, since their chips are gone.
|
|
720
|
+
if (O.r.draft && O.r.draft.restored) {
|
|
721
|
+
var nf = O.r.draft.ids.length;
|
|
722
|
+
O.note = { text: 'Your last message did not finish sending. Send sends it again with the ' + (nf === 1 ? 'file' : nf + ' files') + ' you attached.', kind: '' };
|
|
723
|
+
paintNote();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
paintMeta();
|
|
727
|
+
if (!O.r) return;
|
|
728
|
+
if (W.sessionQuestions && W.sessionQuestions.mount) {
|
|
729
|
+
// mount() draws at once and calls back before it returns, so the counts come from the callback, not O.qMount:
|
|
730
|
+
// the questions to answer here, and those the Mac's own prompt waits on (drawn read-only).
|
|
731
|
+
O.qMount = W.sessionQuestions.mount($('ta-q'), id, function (n, mac) { O.qCount = n; O.macCount = mac || 0; paintMeta(); });
|
|
732
|
+
}
|
|
733
|
+
if (kind === 'run') { paintLive(); watchStream(O.r); }
|
|
734
|
+
loadLast(O.r);
|
|
735
|
+
K.probe(O.r);
|
|
736
|
+
if (kind === 'need') recheckNeed(key);
|
|
737
|
+
}
|
|
738
|
+
function stopSession() {
|
|
739
|
+
if (K.imagesStop) K.imagesStop($('ta-last'));
|
|
740
|
+
if (O.stream) { try { O.stream.close(); } catch (_e) {} O.stream = null; }
|
|
741
|
+
clearInterval(O.clock); O.clock = null;
|
|
742
|
+
clearInterval(O.watch); O.watch = null;
|
|
743
|
+
if (O.qMount) { O.qMount.unmount(); O.qMount = null; }
|
|
744
|
+
O.qCount = 0; O.macCount = 0;
|
|
745
|
+
O.r = null; O.row = null; O.item = null;
|
|
746
|
+
}
|
|
747
|
+
// The Needs you list may be minutes old: read it again, and say so if this one is no longer waiting.
|
|
748
|
+
function recheckNeed(key) {
|
|
749
|
+
var r = O.r;
|
|
750
|
+
api('/api/work-board/your-move').then(function (d) {
|
|
751
|
+
if (O.r !== r) return;
|
|
752
|
+
var items = (d && d.items) || [];
|
|
753
|
+
if (d && d.available !== false && !items.some(function (it) { return it.id === key; })) {
|
|
754
|
+
O.needGone = true; paintMeta();
|
|
755
|
+
T.refresh(); // and Today drops its row
|
|
756
|
+
}
|
|
757
|
+
}, function () {});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function composerHtml(kind) {
|
|
761
|
+
var label = kind === 'run' ? 'Send a note to this session' : 'Reply to this session';
|
|
762
|
+
return '<div id="ta-verdict"></div>' +
|
|
763
|
+
'<form class="composer ta-composer" id="ta-form">' +
|
|
764
|
+
'<div class="cc-chips ta-files hidden" id="ta-files"></div>' +
|
|
765
|
+
'<textarea id="ta-input" rows="1" placeholder="' + esc(label) + '" aria-label="' + esc(label) + '" enterkeyhint="send"></textarea>' +
|
|
766
|
+
'<div class="composer-actions"><span id="ta-clip" class="ta-clip"></span><button type="submit" class="btn primary small" id="ta-send">Send</button></div>' +
|
|
767
|
+
'</form>' +
|
|
768
|
+
'<p class="status-line" id="ta-status" role="status" aria-live="polite"></p>';
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ── files with a reply or a note (the COS chat's attach kit) ──
|
|
772
|
+
// One box per session, kept while this tab is open, so closing and reopening the sheet keeps what was attached.
|
|
773
|
+
var FILES = {};
|
|
774
|
+
function KIT() { return W.cosChat && W.cosChat.attach ? W.cosChat.attach : null; }
|
|
775
|
+
// Only where the Mac takes files on a session turn: on an older server the words go alone, and no clip says otherwise.
|
|
776
|
+
function filesOn() { var k = KIT(); return !!(k && k.turnFiles() > 0 && O.r); }
|
|
777
|
+
function filesBox(r) {
|
|
778
|
+
r = r || O.r; var kit = KIT();
|
|
779
|
+
if (!r || !kit) return null;
|
|
780
|
+
var key = r.provider + '|' + r.id;
|
|
781
|
+
// 6.99.27: any file the Mac takes on a turn (threadTurnFileBytes), sent as it is; else the chat's list.
|
|
782
|
+
if (!FILES[key]) FILES[key] = kit.box({
|
|
783
|
+
changed: function (box) { if (O.r && FILES[O.r.provider + '|' + O.r.id] === box) paintFiles(); },
|
|
784
|
+
removed: function () {},
|
|
785
|
+
signedOut: function (box) { if (O.r && FILES[O.r.provider + '|' + O.r.id] === box) { O.note = { text: 'This browser was signed out. Your message and files are kept. Sign in, then tap Retry on each file.', kind: 'error' }; paintNote(); } }
|
|
786
|
+
}, { anyBytes: kit.turnFileBytes() });
|
|
787
|
+
// The Mac's answer can come after the box was made: a box with nothing in it yet takes it then.
|
|
788
|
+
if (!FILES[key].anyBytes && !FILES[key].atts.length && kit.turnFileBytes()) FILES[key].anyBytes = kit.turnFileBytes();
|
|
789
|
+
return FILES[key];
|
|
790
|
+
}
|
|
791
|
+
function filesState(box) {
|
|
792
|
+
var kit = KIT();
|
|
793
|
+
if (!kit || !box || !box.atts.length) return 'none';
|
|
794
|
+
return kit.pending(box) ? 'waiting' : kit.failed(box) ? 'failed' : 'ready';
|
|
795
|
+
}
|
|
796
|
+
function paintClip() {
|
|
797
|
+
var slot = $('ta-clip'); if (!slot) return;
|
|
798
|
+
var kit = KIT();
|
|
799
|
+
if (!filesOn()) { slot.innerHTML = ''; return; }
|
|
800
|
+
var box = filesBox(), full = !!(box && box.atts.length >= kit.max);
|
|
801
|
+
var mode = String(box && box.anyBytes || 0);
|
|
802
|
+
if (slot.getAttribute('data-mode') !== mode) { slot.innerHTML = kit.cameraHtml('data-ta-file') + kit.clipHtml('data-ta-file', { any: box && box.anyBytes }); slot.setAttribute('data-mode', mode); }
|
|
803
|
+
var lbls = slot.querySelectorAll ? slot.querySelectorAll('.cc-attach') : [], inps = slot.querySelectorAll ? slot.querySelectorAll('[data-ta-file]') : [];
|
|
804
|
+
Array.prototype.forEach.call(lbls, function (l) { l.classList.toggle('full', full); });
|
|
805
|
+
Array.prototype.forEach.call(inps, function (i) { i.disabled = full || !!(O.r && O.r.sending); });
|
|
806
|
+
}
|
|
807
|
+
function paintFiles() {
|
|
808
|
+
var el = $('ta-files'), kit = KIT(), box = filesBox();
|
|
809
|
+
if (el && kit && box) {
|
|
810
|
+
el.innerHTML = kit.chipsHtml(box, 'data-ta') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
|
|
811
|
+
el.classList.toggle('hidden', !box.atts.length);
|
|
812
|
+
}
|
|
813
|
+
paintClip(); paintSendButton();
|
|
814
|
+
}
|
|
815
|
+
// Send waits while a file uploads. A failed file never greys it out (QA 6.99.27): a tap says why, as the question
|
|
816
|
+
// card does.
|
|
817
|
+
function paintSendButton() {
|
|
818
|
+
var send = $('ta-send'); if (!send) return;
|
|
819
|
+
var sending = !!(O.r && O.r.sending), st = filesState(filesBox());
|
|
820
|
+
send.disabled = sending || st === 'waiting';
|
|
821
|
+
send.textContent = sending ? 'Sending' : st === 'waiting' ? 'Uploading' : 'Send';
|
|
822
|
+
}
|
|
823
|
+
function addFiles(list) {
|
|
824
|
+
var kit = KIT(), box = filesBox();
|
|
825
|
+
if (!kit || !box || !list || !list.length || !filesOn()) return;
|
|
826
|
+
var refusals = kit.add(box, list);
|
|
827
|
+
if (refusals.length) { O.note = { text: refusals.join(' '), kind: 'error' }; paintNote(); }
|
|
828
|
+
paintFiles();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function needLine(it) {
|
|
832
|
+
var why = T.needWhy(it) + (it.domain ? ' in ' + W.domainLabel(it.domain) : '');
|
|
833
|
+
var age = W.ago(it.updatedAt || it.createdAt);
|
|
834
|
+
if (!age) return why;
|
|
835
|
+
if (age === 'now') return why + ', just now';
|
|
836
|
+
if (/ (min|h)$/.test(age)) return why + ', ' + age + ' ago';
|
|
837
|
+
return why + ', since ' + (age === 'Yesterday' ? 'yesterday' : age);
|
|
838
|
+
}
|
|
839
|
+
function stateWords(s) {
|
|
840
|
+
if (!s) return '';
|
|
841
|
+
var st = K.stateOf(s);
|
|
842
|
+
if (st === 'running') return 'Working now, for ' + T.span(s.state_since || s.last_activity_at);
|
|
843
|
+
if (st === 'waiting') { var l = K.stateLabel(s).replace(/^Waiting( · )?/, ''); return 'Waiting for you' + (l ? ': ' + l.replace(/ · /g, ', ') : ''); }
|
|
844
|
+
if (st === 'error') return K.stateLabel(s).replace(/ · /g, ': ');
|
|
845
|
+
if (st === 'stale') return 'Not running on your Mac';
|
|
846
|
+
return s.running ? 'Open and quiet for ' + T.span(s.last_activity_at || s.state_since) : 'Not running right now';
|
|
847
|
+
}
|
|
848
|
+
function runWords() {
|
|
849
|
+
var r = O.r, f = r && r.feed;
|
|
850
|
+
if (f && (f.connected || f.lastSeq > 0)) {
|
|
851
|
+
var w = K.feed.word(r), since = K.feed.elapsed(f, r);
|
|
852
|
+
if (w === 'Working') return 'Working now' + (since ? ', for ' + since : '');
|
|
853
|
+
if (w === 'Waiting') return 'Waiting for you' + (since ? ', for ' + since : '');
|
|
854
|
+
if (w === 'Failed') return 'Stopped with an error';
|
|
855
|
+
if (w === 'Done') return 'Finished';
|
|
856
|
+
return 'Idle' + (since ? ' for ' + since : '');
|
|
857
|
+
}
|
|
858
|
+
return O.row ? stateWords(O.row) : '';
|
|
859
|
+
}
|
|
860
|
+
function paintMeta() {
|
|
861
|
+
var box = $('ta-meta'); if (!box || !O.open) return;
|
|
862
|
+
var html = '';
|
|
863
|
+
var questions = O.qCount || 0;
|
|
864
|
+
if (O.open.kind === 'need') {
|
|
865
|
+
var it = O.item;
|
|
866
|
+
if (!it) { box.innerHTML = '<p class="ta-note">This is no longer waiting on you. It was handled on another screen.</p>'; return; }
|
|
867
|
+
// Gone: say so in place of the waiting line, never under it (qa2-user N3).
|
|
868
|
+
if (O.needGone) html += '<p class="ta-note" role="status">This is no longer waiting on you. It was handled on another screen.</p>';
|
|
869
|
+
else html += '<p class="ta-sub">' + esc(needLine(it)) + '</p>';
|
|
870
|
+
var card = str(it.title), session = str(it.sessionTitle);
|
|
871
|
+
if (card && card !== session) html += '<p class="ta-card-line">' + esc('Work card: ' + card) + '</p>';
|
|
872
|
+
var p = it.progress && !it.progress.unreadable ? it.progress : null;
|
|
873
|
+
if (p && p.evidence) html += '<blockquote class="ta-quote">' + esc(p.evidence) + '</blockquote>';
|
|
874
|
+
} else if (O.open.kind === 'wait') {
|
|
875
|
+
// A session waiting on you: what it waits on, its question to answer here when the Mac holds one for this page;
|
|
876
|
+
// when the Mac's own prompt has it, that, read-only; else (a reply is the answer) the reply box below. No live
|
|
877
|
+
// peek and no Stop: it is not at work (W4-r2).
|
|
878
|
+
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>';
|
|
879
|
+
var where = waitWhere();
|
|
880
|
+
// The Mac card says it itself when there is one; say it here only when there is none.
|
|
881
|
+
if (where === 'mac' && !O.macCount) html += '<p class="ta-sub ta-mac-line">' + esc(macLine()) + '</p>';
|
|
882
|
+
if (where === 'answered') html += '<p class="ta-sub ta-mac-line">' + esc((W.sessionQuestions && W.sessionQuestions.answeredLine) || 'Answered. The session carries on.') + '</p>';
|
|
883
|
+
if (where === 'reply') html += '<p class="ta-sub">' + esc('Answer it in a reply below, or open the session.') + '</p>';
|
|
884
|
+
} else if (O.row || O.r) {
|
|
885
|
+
// Once the live feed speaks, the line says what it says, so the header and the peek never disagree (qa2-user N3).
|
|
886
|
+
html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : O.row.provider) + ' <span id="ta-run-state">' + esc(runWords()) + '</span></p>';
|
|
887
|
+
}
|
|
888
|
+
if (O.row && O.open.kind === 'need') html += '<p class="ta-sub">' + esc(stateWords(O.row)) + '</p>';
|
|
889
|
+
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>';
|
|
890
|
+
var links = '';
|
|
891
|
+
if (O.r) links += '<button type="button" class="link-btn" data-ta="open-session">Open session</button>';
|
|
892
|
+
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>';
|
|
893
|
+
if (links) html += '<div class="ta-links">' + links + '</div>';
|
|
894
|
+
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>';
|
|
895
|
+
box.innerHTML = html;
|
|
896
|
+
// The reply box shows only where a reply can be the answer; what was typed stays in it, hidden.
|
|
897
|
+
var foot = $('ta-foot');
|
|
898
|
+
if (foot && O.open.kind === 'wait') foot.hidden = waitWhere() !== 'reply';
|
|
899
|
+
}
|
|
900
|
+
// Where a waiting session's question is answered: 'here' (its cards in this sheet), 'answered' (on some screen just
|
|
901
|
+
// now), 'mac' (a Claude session's own prompt, or a held question that went back to the Mac), or 'reply' (a Codex or
|
|
902
|
+
// Cursor session asking in words).
|
|
903
|
+
function waitWhere() {
|
|
904
|
+
if (O.qCount) return 'here';
|
|
905
|
+
if (O.r && W.sessionQuestions && W.sessionQuestions.answeredFor && W.sessionQuestions.answeredFor(O.r.id)) return 'answered';
|
|
906
|
+
if (O.macCount || (O.r && O.r.provider === 'claude') || (O.wait && O.wait.where === 'mac')) return 'mac';
|
|
907
|
+
return 'reply';
|
|
908
|
+
}
|
|
909
|
+
function macLine() { return (W.sessionQuestions && W.sessionQuestions.macLine) || "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens)."; }
|
|
910
|
+
|
|
911
|
+
// ═════════════ The pet's sessions ═════════════
|
|
912
|
+
// The same sessions the pet's caption counts (app.js petPlay): waiting on you, running, then done today. Each row
|
|
913
|
+
// opens that session's own sheet.
|
|
914
|
+
var PET_DONE_SHOWN = 5;
|
|
915
|
+
function openPet() {
|
|
916
|
+
setHead('Sessions');
|
|
917
|
+
var p = T.petPlay ? T.petPlay() : { waiting: [], running: [], done: [] };
|
|
918
|
+
var row = function (s, kind, line) {
|
|
919
|
+
var k = T.splitSessionId(s.provider, s.session_id || s.id);
|
|
920
|
+
return '<button type="button" class="ta-pet-row" data-ta="pet-open" data-kind="' + kind + '" data-id="' + esc(k.provider + '|' + k.id) + '">' +
|
|
921
|
+
'<span class="ta-pet-top">' + T.badge(k.provider) + '<span class="ta-pet-title">' + esc(T.sessionTitle(s)) + '</span></span>' +
|
|
922
|
+
'<span class="ta-pet-line">' + esc(line) + '</span></button>';
|
|
923
|
+
};
|
|
924
|
+
var group = function (title, rows) { return rows.length ? '<section class="ta-pet-group"><h3 class="ta-pet-head">' + esc(title) + '</h3>' + rows.join('') + '</section>' : ''; };
|
|
925
|
+
// 6.99.27: the questions this page holds come first, one row per session, each opening that session's cards.
|
|
926
|
+
var asked = petAsked(), keyOf = function (s) { var k = T.splitSessionId(s.provider, s.session_id || s.id); return (k.provider + '|' + k.id).toLowerCase(); };
|
|
927
|
+
var questions = asked.map(function (a) {
|
|
928
|
+
var s = sessionRowFor(a.provider, a.id);
|
|
929
|
+
var k = esc(a.provider + '|' + a.id);
|
|
930
|
+
return '<button type="button" class="ta-pet-row" data-ta="pet-open" data-kind="wait" data-id="' + k + '">' +
|
|
931
|
+
'<span class="ta-pet-top">' + T.badge(a.provider) + '<span class="ta-pet-title">' + esc(s ? T.sessionTitle(s) : a.title) + '</span></span>' +
|
|
932
|
+
'<span class="ta-pet-line">' + esc(a.line) + '</span></button>';
|
|
933
|
+
});
|
|
934
|
+
var held = {}; asked.forEach(function (a) { held[(a.provider + '|' + a.id).toLowerCase()] = 1; });
|
|
935
|
+
var waiting = p.waiting.filter(function (s) { return !held[keyOf(s)]; }).map(function (s) { return row(s, 'wait', stateWords(s)); });
|
|
936
|
+
// The pet counts a session as running from the Running card's test; one of those paused on you opens as waiting.
|
|
937
|
+
var running = p.running.filter(function (s) { return !held[keyOf(s)]; }).map(function (s) { return row(s, K.stateOf(s) === 'waiting' ? 'wait' : 'run', stateWords(s)); });
|
|
938
|
+
var done = p.done.slice(0, PET_DONE_SHOWN).map(function (s) { return row(s, 'run', 'Last active ' + doneWhen(s)); });
|
|
939
|
+
var body = $('ta-body');
|
|
940
|
+
if (body) body.innerHTML = group(questions.length === 1 ? 'A question for you' : 'Questions for you', questions) + group('Waiting on you', waiting) + group('Running', running) + group('Done today', done) +
|
|
941
|
+
(!questions.length && !waiting.length && !running.length ? '<p class="ta-note">Nothing is running or waiting on you now.</p>' : '') +
|
|
942
|
+
'<div class="ta-links"><button type="button" class="link-btn" data-ta="open-sessions">All sessions</button></div>';
|
|
943
|
+
var foot = $('ta-foot'); if (foot) { foot.innerHTML = ''; foot.hidden = false; }
|
|
944
|
+
}
|
|
945
|
+
// The held questions by session (panes/questions.js), oldest first: the words of each session's first question.
|
|
946
|
+
function petAsked() {
|
|
947
|
+
var q = W.sessionQuestions, items = q && q.itemsFor ? q.itemsFor('') : [], out = [], at = {};
|
|
948
|
+
(items || []).forEach(function (it) {
|
|
949
|
+
if (!it || !it.sessionId) return;
|
|
950
|
+
var k = T.splitSessionId(it.provider, it.sessionId), key = (k.provider + '|' + k.id).toLowerCase();
|
|
951
|
+
if (at[key] !== undefined) { out[at[key]].n++; return; }
|
|
952
|
+
var first = it.kind === 'approval' ? (it.approval && it.approval.summary ? 'Wants to run ' + it.approval.summary : 'Wants your permission')
|
|
953
|
+
: String(((it.questions || [])[0] || {}).question || 'Asked you a question');
|
|
954
|
+
at[key] = out.length;
|
|
955
|
+
out.push({ provider: k.provider, id: k.id, n: 1, line: clip(first, 160), title: it.kind === 'approval' ? 'A session wants your permission' : 'A session asked you a question' });
|
|
956
|
+
});
|
|
957
|
+
out.forEach(function (a) { if (a.n > 1) a.line = a.n + ' questions. ' + a.line; });
|
|
958
|
+
return out;
|
|
959
|
+
}
|
|
960
|
+
function doneWhen(s) {
|
|
961
|
+
var a = W.ago(s.last_activity_at || s.state_since);
|
|
962
|
+
if (!a || a === 'now') return 'just now';
|
|
963
|
+
return /( min| h)$/.test(a) ? a + ' ago' : a;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// The session's last reply, from the same read the reader makes (?turns=N, lib/agent-session-turns.ts).
|
|
967
|
+
function loadLast(r) {
|
|
968
|
+
var request = r.lastRead = {};
|
|
969
|
+
O.lastState = 'loading'; O.lastWhy = ''; paintLast();
|
|
970
|
+
return K.answer(K.path(r.provider, r.id, '?turns=6'), { timeoutMs: 20000 }).then(function (a) {
|
|
971
|
+
if (O.r !== r || r.lastRead !== request) return;
|
|
972
|
+
if (a.status === 404) { O.lastState = 'none'; O.lastWhy = 'This session is not on your Mac any more.'; paintLast(); return; }
|
|
973
|
+
if (a.status !== 200) { O.lastState = 'error'; paintLast(); return; }
|
|
974
|
+
var d = a.body || {};
|
|
975
|
+
if (!O.row) { O.row = d; paintMeta(); if (O.open && O.open.kind === 'run') setHead(K.title(d)); }
|
|
976
|
+
var turns = Array.isArray(d.recent_turns) ? d.recent_turns : [];
|
|
977
|
+
var lastA = turns.filter(function (t) { return lower(t.role) !== 'user' && (String(t.text || t.content || '').trim() || (K.imageHtml && K.imageHtml(t))); }).pop();
|
|
978
|
+
O.last = lastA ? { text: String(lastA.text || lastA.content || '').trim(), at: lastA.at, outputImages: lastA.outputImages } : null;
|
|
979
|
+
O.lastState = O.last ? 'ready' : 'none';
|
|
980
|
+
O.lastWhy = '';
|
|
981
|
+
paintLast();
|
|
982
|
+
}, function () { if (O.r === r && r.lastRead === request) { O.lastState = 'error'; paintLast(); } });
|
|
983
|
+
}
|
|
984
|
+
function paintLast() {
|
|
985
|
+
var box = $('ta-last'); if (!box) return;
|
|
986
|
+
// Retain the actual reply nodes (and its open image) during refreshes and transient errors.
|
|
987
|
+
// Only a successful changed reply replaces them. Metadata changes count as changed too.
|
|
988
|
+
var signature = O.last ? JSON.stringify([O.last.text, O.last.at, K.imageHtml ? K.imageHtml(O.last) : '']) : '';
|
|
989
|
+
if (signature && box.__taLastSignature === signature) {
|
|
990
|
+
var refresh = $('ta-last-refresh');
|
|
991
|
+
if (refresh) refresh.textContent = O.lastState === 'loading' ? 'Checking for a newer reply…'
|
|
992
|
+
: O.lastState === 'error' ? 'The latest reply could not load. Showing the previous reply.'
|
|
993
|
+
: O.lastState === 'none' ? (O.lastWhy || '') : '';
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
box.__taLastSignature = signature;
|
|
997
|
+
if (K.imagesStop) K.imagesStop(box);
|
|
998
|
+
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; }
|
|
999
|
+
if (O.lastState === 'error') { box.innerHTML = '<p class="ta-sub">The last reply did not load. Open the session to read it.</p>'; return; }
|
|
1000
|
+
if (O.lastState === 'none') { box.innerHTML = O.lastWhy ? '<p class="ta-sub">' + esc(O.lastWhy) + '</p>' : ''; return; }
|
|
1001
|
+
if (!O.last) { box.innerHTML = ''; return; }
|
|
1002
|
+
var at = W.toDate(O.last.at), long = O.last.text.length > 700;
|
|
1003
|
+
box.innerHTML = '<section class="ta-last" aria-label="Last reply"><p class="ta-last-head">' + esc('Last reply' + (at ? ', ' + W.clock(at) : '')) + '</p>' +
|
|
1004
|
+
'<div class="sx-md ta-last-text' + (long ? ' clamped' : '') + '" id="ta-last-text">' + K.markdown(O.last.text.slice(0, 12000)) + '</div>' +
|
|
1005
|
+
(long ? '<button type="button" class="link-btn" data-ta="more">Show all of it</button>' : '') + (K.imageHtml ? K.imageHtml(O.last) : '') + '<p id="ta-last-refresh" class="ta-sub" role="status"></p></section>';
|
|
1006
|
+
if (K.imagesMount) K.imagesMount(box, O.r);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// Running: the same live stream the reader follows, read with its own reducer (applyFeed, sxFeedWord and friends).
|
|
1010
|
+
function watchStream(r) {
|
|
1011
|
+
if (typeof EventSource !== 'function') { r.feed.fallback = 'unsupported'; paintLive(); return; }
|
|
1012
|
+
var es;
|
|
1013
|
+
try { es = new EventSource(K.path(r.provider, r.id, '/stream?seed=turn')); } catch (_e) { r.feed.fallback = 'unsupported'; paintLive(); return; }
|
|
1014
|
+
O.stream = es;
|
|
1015
|
+
es.onmessage = function (ev) {
|
|
1016
|
+
var e; try { e = JSON.parse(ev.data); } catch (_e) { return; }
|
|
1017
|
+
if (O.r !== r) return;
|
|
1018
|
+
K.feed.apply(r, e);
|
|
1019
|
+
r.live = r.live || {};
|
|
1020
|
+
if (e.kind === 'status') {
|
|
1021
|
+
// 6.99.29: the card's one state (app.js sxTruth) decides working and waiting: the agent's own
|
|
1022
|
+
// waiting still wins over a busy file (W4-r2), and the stream's echo of a Stop still cannot
|
|
1023
|
+
// reopen it, but a stale idle no longer outranks fresher working evidence.
|
|
1024
|
+
var wasWorking = !!r.live.working, wasWaiting = !!r.live.waiting;
|
|
1025
|
+
K.feed.settle(r);
|
|
1026
|
+
if (wasWorking && !r.live.working) loadLast(r);
|
|
1027
|
+
// Gone to waiting: its question may be parked for this page now, so the card reads at once (6.99.27).
|
|
1028
|
+
if (r.live.waiting && !wasWaiting && W.sessionQuestions && W.sessionQuestions.nudge) W.sessionQuestions.nudge();
|
|
1029
|
+
} else if (e.kind === 'prose' && e.text) r.live.prose = String(e.text);
|
|
1030
|
+
paintLive();
|
|
1031
|
+
paintVerdict();
|
|
1032
|
+
};
|
|
1033
|
+
es.onerror = function () {
|
|
1034
|
+
if (O.r !== r || O.stream !== es) return;
|
|
1035
|
+
if (es.readyState === 2) {
|
|
1036
|
+
if (r.feed.lastSeq > 0) { r.feed.connected = false; r.feed.fallback = 'stream_lost'; paintLive(); }
|
|
1037
|
+
else probeStream(r);
|
|
1038
|
+
} else { r.feed.fallback = 'reconnecting'; paintLive(); }
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
// EventSource hides why a stream was refused: one GET reads the status for the fallback line (the reader's sxProbeStream).
|
|
1042
|
+
function probeStream(r) {
|
|
1043
|
+
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1044
|
+
fetch(K.path(r.provider, r.id, '/stream?seed=turn'), { headers: { 'X-COS-Web': '1' }, credentials: 'same-origin', signal: ctrl ? ctrl.signal : undefined })
|
|
1045
|
+
.then(function (res) { var code = res.status; if (ctrl) ctrl.abort(); return code; }, function () { return 0; })
|
|
1046
|
+
.then(function (code) {
|
|
1047
|
+
if (O.r !== r || r.feed.lastSeq > 0) return;
|
|
1048
|
+
r.feed.fallback = code === 200 ? 'stream_lost' : code ? 'http_' + code : 'unreachable';
|
|
1049
|
+
paintLive();
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
function paintLive() {
|
|
1053
|
+
var box = $('ta-live'); if (!box || !O.r || !O.open || O.open.kind !== 'run') return;
|
|
1054
|
+
var r = O.r, f = r.feed, live = r.live || {};
|
|
1055
|
+
var on = f.connected || f.lastSeq > 0 || live.working;
|
|
1056
|
+
var html;
|
|
1057
|
+
if (on) {
|
|
1058
|
+
// Idle: the reader's line repeats the last reply, which the peek shows in full just below.
|
|
1059
|
+
var word = K.feed.word(r), detail = word === 'Idle' ? '' : K.feed.detail(f, word);
|
|
1060
|
+
html = '<div class="sx-act-state"><span class="sx-dot ' + word.toLowerCase() + '" aria-hidden="true"></span><strong>' + esc(word) + '</strong>' +
|
|
1061
|
+
'<span class="sx-act-detail">' + esc(detail) + '</span><span class="sx-act-clock" id="ta-clock">' + esc(K.feed.elapsed(f, r)) + '</span>' +
|
|
1062
|
+
(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' : '') + '>' +
|
|
1063
|
+
(O.stopping ? 'Stopping' : O.armedAt.stop != null ? 'Tap again to stop' : 'Stop') + '</button>' : '') + '</div>' +
|
|
1064
|
+
(f.prompt ? '<p class="ta-act-prompt">' + esc(K.feed.promptLine(r, clip(f.prompt, 240))) + '</p>' : '') +
|
|
1065
|
+
'<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('') +
|
|
1066
|
+
(live.working && live.prose ? '<div class="sx-act-prose">' + esc(String(live.prose).slice(-500)) + '</div>' : '') + '</div>';
|
|
1067
|
+
// The feed stopped or was refused: say why under the last state it showed.
|
|
1068
|
+
if (f.fallback && !f.connected) html += '<p class="ta-sub ta-feed-note">' + esc(K.feed.fallback(f.fallback)) + '</p>';
|
|
1069
|
+
} else html = '<p class="ta-sub">' + esc(f.fallback ? K.feed.fallback(f.fallback) : 'Connecting to the live feed\u2026') + '</p>';
|
|
1070
|
+
box.innerHTML = '<div class="sx-activity ta-activity" aria-live="polite">' + html + '</div>';
|
|
1071
|
+
var rs = $('ta-run-state'); if (rs) rs.textContent = runWords();
|
|
1072
|
+
clearInterval(O.clock); O.clock = null;
|
|
1073
|
+
var w = on ? K.feed.word(r) : '';
|
|
1074
|
+
if (w === 'Working' || w === 'Waiting') {
|
|
1075
|
+
O.clock = setInterval(function () {
|
|
1076
|
+
if (O.r !== r) { clearInterval(O.clock); return; }
|
|
1077
|
+
var c = $('ta-clock'); if (c) c.textContent = K.feed.elapsed(r.feed, r);
|
|
1078
|
+
if (K.feed.gateStale(r)) K.probe(r);
|
|
1079
|
+
var rs = $('ta-run-state'); if (rs) rs.textContent = runWords();
|
|
1080
|
+
}, 1000);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// ── sending: the reader's attach, turn and queue path ──
|
|
1085
|
+
// continueSession and parkTurn run as they do in the reader and report through this view (app.js READER_VIEW's
|
|
1086
|
+
// methods). The gates in sendReply, watchTurn and paintVerdict below are this file's copies of the reader's (see the
|
|
1087
|
+
// header). The overlay never holds a message for later: when COS's own reply is still running it says so, and the
|
|
1088
|
+
// words stay in the box, under the same key, so Send again cannot post twice.
|
|
1089
|
+
var VIEW = {
|
|
1090
|
+
status: function (r, text, kind, actionHtml) { if (r !== O.r) return; O.note = { text: text || '', kind: kind || '' }; paintNote(); },
|
|
1091
|
+
pending: function (r, text, note) {
|
|
1092
|
+
if (r !== O.r) return;
|
|
1093
|
+
O.dirty = true;
|
|
1094
|
+
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: '' };
|
|
1095
|
+
paintNote();
|
|
1096
|
+
},
|
|
1097
|
+
clear: function (r, text) {
|
|
1098
|
+
// The Mac took it: the send is settled, and the kept draft goes with the words that were sent. So do the files
|
|
1099
|
+
// that went with them; any added since stay for the next message.
|
|
1100
|
+
var sent = r.draft && r.draft.text === text ? r.draft.ids || [] : [];
|
|
1101
|
+
if (r.draft && r.draft.text === text) r.draft = null;
|
|
1102
|
+
var fb = sent.length ? filesBox(r) : null, kit = KIT();
|
|
1103
|
+
if (fb && kit) fb.atts.filter(function (t) { return sent.indexOf(t.mediaId) >= 0; }).forEach(function (t) { kit.remove(fb, t.id); });
|
|
1104
|
+
var box = r === O.r ? $('ta-input') : null;
|
|
1105
|
+
var left = box ? (box.value.trim() === String(text).trim() ? '' : box.value) : '';
|
|
1106
|
+
if (box && !left) { box.value = ''; W.autoGrow(box); }
|
|
1107
|
+
keepDraft(r, left, null);
|
|
1108
|
+
},
|
|
1109
|
+
verdict: function (r) { if (r === O.r) paintVerdict(); },
|
|
1110
|
+
// 6.99.29: the card's one state moved (a gate answer, a parked send, a live delivery): repaint all of it.
|
|
1111
|
+
stateChanged: function (r) {
|
|
1112
|
+
if (r !== O.r) return;
|
|
1113
|
+
var wasWorking = !!r.live && !!r.live.working;
|
|
1114
|
+
K.feed.settle(r);
|
|
1115
|
+
if (wasWorking && !r.live.working) loadLast(r);
|
|
1116
|
+
paintLive(); paintVerdict();
|
|
1117
|
+
},
|
|
1118
|
+
hold: function (r) {
|
|
1119
|
+
if (r !== O.r) return;
|
|
1120
|
+
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' };
|
|
1121
|
+
paintNote();
|
|
1122
|
+
},
|
|
1123
|
+
watch: function (r, bindingId, key) { watchTurn(r, bindingId, key); },
|
|
1124
|
+
queue: function () {}
|
|
1125
|
+
};
|
|
1126
|
+
function paintNote() { W.setStatus($('ta-status'), O.note.text, O.note.kind === 'error'); if (KB.on) fit(); }
|
|
1127
|
+
// A copy of the reader's renderVerdict (app.js): the same boxes and words, with Open session where the reader offers
|
|
1128
|
+
// Fork and Refresh, which live only in the reader.
|
|
1129
|
+
function paintVerdict() {
|
|
1130
|
+
var box = $('ta-verdict'), r = O.r; if (!box || !r) return;
|
|
1131
|
+
var v = r.verdict, html = '';
|
|
1132
|
+
if (O.cautionAsk) {
|
|
1133
|
+
html = '<div class="sx-box confirm" role="alertdialog" aria-labelledby="ta-caution-title"><strong id="ta-caution-title">Send into an open session?</strong>' +
|
|
1134
|
+
'<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>' +
|
|
1135
|
+
'<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>';
|
|
1136
|
+
} else if (r.refusal) {
|
|
1137
|
+
var f = r.refusal;
|
|
1138
|
+
html = '<div class="sx-box refusal" role="status"><p>' + esc(f.copy) + '</p>' + (f.supplement ? '<p class="sx-box-sub">' + esc(f.supplement) + '</p>' : '') +
|
|
1139
|
+
'<div class="sx-box-actions">' +
|
|
1140
|
+
(f.changed ? '<button type="button" class="cx-btn quiet" data-ta="send-anyway">Continue anyway</button>' : '') +
|
|
1141
|
+
(f.retry ? '<button type="button" class="cx-btn quiet" data-ta="retry">Try again</button>' : '') +
|
|
1142
|
+
((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>' : '') +
|
|
1143
|
+
'</div></div>';
|
|
1144
|
+
} else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
|
|
1145
|
+
// 6.99.29: "Working right now" only while the card's one state says so.
|
|
1146
|
+
if (K.feed.parkNoteShows(r)) html = '<p class="sx-park">' + esc(K.parkCopy(r.provider)) + '</p>';
|
|
1147
|
+
} else if (v && v.attachable === false && v.reasonCopy) {
|
|
1148
|
+
html = '<div class="sx-box refusal" role="status"><p>' + esc(v.reasonCopy) + '</p></div>';
|
|
1149
|
+
} else if (v && v.caution) {
|
|
1150
|
+
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.'
|
|
1151
|
+
: 'Another app on this Mac has this session open. COS asks before the first send.') + '</p>';
|
|
1152
|
+
}
|
|
1153
|
+
box.innerHTML = html;
|
|
1154
|
+
if (KB.on) fit(); // the reply box's height changed: the conversation's room is measured again
|
|
1155
|
+
}
|
|
1156
|
+
function setSending(on) {
|
|
1157
|
+
var input = $('ta-input');
|
|
1158
|
+
if (input) input.readOnly = on;
|
|
1159
|
+
paintSendButton(); paintClip();
|
|
1160
|
+
}
|
|
1161
|
+
function sendReply(opts) {
|
|
1162
|
+
var r = O.r; if (!r) return;
|
|
1163
|
+
var input = $('ta-input'), text = input ? input.value.trim() : '';
|
|
1164
|
+
if (!text) { O.note = { text: 'Write a message first.', kind: 'error' }; paintNote(); return; }
|
|
1165
|
+
// Files: never before every upload has finished, and never with one that failed.
|
|
1166
|
+
var box = filesOn() ? filesBox(r) : null, st = filesState(box);
|
|
1167
|
+
if (st === 'waiting') { O.note = { text: 'A file is still uploading. Send when it finishes.', kind: '' }; paintNote(); return; }
|
|
1168
|
+
if (st === 'failed') { O.note = { text: 'A file did not upload. Retry it or remove it.', kind: 'error' }; paintNote(); return; }
|
|
1169
|
+
var ids = st === 'ready' ? KIT().ids(box) : [];
|
|
1170
|
+
// A send with files cut off by a reload: the chips are gone, the draft kept their ids. The same words go again with
|
|
1171
|
+
// the same files under the same key, so a send that did land is answered, never repeated.
|
|
1172
|
+
if (!ids.length && r.draft && r.draft.restored && r.draft.text === text && r.draft.ids && r.draft.ids.length) ids = r.draft.ids.slice();
|
|
1173
|
+
if (ids.length) opts = Object.assign({}, opts || {}, { attachmentIds: ids });
|
|
1174
|
+
// Control's caution: another app holds this session and looks idle, so the first send asks.
|
|
1175
|
+
if (r.verdict && r.verdict.caution && !r.cautionAck) { O.cautionAsk = true; O.armedAt = { caution: Date.now() }; paintVerdict(); return; }
|
|
1176
|
+
if (r.sending) return;
|
|
1177
|
+
if (r.refusal || O.cautionAsk) { r.refusal = null; O.cautionAsk = false; paintVerdict(); }
|
|
1178
|
+
r.sending = true; setSending(true);
|
|
1179
|
+
var done = function () { r.sending = false; if (O.r === r) setSending(false); };
|
|
1180
|
+
var sending = K.send(r, text, opts || {});
|
|
1181
|
+
// continueSession picks the key at once (draftFor): keep it until the Mac takes the words.
|
|
1182
|
+
if (r.draft) keepDraft(r, text, r.draft);
|
|
1183
|
+
return sending.then(function () { done(); }, function (err) {
|
|
1184
|
+
done();
|
|
1185
|
+
if (O.r !== r) return;
|
|
1186
|
+
var why = err && err.name === 'AbortError'
|
|
1187
|
+
? 'No answer from your Mac yet. It may still land. Sending again is safe; it will not post twice.'
|
|
1188
|
+
: (err && err.message) || 'Could not reach your Mac.';
|
|
1189
|
+
O.note = { text: '', kind: '' }; paintNote();
|
|
1190
|
+
r.refusal = { copy: why, retry: true };
|
|
1191
|
+
paintVerdict();
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
// Stop, as the reader's Stop sends it (app.js stopSession): POST .../cancel with one clientCancelId per attempt, so a
|
|
1195
|
+
// retry after a lost answer replays instead of stopping twice. The server's own words say what happened.
|
|
1196
|
+
function stopRun() {
|
|
1197
|
+
var r = O.r; if (!r || O.stopping) return;
|
|
1198
|
+
O.stopping = true; O.armedAt = {};
|
|
1199
|
+
if (!O.stopKey) O.stopKey = K.newKey();
|
|
1200
|
+
O.note = { text: 'Stopping', kind: '' }; paintNote(); paintLive();
|
|
1201
|
+
return K.answer(K.path(r.provider, r.id, '/cancel'), { method: 'POST', timeoutMs: 15000, body: { clientCancelId: O.stopKey } }).then(function (c) {
|
|
1202
|
+
O.stopping = false;
|
|
1203
|
+
if (O.r !== r) return;
|
|
1204
|
+
var b = c.body || {};
|
|
1205
|
+
O.stopKey = '';
|
|
1206
|
+
O.note = { text: b.reasonCopy || (b.cancelled ? 'Stopped.' : 'COS could not stop it.'), kind: b.cancelled ? '' : 'error' };
|
|
1207
|
+
if (b.cancelled) { r.stoppedAt = Date.now(); K.feed.settle(r); O.dirty = true; paintLive(); paintVerdict(); setTimeout(function () { if (O.r === r) loadLast(r); }, 1500); }
|
|
1208
|
+
paintNote(); paintLive();
|
|
1209
|
+
}, function (err) {
|
|
1210
|
+
O.stopping = false;
|
|
1211
|
+
if (O.r !== r) return;
|
|
1212
|
+
O.note = { text: 'No answer from your Mac' + (err && err.message ? ': ' + err.message : '') + '. Stopping again is safe; it will not stop twice.', kind: 'error' };
|
|
1213
|
+
paintNote(); paintLive();
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
// A copy of the reader's watchTurn (app.js): the same route, outcomes and three minutes, for as long as the overlay
|
|
1217
|
+
// shows this session. The reader's own watch is tied to its page, so it cannot be reused here.
|
|
1218
|
+
function watchTurn(r, bindingId, key) {
|
|
1219
|
+
clearInterval(O.watch);
|
|
1220
|
+
var started = Date.now();
|
|
1221
|
+
O.watch = setInterval(function () {
|
|
1222
|
+
if (O.r !== r || Date.now() - started > WATCH_MS) { clearInterval(O.watch); return; }
|
|
1223
|
+
K.answer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
|
|
1224
|
+
if (O.r !== r) return;
|
|
1225
|
+
var o = s.body && s.body.outcome;
|
|
1226
|
+
if (o === 'completed') {
|
|
1227
|
+
clearInterval(O.watch);
|
|
1228
|
+
// 6.99.29: a turn delivered LIVE is in the open session, which now works on it; it is not done.
|
|
1229
|
+
if (s.body.via === 'live') { K.feed.noteGate(r, true); O.note = { text: 'Sent into the open session. It is working on it now.', kind: '' }; }
|
|
1230
|
+
else { O.note = { text: 'Done. The reply is below.', kind: '' }; loadLast(r); }
|
|
1231
|
+
paintNote(); VIEW.stateChanged(r);
|
|
1232
|
+
}
|
|
1233
|
+
else if (o === 'refused') { clearInterval(O.watch); O.note = { text: s.body.reasonCopy || 'The session refused that message.', kind: 'error' }; paintNote(); }
|
|
1234
|
+
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(); }
|
|
1235
|
+
}).catch(function () {});
|
|
1236
|
+
}, 3000);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// ═════════════ what COS is told about Today ═════════════
|
|
1240
|
+
// A short plain summary of what the Today cards show, for an Ask made from Today (app.js askCos). Titles and words
|
|
1241
|
+
// only: no ids, no tokens. At most SUMMARY_MAX characters, the left-out marker included.
|
|
1242
|
+
function end(s) { s = String(s || '').trim(); return /[.!?\u2026]$/.test(s) ? s : s + '.'; }
|
|
1243
|
+
function summary() {
|
|
1244
|
+
var d = T.data();
|
|
1245
|
+
if (!d.at) return '';
|
|
1246
|
+
var lines = [], now = new Date();
|
|
1247
|
+
lines.push('Today, ' + now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ', ' + W.clock(now) + '.');
|
|
1248
|
+
// Needs you as Today lists it (app.js needsModel: waiting sessions, Work handoffs, voices, memories), and what it
|
|
1249
|
+
// could not read. Before that model exists, the Work handoffs alone.
|
|
1250
|
+
var m = T.needs ? T.needs() : null;
|
|
1251
|
+
var rows = m ? m.rows : (d.needs || []).map(function (it) { return { kind: 'work', item: it }; });
|
|
1252
|
+
var later = m ? m.later.length : 0;
|
|
1253
|
+
lines.push(rows.length ? 'Needs you (' + rows.length + (later ? ', ' + later + ' of them can wait until tomorrow' : '') + '):'
|
|
1254
|
+
: m && !m.settled ? 'Needs you: nothing found so far.' : 'Needs you: nothing.');
|
|
1255
|
+
rows.slice(0, 6).forEach(function (r) {
|
|
1256
|
+
// 6.99.26: where a waiting session can be answered, as the row says it.
|
|
1257
|
+
var where = r.where === 'here' ? ' It can be answered from Today.' : r.where === 'mac' ? ' It waits on the Mac\u2019s own prompt.' : r.where === 'answered' ? ' It was just answered.' : '';
|
|
1258
|
+
if (r.kind !== 'work') { lines.push('- ' + end(clip(r.title, 140)) + (r.why ? ' ' + end(clip(r.why, 160)) : '') + where); return; }
|
|
1259
|
+
var it = r.item;
|
|
1260
|
+
var title = str(it.sessionTitle) || str(it.title) || 'Handoff';
|
|
1261
|
+
var bits = [end(clip(title, 120)) + ' ' + end(needLine(it))];
|
|
1262
|
+
if (str(it.title) && str(it.title) !== str(it.sessionTitle)) bits.push(end('Work card: ' + clip(it.title, 100)));
|
|
1263
|
+
var p = it.progress && !it.progress.unreadable ? it.progress : null;
|
|
1264
|
+
if (p && p.evidence) bits.push('The session said: "' + clip(p.evidence, 200) + '"');
|
|
1265
|
+
lines.push('- ' + bits.join(' '));
|
|
1266
|
+
});
|
|
1267
|
+
if (rows.length > 6) lines.push('- and ' + (rows.length - 6) + ' more.');
|
|
1268
|
+
if (m && m.failed.length) lines.push('Today could not read ' + m.failed.join(', ') + ', so this may not be everything.');
|
|
1269
|
+
var working = d.working || [], open = d.open || [];
|
|
1270
|
+
lines.push('Running: ' + working.length + ' working' + (open.length ? ', ' + open.length + ' more open and quiet' : '') + (working.length ? ':' : '.'));
|
|
1271
|
+
working.slice(0, 6).forEach(function (s) {
|
|
1272
|
+
var k = T.splitSessionId(s.provider, s.session_id);
|
|
1273
|
+
lines.push('- ' + K.providerLabel(k.provider) + ': ' + clip(T.sessionTitle(s), 120) + ', working for ' + T.span(s.state_since || s.last_activity_at) + '.');
|
|
1274
|
+
});
|
|
1275
|
+
var items = d.intake || [];
|
|
1276
|
+
if (items.length) {
|
|
1277
|
+
lines.push('From today\u2019s meetings: ' + items.length + (items.length === 1 ? ' suggestion' : ' suggestions') + ' waiting for a decision (they move to Work at midnight):');
|
|
1278
|
+
var groups = [], byTitle = {};
|
|
1279
|
+
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); });
|
|
1280
|
+
groups.slice(0, 8).forEach(function (t) {
|
|
1281
|
+
var list = byTitle[t];
|
|
1282
|
+
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' : '') + '.');
|
|
1283
|
+
});
|
|
1284
|
+
if (groups.length > 8) lines.push('- and ' + (groups.length - 8) + ' more meetings.');
|
|
1285
|
+
}
|
|
1286
|
+
var n = d.welcome && d.welcome.nextEvent, nd = n && W.toDate(n.time);
|
|
1287
|
+
if (nd && n.title) lines.push('Next up: ' + clip(n.title, 120) + ' at ' + W.clock(nd) + ' ' + W.dayWord(nd) + '.');
|
|
1288
|
+
var out = '', i;
|
|
1289
|
+
for (i = 0; i < lines.length; i++) {
|
|
1290
|
+
if (out.length + lines[i].length + 1 > SUMMARY_MAX - SUMMARY_MORE.length) { out += SUMMARY_MORE; break; }
|
|
1291
|
+
out += lines[i] + '\n';
|
|
1292
|
+
}
|
|
1293
|
+
return out.trim();
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
W.registerPane('todayActions', {
|
|
1297
|
+
load: function () {},
|
|
1298
|
+
open: open, close: close, restore: restore, summary: summary, summaryMax: SUMMARY_MAX,
|
|
1299
|
+
isOpen: function () { return !!O.open; },
|
|
1300
|
+
_state: O, _decide: decide, _undo: undo, _intakeHtml: intakeHtml, _send: sendReply, _view: VIEW, _paintVerdict: paintVerdict,
|
|
1301
|
+
_paintLive: paintLive, _paintMeta: paintMeta, _onClick: onClick, _expire: expireLater, _kb: KB, _fitNow: applyFit
|
|
1302
|
+
});
|
|
1303
|
+
})();
|