@gotcos/glasses-server 6.99.25 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/package.json +1 -1
  3. package/server/index.ts +46 -4
  4. package/server/lib/agent-session-output-images.ts +280 -0
  5. package/server/lib/agent-session-store.ts +3 -1
  6. package/server/lib/agent-session-turns.ts +18 -7
  7. package/server/lib/client-liveness.ts +38 -3
  8. package/server/lib/codex-subagent-activity.ts +36 -0
  9. package/server/lib/g2-turn-provenance.ts +679 -0
  10. package/server/lib/media-store.ts +41 -2
  11. package/server/lib/permission-broker.ts +94 -17
  12. package/server/lib/queue-turn-evidence.ts +19 -2
  13. package/server/lib/session-halt.ts +15 -1
  14. package/server/lib/session-stream-events.ts +4 -0
  15. package/server/lib/session-subagents.ts +155 -0
  16. package/server/lib/session-turn-attachments.ts +245 -0
  17. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  18. package/server/lib/thread-turn-queue.ts +10 -0
  19. package/server/lib/today-brief.ts +731 -0
  20. package/server/lib/turn-file-store.ts +202 -0
  21. package/server/lib/turn-footer.ts +81 -0
  22. package/server/routes/agent-session-bindings.ts +164 -16
  23. package/server/routes/agent-sessions.ts +77 -8
  24. package/server/routes/client-instance.ts +12 -1
  25. package/server/routes/cursor-stop-followup.ts +30 -1
  26. package/server/routes/g2-turns.ts +46 -0
  27. package/server/routes/health.ts +9 -0
  28. package/server/routes/media.ts +32 -8
  29. package/server/routes/permission-broker.ts +47 -4
  30. package/server/routes/thread-turn-queue.ts +148 -8
  31. package/server/routes/today-brief.ts +148 -0
  32. package/server/web-control/app.css +2 -0
  33. package/server/web-control/app.js +738 -102
  34. package/server/web-control/index.html +5 -1
  35. package/server/web-control/panes/cos-chat.css +18 -0
  36. package/server/web-control/panes/cos-chat.js +239 -45
  37. package/server/web-control/panes/meetings.js +20 -9
  38. package/server/web-control/panes/questions.css +39 -1
  39. package/server/web-control/panes/questions.js +368 -51
  40. package/server/web-control/panes/reader-media.js +154 -0
  41. package/server/web-control/panes/sessions.css +2 -0
  42. package/server/web-control/panes/settings.js +34 -2
  43. package/server/web-control/panes/today-actions.css +62 -4
  44. package/server/web-control/panes/today-actions.js +384 -53
@@ -8,6 +8,12 @@
8
8
  status line and its last reply. Open session stays a link to the full reader.
9
9
  - Running: a peek at what the session is doing now, read with the reader's own activity reducer (applyFeed) from the
10
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.
11
17
  What a reply or a note shares with the Sessions reader, and what is this file's own copy:
12
18
  - Shared (app.js, through COSWeb.sessionKit): the attach, turn and queue path itself (continueSession and parkTurn,
13
19
  run with this overlay as their view), the per-browser binding store, one key per draft, the attachability probe
@@ -22,6 +28,12 @@
22
28
  Close with the X, a tap outside, Escape, or Back (the overlay holds one history entry of its own). Focus moves in and
23
29
  comes back to the row. The page behind is inert and does not scroll.
24
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
+
25
37
  Also the summary COS gets when Miles asks from Today (summary(): app.js askCos passes it as the Ask's reference).
26
38
  Loaded on the first tap or once the page is idle (app.js ensurePane, #lazy-panes). */
27
39
  (function () {
@@ -61,9 +73,11 @@
61
73
  var CREATE_LABEL = 'Create task', DISMISS_LABEL = 'Dismiss';
62
74
 
63
75
  var O = {
64
- open: null, // { kind: 'need' | 'run' | 'meeting' | 'intake', key, all }
76
+ open: null, // { kind: 'need' | 'run' | 'wait' | 'pet' | 'meeting' | 'intake', key, all }
65
77
  opener: null, openerSel: '',
66
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
67
81
  dirty: false, // something was decided or sent: Today reloads on close
68
82
  // From today's meetings
69
83
  busy: '', // the one suggestion being decided (Control: one at a time)
@@ -75,7 +89,7 @@
75
89
  r: null, // the session object the reader's send path takes (sessionKit.session)
76
90
  row: null, // the session's row in the session list
77
91
  item: null, // the Needs you item
78
- stream: null, clock: null, watch: null, qMount: null, cautionAsk: false, note: { text: '', kind: '' },
92
+ stream: null, clock: null, watch: null, qMount: null, qCount: 0, macCount: 0, cautionAsk: false, note: { text: '', kind: '' },
79
93
  last: null, // { text, at } the session's last reply
80
94
  lastState: 'idle', // idle | loading | ready | error | none
81
95
  needGone: false
@@ -95,6 +109,8 @@
95
109
  var all = W.readStore(DRAFTS), k = draftKey(r), now = Date.now(), prev = all[k] || {};
96
110
  Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) >= DRAFT_DAYS * 86400000) delete all[x]; });
97
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();
98
114
  var t = String(text || '');
99
115
  if (!t.trim() && !u) delete all[k]; else all[k] = { text: t, unsettled: u, at: now };
100
116
  W.writeStore(DRAFTS, all);
@@ -115,7 +131,8 @@
115
131
  layer.innerHTML = '<div class="ta-scrim" data-ta="close" aria-hidden="true"></div>' +
116
132
  '<section class="ta-sheet" id="ta-sheet" role="dialog" aria-modal="true" aria-labelledby="ta-title" tabindex="-1">' +
117
133
  '<div class="ta-grip" aria-hidden="true"></div>' +
118
- '<header class="ta-head"><h2 class="ta-title" id="ta-title"></h2>' +
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>' +
119
136
  '<button type="button" class="ta-x" data-ta="close" aria-label="Close">' + X_ICON + '</button></header>' +
120
137
  '<div class="ta-body" id="ta-body"></div>' +
121
138
  '<div class="ta-foot" id="ta-foot"></div>' +
@@ -130,18 +147,86 @@
130
147
  layer.addEventListener('input', function (e) {
131
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);
132
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
+ });
133
167
  // A drag on the dimmed page never scrolls what is behind it.
134
- layer.addEventListener('touchmove', function (e) { if (e.target && e.target.classList && e.target.classList.contains('ta-scrim')) e.preventDefault(); }, { passive: false });
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 });
135
170
  return layer;
136
171
  }
137
- // The layer follows the visible viewport, so the on-screen keyboard never covers the reply box or Send.
138
- function fit() {
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;
139
198
  var vv = window.visualViewport;
140
199
  if (!layer || !vv) return;
141
200
  layer.style.setProperty('--ta-vh', Math.round(vv.height) + 'px');
142
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');
143
229
  }
144
- if (window.visualViewport) { window.visualViewport.addEventListener('resize', fit); window.visualViewport.addEventListener('scroll', fit); }
145
230
 
146
231
  function openerSelector(t) {
147
232
  if (!t || !t.getAttribute) return '';
@@ -151,9 +236,14 @@
151
236
  return '[data-today=' + q(kind) + ']' + (key != null ? '[data-key=' + q(key) + ']' : '');
152
237
  }
153
238
 
154
- function open(kind, key, opener) {
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 || {};
155
244
  build();
156
245
  if (O.open) stopSession();
246
+ O.back = opt.from || null;
157
247
  O.open = { kind: kind, key: String(key || ''), all: kind === 'intake' };
158
248
  O.opener = opener || document.activeElement; O.openerSel = openerSelector(opener);
159
249
  O.note = { text: '', kind: '' }; O.cautionAsk = false; O.needGone = false; O.armedAt = {};
@@ -161,14 +251,20 @@
161
251
  layer.hidden = false;
162
252
  document.documentElement.classList.add('ta-open');
163
253
  var shell = $('app-shell'); if (shell) shell.setAttribute('inert', '');
164
- fit();
254
+ listenViewport(true); applyFit();
165
255
  // The entry names what is open, so a reload can open it again and Back still closes it (restore below).
166
256
  var entry = { cosTodayOverlay: 1, kind: kind, key: O.open.key };
257
+ if (O.back) entry.from = 'pet';
167
258
  try {
168
- if (!O.pushed) { history.pushState(entry, ''); O.pushed = true; } else history.replaceState(entry, '');
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, '');
169
263
  } catch (_e) { /* no history API: Back simply leaves Today */ }
264
+ var backBtn = $('ta-back'); if (backBtn) backBtn.hidden = !O.back;
170
265
  try {
171
266
  if (kind === 'need' || kind === 'run' || kind === 'wait') openSession(kind, O.open.key);
267
+ else if (kind === 'pet') openPet();
172
268
  else openIntake();
173
269
  } catch (err) {
174
270
  // A bug here must not leave a half-drawn sheet over an inert page: take it down, keep the history entry for
@@ -212,9 +308,15 @@
212
308
  stopSession();
213
309
  O.open = null;
214
310
  if (layer) layer.hidden = true;
311
+ unfit();
215
312
  document.documentElement.classList.remove('ta-open');
216
313
  var shell = $('app-shell'); if (shell) shell.removeAttribute('inert');
217
- if (O.pushed && how !== 'pop' && how !== 'leave') { O.pushed = false; O.ignorePops++; try { history.back(); } catch (_e) { O.ignorePops--; } }
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
+ }
218
320
  if (how === 'pop' || how === 'leave') O.pushed = false;
219
321
  clearTimeout(O.expireTimer);
220
322
  // Settled lines are done once the overlay closes; Today reads the server again if anything changed here.
@@ -241,7 +343,7 @@
241
343
  // Back from there showed nothing (measured in Chrome 11/07: a same-document popstate). On load, app.js hands that
242
344
  // entry here: open the same thing again, so the first Back closes it as it did before the reload. What cannot be
243
345
  // opened (gone, or Today left meanwhile) just clears the entry's mark.
244
- var KINDS = { need: 1, run: 1, wait: 1, meeting: 1, intake: 1 };
346
+ var KINDS = { need: 1, run: 1, wait: 1, pet: 1, meeting: 1, intake: 1 };
245
347
  function restore(st) {
246
348
  if (O.open || !st || !st.cosTodayOverlay) return Promise.resolve(false);
247
349
  var forget = function () { try { history.replaceState(null, ''); } catch (_e) {} return false; };
@@ -249,23 +351,36 @@
249
351
  var started = Date.now();
250
352
  return new Promise(function (resolve) {
251
353
  (function wait() {
252
- var d = T.data(), ready = d.at > 0 && (st.kind === 'need' || st.kind === 'run' || st.kind === 'wait' || d.intakeLoaded);
354
+ var d = T.data(), ready = d.at > 0 && (st.kind === 'need' || st.kind === 'run' || st.kind === 'wait' || st.kind === 'pet' || d.intakeLoaded);
253
355
  if (W.parseRoute().pane !== 'today' || O.open) return resolve(forget());
254
356
  if (!ready) { if (Date.now() - started > 15000) return resolve(forget()); return setTimeout(wait, 150); }
255
357
  O.pushed = true; // this entry is the overlay's own
256
- try { open(st.kind, String(st.key || ''), null); resolve(true); }
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); }
257
361
  catch (err) { console.error('[today] could not reopen the overlay after a reload.', err); O.pushed = false; resolve(forget()); }
258
362
  })();
259
363
  });
260
364
  }
261
365
  window.addEventListener('popstate', function () {
262
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; }
263
369
  if (O.open) close('pop');
264
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
+ }
265
380
  W.onRoute(function (r) { if (O.open && r.pane !== 'today') close('leave'); });
266
381
  // Escape closes the overlay, unless the Ask sheet is open over it (app.js closes that first).
267
382
  document.addEventListener('keydown', function (e) {
268
- if (!O.open || e.key !== 'Escape') return;
383
+ if (!O.open || e.key !== 'Escape' || (W.readerMedia && W.readerMedia.isOpen())) return;
269
384
  var ask = $('ask-sheet');
270
385
  if (ask && !ask.classList.contains('hidden')) return;
271
386
  e.preventDefault();
@@ -295,6 +410,7 @@
295
410
  if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) paintIntake(); else paintLive();
296
411
  }
297
412
  if (what === 'close') return close('user');
413
+ if (what === 'back') return backToList(true);
298
414
  if (what === 'create' || what === 'link') return decide(id, 'accept');
299
415
  if (what === 'dismiss') {
300
416
  // Without Undo on this server a Dismiss cannot be taken back, so it asks once: tap, then tap again.
@@ -306,7 +422,20 @@
306
422
  if (what === 'open-work') return leaveTo('work', ['sort']);
307
423
  if (what === 'open-card') return leaveTo('work', id ? [id] : []);
308
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
+ }
309
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
+ }
310
439
  if (what === 'stop') {
311
440
  // A tap arms it (Stop ends the agent's turn on your Mac); the second sends it.
312
441
  if (!confirmTap('stop', paintLive)) return;
@@ -328,7 +457,8 @@
328
457
  // The keyboard rises over the bottom of the sheet: keep the box in view as it does.
329
458
  function onFocusIn(e) {
330
459
  if (!e.target || e.target.id !== 'ta-input') return;
331
- setTimeout(function () { fit(); var f = $('ta-form'); if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' }); }, 250);
460
+ fit();
461
+ setTimeout(function () { if (!O.open) return; applyFit(); var f = $('ta-form'); if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' }); }, 250);
332
462
  }
333
463
 
334
464
  // ═════════════ From today's meetings ═════════════
@@ -359,7 +489,8 @@
359
489
  probeLoop().then(function () { if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) paintIntake(); });
360
490
  paintIntake();
361
491
  var foot = $('ta-foot');
362
- if (foot) foot.innerHTML = '<div class="ta-links"><button type="button" class="link-btn" data-ta="open-work">Open Sort in Work</button></div>';
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; }
363
494
  // Read Intake again: what was decided on another screen since Today loaded shows as handled, never as a phantom.
364
495
  var asked = T.reloadIntake();
365
496
  if (asked && asked.then) asked.then(function () { if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) { noteVanished(); paintIntake(); } });
@@ -562,25 +693,42 @@
562
693
  O.row = id ? sessionRowFor(provider, id) : null;
563
694
  O.r = id ? K.session(provider, id, VIEW) : null;
564
695
  // Working only when the session is not waiting on you: a session paused on a question is not at work (W4-r2).
565
- if (O.r) O.r.live = { working: !!(O.row && O.row.running_active && K.stateOf(O.row) !== 'waiting') };
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); }
566
698
  // What was typed, and a send whose answer never came, survive a close, Back or reload: the same words resend under
567
699
  // the same key (skeptic W3, qa2-user N1).
568
700
  var kept = O.r ? draftOf(O.r) : null;
569
- if (kept && kept.unsettled) O.r.draft = { text: kept.unsettled.text, key: kept.unsettled.key };
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
+ }
570
705
  O.last = null; O.lastState = 'idle';
571
706
  setHead(kind === 'need' ? (O.item ? str(O.item.sessionTitle) || str(O.item.title) || 'Handoff' : 'Needs you')
572
707
  : kind === 'wait' ? (O.wait ? O.wait.title : 'A session is waiting for you') : (O.row ? K.title(O.row) : 'Session'));
573
708
  var body = $('ta-body');
574
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>';
575
710
  var foot = $('ta-foot');
576
- if (foot) foot.innerHTML = O.r ? composerHtml(kind) : '';
711
+ if (foot) { foot.innerHTML = O.r ? composerHtml(kind) : ''; foot.hidden = false; }
577
712
  var input = $('ta-input');
578
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
+ }
579
726
  paintMeta();
580
727
  if (!O.r) return;
581
728
  if (W.sessionQuestions && W.sessionQuestions.mount) {
582
- // mount() draws at once and calls back before it returns, so the count comes from the callback, not O.qMount.
583
- O.qMount = W.sessionQuestions.mount($('ta-q'), id, function (n) { O.qCount = n; paintMeta(); });
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(); });
584
732
  }
585
733
  if (kind === 'run') { paintLive(); watchStream(O.r); }
586
734
  loadLast(O.r);
@@ -588,11 +736,12 @@
588
736
  if (kind === 'need') recheckNeed(key);
589
737
  }
590
738
  function stopSession() {
739
+ if (K.imagesStop) K.imagesStop($('ta-last'));
591
740
  if (O.stream) { try { O.stream.close(); } catch (_e) {} O.stream = null; }
592
741
  clearInterval(O.clock); O.clock = null;
593
742
  clearInterval(O.watch); O.watch = null;
594
743
  if (O.qMount) { O.qMount.unmount(); O.qMount = null; }
595
- O.qCount = 0;
744
+ O.qCount = 0; O.macCount = 0;
596
745
  O.r = null; O.row = null; O.item = null;
597
746
  }
598
747
  // The Needs you list may be minutes old: read it again, and say so if this one is no longer waiting.
@@ -612,12 +761,73 @@
612
761
  var label = kind === 'run' ? 'Send a note to this session' : 'Reply to this session';
613
762
  return '<div id="ta-verdict"></div>' +
614
763
  '<form class="composer ta-composer" id="ta-form">' +
764
+ '<div class="cc-chips ta-files hidden" id="ta-files"></div>' +
615
765
  '<textarea id="ta-input" rows="1" placeholder="' + esc(label) + '" aria-label="' + esc(label) + '" enterkeyhint="send"></textarea>' +
616
- '<div class="composer-actions"><button type="submit" class="btn primary small" id="ta-send">Send</button></div>' +
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>' +
617
767
  '</form>' +
618
768
  '<p class="status-line" id="ta-status" role="status" aria-live="polite"></p>';
619
769
  }
620
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
+
621
831
  function needLine(it) {
622
832
  var why = T.needWhy(it) + (it.domain ? ' in ' + W.domainLabel(it.domain) : '');
623
833
  var age = W.ago(it.updatedAt || it.createdAt);
@@ -638,7 +848,7 @@
638
848
  function runWords() {
639
849
  var r = O.r, f = r && r.feed;
640
850
  if (f && (f.connected || f.lastSeq > 0)) {
641
- var w = K.feed.word(r), since = K.feed.elapsed(f);
851
+ var w = K.feed.word(r), since = K.feed.elapsed(f, r);
642
852
  if (w === 'Working') return 'Working now' + (since ? ', for ' + since : '');
643
853
  if (w === 'Waiting') return 'Waiting for you' + (since ? ', for ' + since : '');
644
854
  if (w === 'Failed') return 'Stopped with an error';
@@ -662,10 +872,15 @@
662
872
  var p = it.progress && !it.progress.unreadable ? it.progress : null;
663
873
  if (p && p.evidence) html += '<blockquote class="ta-quote">' + esc(p.evidence) + '</blockquote>';
664
874
  } else if (O.open.kind === 'wait') {
665
- // A session waiting on you: what it waits on, its question to answer here when the Mac holds one for this page,
666
- // else its last message and the reply box below. No live peek and no Stop: it is not at work (W4-r2).
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).
667
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>';
668
- if (!questions) html += '<p class="ta-sub">' + esc('Answer it in a reply below, or open the session.') + '</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>';
669
884
  } else if (O.row || O.r) {
670
885
  // Once the live feed speaks, the line says what it says, so the header and the peek never disagree (qa2-user N3).
671
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>';
@@ -678,27 +893,108 @@
678
893
  if (links) html += '<div class="ta-links">' + links + '</div>';
679
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>';
680
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;
681
964
  }
682
965
 
683
966
  // The session's last reply, from the same read the reader makes (?turns=N, lib/agent-session-turns.ts).
684
967
  function loadLast(r) {
685
- O.lastState = 'loading'; paintLast();
968
+ var request = r.lastRead = {};
969
+ O.lastState = 'loading'; O.lastWhy = ''; paintLast();
686
970
  return K.answer(K.path(r.provider, r.id, '?turns=6'), { timeoutMs: 20000 }).then(function (a) {
687
- if (O.r !== r) return;
971
+ if (O.r !== r || r.lastRead !== request) return;
688
972
  if (a.status === 404) { O.lastState = 'none'; O.lastWhy = 'This session is not on your Mac any more.'; paintLast(); return; }
689
973
  if (a.status !== 200) { O.lastState = 'error'; paintLast(); return; }
690
974
  var d = a.body || {};
691
975
  if (!O.row) { O.row = d; paintMeta(); if (O.open && O.open.kind === 'run') setHead(K.title(d)); }
692
976
  var turns = Array.isArray(d.recent_turns) ? d.recent_turns : [];
693
- var lastA = turns.filter(function (t) { return lower(t.role) !== 'user' && String(t.text || t.content || '').trim(); }).pop();
694
- O.last = lastA ? { text: String(lastA.text || lastA.content).trim(), at: lastA.at } : null;
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;
695
979
  O.lastState = O.last ? 'ready' : 'none';
696
980
  O.lastWhy = '';
697
981
  paintLast();
698
- }, function () { if (O.r === r) { O.lastState = 'error'; paintLast(); } });
982
+ }, function () { if (O.r === r && r.lastRead === request) { O.lastState = 'error'; paintLast(); } });
699
983
  }
700
984
  function paintLast() {
701
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);
702
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; }
703
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; }
704
1000
  if (O.lastState === 'none') { box.innerHTML = O.lastWhy ? '<p class="ta-sub">' + esc(O.lastWhy) + '</p>' : ''; return; }
@@ -706,7 +1002,8 @@
706
1002
  var at = W.toDate(O.last.at), long = O.last.text.length > 700;
707
1003
  box.innerHTML = '<section class="ta-last" aria-label="Last reply"><p class="ta-last-head">' + esc('Last reply' + (at ? ', ' + W.clock(at) : '')) + '</p>' +
708
1004
  '<div class="sx-md ta-last-text' + (long ? ' clamped' : '') + '" id="ta-last-text">' + K.markdown(O.last.text.slice(0, 12000)) + '</div>' +
709
- (long ? '<button type="button" class="link-btn" data-ta="more">Show all of it</button>' : '') + '</section>';
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);
710
1007
  }
711
1008
 
712
1009
  // Running: the same live stream the reader follows, read with its own reducer (applyFeed, sxFeedWord and friends).
@@ -721,15 +1018,17 @@
721
1018
  K.feed.apply(r, e);
722
1019
  r.live = r.live || {};
723
1020
  if (e.kind === 'status') {
724
- // The agent's own state wins: a process that is up while it waits on you is waiting, not working (W4-r2).
725
- var working = e.agent_state ? e.agent_state === 'running' : e.state === 'working';
726
- // The stream's status trails a Stop by a few seconds (the reader's rule): do not let that echo reopen it.
727
- if (working && r.stoppedAt && Date.now() - r.stoppedAt < 8000) working = false;
728
- if (r.live.working && !working) loadLast(r);
729
- r.live.working = working;
730
- if (!working) r.live.prose = null;
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();
731
1029
  } else if (e.kind === 'prose' && e.text) r.live.prose = String(e.text);
732
1030
  paintLive();
1031
+ paintVerdict();
733
1032
  };
734
1033
  es.onerror = function () {
735
1034
  if (O.r !== r || O.stream !== es) return;
@@ -759,10 +1058,10 @@
759
1058
  // Idle: the reader's line repeats the last reply, which the peek shows in full just below.
760
1059
  var word = K.feed.word(r), detail = word === 'Idle' ? '' : K.feed.detail(f, word);
761
1060
  html = '<div class="sx-act-state"><span class="sx-dot ' + word.toLowerCase() + '" aria-hidden="true"></span><strong>' + esc(word) + '</strong>' +
762
- '<span class="sx-act-detail">' + esc(detail) + '</span><span class="sx-act-clock" id="ta-clock">' + esc(K.feed.elapsed(f)) + '</span>' +
1061
+ '<span class="sx-act-detail">' + esc(detail) + '</span><span class="sx-act-clock" id="ta-clock">' + esc(K.feed.elapsed(f, r)) + '</span>' +
763
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' : '') + '>' +
764
1063
  (O.stopping ? 'Stopping' : O.armedAt.stop != null ? 'Tap again to stop' : 'Stop') + '</button>' : '') + '</div>' +
765
- (f.prompt ? '<p class="ta-act-prompt">' + esc('Working on: ' + clip(f.prompt, 240)) + '</p>' : '') +
1064
+ (f.prompt ? '<p class="ta-act-prompt">' + esc(K.feed.promptLine(r, clip(f.prompt, 240))) + '</p>' : '') +
766
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('') +
767
1066
  (live.working && live.prose ? '<div class="sx-act-prose">' + esc(String(live.prose).slice(-500)) + '</div>' : '') + '</div>';
768
1067
  // The feed stopped or was refused: say why under the last state it showed.
@@ -775,7 +1074,8 @@
775
1074
  if (w === 'Working' || w === 'Waiting') {
776
1075
  O.clock = setInterval(function () {
777
1076
  if (O.r !== r) { clearInterval(O.clock); return; }
778
- var c = $('ta-clock'); if (c) c.textContent = K.feed.elapsed(r.feed);
1077
+ var c = $('ta-clock'); if (c) c.textContent = K.feed.elapsed(r.feed, r);
1078
+ if (K.feed.gateStale(r)) K.probe(r);
779
1079
  var rs = $('ta-run-state'); if (rs) rs.textContent = runWords();
780
1080
  }, 1000);
781
1081
  }
@@ -795,14 +1095,26 @@
795
1095
  paintNote();
796
1096
  },
797
1097
  clear: function (r, text) {
798
- // The Mac took it: the send is settled, and the kept draft goes with the words that were sent.
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 || [] : [];
799
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); });
800
1104
  var box = r === O.r ? $('ta-input') : null;
801
1105
  var left = box ? (box.value.trim() === String(text).trim() ? '' : box.value) : '';
802
1106
  if (box && !left) { box.value = ''; W.autoGrow(box); }
803
1107
  keepDraft(r, left, null);
804
1108
  },
805
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
+ },
806
1118
  hold: function (r) {
807
1119
  if (r !== O.r) return;
808
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' };
@@ -811,7 +1123,7 @@
811
1123
  watch: function (r, bindingId, key) { watchTurn(r, bindingId, key); },
812
1124
  queue: function () {}
813
1125
  };
814
- function paintNote() { W.setStatus($('ta-status'), O.note.text, O.note.kind === 'error'); }
1126
+ function paintNote() { W.setStatus($('ta-status'), O.note.text, O.note.kind === 'error'); if (KB.on) fit(); }
815
1127
  // A copy of the reader's renderVerdict (app.js): the same boxes and words, with Open session where the reader offers
816
1128
  // Fork and Refresh, which live only in the reader.
817
1129
  function paintVerdict() {
@@ -830,7 +1142,8 @@
830
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>' : '') +
831
1143
  '</div></div>';
832
1144
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
833
- html = '<p class="sx-park">' + esc(K.parkCopy(r.provider)) + '</p>';
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>';
834
1147
  } else if (v && v.attachable === false && v.reasonCopy) {
835
1148
  html = '<div class="sx-box refusal" role="status"><p>' + esc(v.reasonCopy) + '</p></div>';
836
1149
  } else if (v && v.caution) {
@@ -838,16 +1151,26 @@
838
1151
  : 'Another app on this Mac has this session open. COS asks before the first send.') + '</p>';
839
1152
  }
840
1153
  box.innerHTML = html;
1154
+ if (KB.on) fit(); // the reply box's height changed: the conversation's room is measured again
841
1155
  }
842
1156
  function setSending(on) {
843
- var send = $('ta-send'), input = $('ta-input');
844
- if (send) { send.disabled = on; send.textContent = on ? 'Sending' : 'Send'; }
1157
+ var input = $('ta-input');
845
1158
  if (input) input.readOnly = on;
1159
+ paintSendButton(); paintClip();
846
1160
  }
847
1161
  function sendReply(opts) {
848
1162
  var r = O.r; if (!r) return;
849
1163
  var input = $('ta-input'), text = input ? input.value.trim() : '';
850
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 });
851
1174
  // Control's caution: another app holds this session and looks idle, so the first send asks.
852
1175
  if (r.verdict && r.verdict.caution && !r.cautionAck) { O.cautionAsk = true; O.armedAt = { caution: Date.now() }; paintVerdict(); return; }
853
1176
  if (r.sending) return;
@@ -881,7 +1204,7 @@
881
1204
  var b = c.body || {};
882
1205
  O.stopKey = '';
883
1206
  O.note = { text: b.reasonCopy || (b.cancelled ? 'Stopped.' : 'COS could not stop it.'), kind: b.cancelled ? '' : 'error' };
884
- if (b.cancelled) { r.stoppedAt = Date.now(); r.live = r.live || {}; r.live.working = false; r.live.prose = null; O.dirty = true; setTimeout(function () { if (O.r === r) loadLast(r); }, 1500); }
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); }
885
1208
  paintNote(); paintLive();
886
1209
  }, function (err) {
887
1210
  O.stopping = false;
@@ -900,7 +1223,13 @@
900
1223
  K.answer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
901
1224
  if (O.r !== r) return;
902
1225
  var o = s.body && s.body.outcome;
903
- if (o === 'completed') { clearInterval(O.watch); O.note = { text: 'Done. The reply is below.', kind: '' }; paintNote(); loadLast(r); }
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
+ }
904
1233
  else if (o === 'refused') { clearInterval(O.watch); O.note = { text: s.body.reasonCopy || 'The session refused that message.', kind: 'error' }; paintNote(); }
905
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(); }
906
1235
  }).catch(function () {});
@@ -924,7 +1253,9 @@
924
1253
  lines.push(rows.length ? 'Needs you (' + rows.length + (later ? ', ' + later + ' of them can wait until tomorrow' : '') + '):'
925
1254
  : m && !m.settled ? 'Needs you: nothing found so far.' : 'Needs you: nothing.');
926
1255
  rows.slice(0, 6).forEach(function (r) {
927
- if (r.kind !== 'work') { lines.push('- ' + end(clip(r.title, 140)) + (r.why ? ' ' + end(clip(r.why, 160)) : '')); return; }
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; }
928
1259
  var it = r.item;
929
1260
  var title = str(it.sessionTitle) || str(it.title) || 'Handoff';
930
1261
  var bits = [end(clip(title, 120)) + ' ' + end(needLine(it))];
@@ -967,6 +1298,6 @@
967
1298
  open: open, close: close, restore: restore, summary: summary, summaryMax: SUMMARY_MAX,
968
1299
  isOpen: function () { return !!O.open; },
969
1300
  _state: O, _decide: decide, _undo: undo, _intakeHtml: intakeHtml, _send: sendReply, _view: VIEW, _paintVerdict: paintVerdict,
970
- _paintLive: paintLive, _paintMeta: paintMeta, _onClick: onClick, _expire: expireLater
1301
+ _paintLive: paintLive, _paintMeta: paintMeta, _onClick: onClick, _expire: expireLater, _kb: KB, _fitNow: applyFit
971
1302
  });
972
1303
  })();