@gotcos/glasses-server 6.99.25 → 6.99.33

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 (53) hide show
  1. package/CHANGELOG.md +224 -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 +42 -9
  33. package/server/web-control/app.js +975 -114
  34. package/server/web-control/control.css +12 -6
  35. package/server/web-control/index.html +6 -1
  36. package/server/web-control/panes/cos-chat.css +27 -3
  37. package/server/web-control/panes/cos-chat.js +240 -46
  38. package/server/web-control/panes/meetings.css +5 -2
  39. package/server/web-control/panes/meetings.js +21 -10
  40. package/server/web-control/panes/memories.css +1 -1
  41. package/server/web-control/panes/memories.js +3 -8
  42. package/server/web-control/panes/questions.css +56 -2
  43. package/server/web-control/panes/questions.js +454 -56
  44. package/server/web-control/panes/reader-media.js +154 -0
  45. package/server/web-control/panes/sessions.css +20 -7
  46. package/server/web-control/panes/settings.js +34 -2
  47. package/server/web-control/panes/speakers.css +3 -5
  48. package/server/web-control/panes/speakers.js +7 -4
  49. package/server/web-control/panes/threads.js +3 -2
  50. package/server/web-control/panes/today-actions.css +81 -3
  51. package/server/web-control/panes/today-actions.js +527 -62
  52. package/server/web-control/panes/work.css +1 -1
  53. package/server/web-control/shell.js +3 -0
@@ -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
- pushed: false, ignorePops: 0,
78
+ pushed: false, ignorePops: 0, pops: [], // pops: our own Back steps still to arrive (quietBack)
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>' +
@@ -127,21 +144,164 @@
127
144
  layer.addEventListener('click', onClick);
128
145
  layer.addEventListener('submit', onSubmit);
129
146
  layer.addEventListener('focusin', onFocusIn);
147
+ // Focus leaving a field (the keyboard's Hide key, a tap outside) refits on the next frame, once it has moved.
148
+ layer.addEventListener('focusout', function () { fit(); });
130
149
  layer.addEventListener('input', function (e) {
131
150
  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
151
  });
152
+ // Files: the clip, a pasted screenshot in the box, and files dropped on the reply box.
153
+ layer.addEventListener('change', function (e) {
154
+ if (e.target && e.target.hasAttribute && e.target.hasAttribute('data-ta-file')) { addFiles(e.target.files); e.target.value = ''; }
155
+ });
156
+ layer.addEventListener('paste', function (e) {
157
+ if (!e.target || e.target.id !== 'ta-input' || !filesOn()) return;
158
+ var files = KIT().pasted(e);
159
+ if (files) { e.preventDefault(); addFiles(files); }
160
+ });
161
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (type) {
162
+ layer.addEventListener(type, function (e) {
163
+ var form = $('ta-form');
164
+ if (!form || !filesOn() || !e.target || !e.target.closest || !e.target.closest('#ta-form')) return;
165
+ var files = KIT().dragged(e, form);
166
+ if (files) addFiles(files);
167
+ });
168
+ });
133
169
  // 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 });
170
+ // The bleed below the bottom edge is the layer's own ::before (today-actions.css): a drag there is the layer's.
171
+ 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
172
  return layer;
136
173
  }
137
- // The layer follows the visible viewport, so the on-screen keyboard never covers the reply box or Send.
174
+ // 6.99.30 (Miles's Duo, in Comet as well as Safari): the layer is ALWAYS the visible part of the page, read the one
175
+ // way Control Web reads it (app.js COSWeb.viewportFit): top and height from visualViewport, 100dvh where there is
176
+ // none. Nothing assumes a browser: whatever covers the bottom (an on-screen keyboard, a hardware keyboard's strip,
177
+ // Comet's or any browser's bottom bar) is what the browser reports, and the reply box, Send and a question card's
178
+ // Submit stay above it. Only the backdrop and the sheet's own colour reach on down to the physical bottom
179
+ // (--ta-below plus the bleed, today-actions.css), so no band of Today shows under a bar that lets the page through.
180
+ // (6.99.29 made the closed-keyboard layer the whole layout viewport to cover IMG_0503's 106 px band; that band may be
181
+ // Comet's bottom bar, real covered space, and the composer must not sit under it.)
182
+ // The layout on top of that box:
183
+ // ta-kb an on-screen keyboard: what covers the bottom is KB_MIN_PX or more. The header folds to one
184
+ // compact row, the sheet fills the box, the reply box stays pinned at its bottom, and the
185
+ // conversation takes what is left. Under about three lines left, ta-kb-tight also hides the
186
+ // "Working right now" note until the keyboard closes (or the screen turns).
187
+ // ta-kb-strip a field in the sheet has focus and something under KB_MIN_PX covers the bottom (a hardware
188
+ // keyboard's 55 to 70 px strip): the header stays as it is; the strip covers the home indicator,
189
+ // so the reply box drops that inset.
190
+ // KB_MIN_PX applies whether or not a field has focus: a bottom bar under it (Comet's folded bar measured about 110 px)
191
+ // never folds the header, one of 120 px or more does. A strip counts from KB_STRIP_MIN_PX, so desktop zoom or a
192
+ // scrollbar (1 to 17 px of rounding, QA 6.99.30) never reads as a keyboard strip. Read on visualViewport resize and scroll while the overlay is open, and on window resize and
193
+ // orientationchange, pageshow, the page coming back into view, and focus moving in the sheet: at most once a frame.
194
+ var KB_MIN_PX = 120;
195
+ var KB_STRIP_MIN_PX = 24;
196
+ var BODY_MIN_PX = 72; // about three lines of the conversation (15 px text at 1.5)
197
+ var KB = { on: false, strip: false, tight: false, frame: 0, listening: false, width: 0, inset: 0 };
198
+ function tall(el) { return (el && el.offsetHeight) || 0; }
199
+ // What the conversation gets once the compact header and the reply box have theirs.
200
+ function bodyRoom(height) {
201
+ var sheet = $('ta-sheet'), grip = sheet && sheet.querySelector ? sheet.querySelector('.ta-grip') : null;
202
+ return height - tall($('ta-head')) - tall($('ta-foot')) - tall(grip);
203
+ }
204
+ // A field that types (the reply box, a question card's Other) has focus inside the sheet.
205
+ function typing() {
206
+ var a = document.activeElement;
207
+ if (!a || !layer || a === layer || !layer.contains || !layer.contains(a)) return false;
208
+ var tag = String(a.tagName || '').toUpperCase();
209
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true;
210
+ if (tag === 'INPUT') return !/^(checkbox|radio|button|submit|reset|range|color|file|image|hidden)$/i.test(String(a.type || 'text'));
211
+ return !!a.isContentEditable;
212
+ }
213
+ function viewport() { return W.viewportFit ? W.viewportFit() : null; }
214
+ function crumb(what, v, focus) {
215
+ if (!W.layoutCrumb || !O.open || !v) return;
216
+ W.layoutCrumb(what, { kind: O.open.kind, kb: KB.on, strip: KB.strip, tight: KB.tight, focus: !!focus, inset: v.inset, below: v.below,
217
+ lh: v.layoutH, top: v.top, h: v.height, zoomed: v.zoomed, wide: !phone() });
218
+ }
219
+ function applyFit() {
220
+ KB.frame = 0;
221
+ if (!layer || !O.open) return null;
222
+ var v = viewport();
223
+ if (!v) return null;
224
+ layer.style.setProperty('--ta-vh', v.height + 'px');
225
+ layer.style.setProperty('--ta-vt', v.top + 'px');
226
+ layer.style.setProperty('--ta-below', v.below + 'px');
227
+ var focus = typing(), on = v.inset >= KB_MIN_PX, strip = !on && focus && v.inset >= KB_STRIP_MIN_PX;
228
+ // A turned screen measures the room again: what was tight in landscape is not in portrait (QA 6.99.29).
229
+ var w = window.innerWidth || 0, turned = !!(KB.width && w && w !== KB.width);
230
+ if (w) KB.width = w;
231
+ var was = KB.on + '|' + KB.strip + '|' + KB.tight;
232
+ if (turned) KB.tight = false;
233
+ // A rise is what covers the bottom growing, by any amount, while the keyboard layout is on: not a class flip (a
234
+ // 120 px bar can already hold ta-kb before the keyboard comes, QA 6.99.32 Skeptic N-3), and not a jump past a step
235
+ // (a keyboard animating up in 20 px frames never crossed one, Codex's root regression on 98f3cde). The reveal is
236
+ // scrollIntoView 'nearest', so a frame where the field is already in view moves nothing. Shrinking never reveals.
237
+ var rose = (on || strip) && v.inset > (KB.inset || 0);
238
+ KB.inset = v.inset;
239
+ if (on !== KB.on) { KB.on = on; KB.tight = false; layer.classList.toggle('ta-kb', on); }
240
+ if (strip !== KB.strip) { KB.strip = strip; layer.classList.toggle('ta-kb-strip', strip); }
241
+ // 6.99.32 (QA 6.99.30 canary W2): the keyboard rises after the focus, so onFocusIn's scroll (250 ms) can land before
242
+ // the layer shrinks and leave a question card's Other field under the keyboard. Once the layout has the keyboard,
243
+ // bring the focused field into view again.
244
+ if (rose && focus) revealFocused();
245
+ // Measured with the compact header in place. Once tight it stays so while the keyboard is up: showing the note
246
+ // again would take the room back and flicker.
247
+ if (on && !KB.tight) KB.tight = bodyRoom(v.height) < BODY_MIN_PX;
248
+ layer.classList.toggle('ta-kb-tight', on && KB.tight);
249
+ if (was !== KB.on + '|' + KB.strip + '|' + KB.tight) crumb('kb', v, focus);
250
+ return v;
251
+ }
252
+ function revealFocused() {
253
+ // What has focus when the frame runs, not when it was asked for (QA 6.99.32 Skeptic N-2).
254
+ var go = function () {
255
+ if (!O.open || !typing()) return;
256
+ var a = document.activeElement, f = a && a.id === 'ta-input' ? $('ta-form') : a;
257
+ if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' });
258
+ };
259
+ // The next frame, so the scroll measures the layout with ta-kb (or ta-kb-strip) applied.
260
+ if (window.requestAnimationFrame) window.requestAnimationFrame(go); else go();
261
+ }
138
262
  function fit() {
263
+ if (KB.frame || !O.open) return;
264
+ if (!window.requestAnimationFrame) return applyFit(); // every browser with visualViewport has frames
265
+ KB.frame = window.requestAnimationFrame(applyFit);
266
+ }
267
+ function listenViewport(on) {
139
268
  var vv = window.visualViewport;
140
- if (!layer || !vv) return;
141
- layer.style.setProperty('--ta-vh', Math.round(vv.height) + 'px');
142
- layer.style.setProperty('--ta-vt', Math.round(vv.offsetTop) + 'px');
269
+ if (!vv || KB.listening === on) return;
270
+ KB.listening = on;
271
+ var m = on ? 'addEventListener' : 'removeEventListener';
272
+ if (typeof vv[m] === 'function') { vv[m]('resize', fit); vv[m]('scroll', fit); }
143
273
  }
144
- if (window.visualViewport) { window.visualViewport.addEventListener('resize', fit); window.visualViewport.addEventListener('scroll', fit); }
274
+ function unfit() {
275
+ listenViewport(false);
276
+ if (KB.frame && window.cancelAnimationFrame) window.cancelAnimationFrame(KB.frame);
277
+ KB.frame = 0; KB.on = false; KB.strip = false; KB.tight = false; KB.width = 0; KB.inset = 0;
278
+ if (!layer) return;
279
+ layer.classList.remove('ta-kb'); layer.classList.remove('ta-kb-strip'); layer.classList.remove('ta-kb-tight');
280
+ }
281
+ // The rest of the moments the visible viewport can change without a visualViewport event of its own (QA 6.99.29: the
282
+ // layer stayed sized for a keyboard that had gone). Each is a no-op while the overlay is closed.
283
+ window.addEventListener('resize', function () { fit(); });
284
+ window.addEventListener('orientationchange', function () { if (O.open) { KB.tight = false; fit(); } });
285
+ document.addEventListener('visibilitychange', function () {
286
+ if (document.hidden || !O.open) return;
287
+ fit();
288
+ if (O.r && O.open.kind === 'run') paintLive(); // the clock and the state, at once rather than on the next tick
289
+ });
290
+ // Back from the back-forward cache (a swipe back, the app switcher): the live stream is closed and everything shown
291
+ // is as old as the visit away. Open the stream again and read the session again.
292
+ window.addEventListener('pageshow', function (e) {
293
+ if (!O.open) return;
294
+ fit();
295
+ if (e && e.persisted) revive();
296
+ });
297
+ // 6.99.30 (QA): the Mac going away or coming back shows in an open sheet at once, not after the stream gives up: the
298
+ // live line says the Mac is not answering, and back up the stream opens again and the session is read again.
299
+ if (W.onServer) W.onServer(function (up) {
300
+ var r = O.r;
301
+ if (!O.open || !r) return;
302
+ if (!up) { r.feed.connected = false; r.feed.fallback = 'unreachable'; if (r.live) r.live.working = false; paintLive(); paintVerdict(); }
303
+ else revive();
304
+ });
145
305
 
146
306
  function openerSelector(t) {
147
307
  if (!t || !t.getAttribute) return '';
@@ -151,9 +311,14 @@
151
311
  return '[data-today=' + q(kind) + ']' + (key != null ? '[data-key=' + q(key) + ']' : '');
152
312
  }
153
313
 
154
- function open(kind, key, opener) {
314
+ // opt.from: a session opened from the pet's list ({ scroll }), which gets Back to sessions and its own history entry,
315
+ // so the phone's Back returns to the list too (Miles's iPhone, 11/07). opt.again: the list shown again from there,
316
+ // its entry already current.
317
+ function open(kind, key, opener, opt) {
318
+ opt = opt || {};
155
319
  build();
156
320
  if (O.open) stopSession();
321
+ O.back = opt.from || null;
157
322
  O.open = { kind: kind, key: String(key || ''), all: kind === 'intake' };
158
323
  O.opener = opener || document.activeElement; O.openerSel = openerSelector(opener);
159
324
  O.note = { text: '', kind: '' }; O.cautionAsk = false; O.needGone = false; O.armedAt = {};
@@ -161,14 +326,20 @@
161
326
  layer.hidden = false;
162
327
  document.documentElement.classList.add('ta-open');
163
328
  var shell = $('app-shell'); if (shell) shell.setAttribute('inert', '');
164
- fit();
329
+ listenViewport(true); applyFit();
165
330
  // The entry names what is open, so a reload can open it again and Back still closes it (restore below).
166
331
  var entry = { cosTodayOverlay: 1, kind: kind, key: O.open.key };
332
+ if (O.back) entry.from = 'pet';
167
333
  try {
168
- if (!O.pushed) { history.pushState(entry, ''); O.pushed = true; } else history.replaceState(entry, '');
334
+ if (opt.again) { /* the list's own entry is current again */ }
335
+ else if (!O.pushed) { history.pushState(entry, ''); O.pushed = true; O.depth = 1; }
336
+ else if (opt.from && O.depth === 1) { history.pushState(entry, ''); O.depth = 2; }
337
+ else history.replaceState(entry, '');
169
338
  } catch (_e) { /* no history API: Back simply leaves Today */ }
339
+ var backBtn = $('ta-back'); if (backBtn) backBtn.hidden = !O.back;
170
340
  try {
171
341
  if (kind === 'need' || kind === 'run' || kind === 'wait') openSession(kind, O.open.key);
342
+ else if (kind === 'pet') openPet();
172
343
  else openIntake();
173
344
  } catch (err) {
174
345
  // A bug here must not leave a half-drawn sheet over an inert page: take it down, keep the history entry for
@@ -179,6 +350,8 @@
179
350
  var sheet = $('ta-sheet');
180
351
  O.settleAt = Date.now() + settleMs(sheet);
181
352
  if (sheet && sheet.focus) sheet.focus({ preventScroll: true });
353
+ // Measured once the sheet is drawn, and sent home as a breadcrumb (app.js layoutCrumb, 6.99.30).
354
+ crumb('open', applyFit(), typing());
182
355
  }
183
356
  // How long taps inside are ignored after opening: the longest open motion of the sheet and its backdrop (their computed
184
357
  // animation and transition, delay included: today-actions.css ta-rise or ta-slide, 220 ms, and fade, 160 ms), plus
@@ -212,9 +385,15 @@
212
385
  stopSession();
213
386
  O.open = null;
214
387
  if (layer) layer.hidden = true;
388
+ unfit();
215
389
  document.documentElement.classList.remove('ta-open');
216
390
  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--; } }
391
+ // X from a session opened off the pet's list unwinds both entries in one step, so the next Back leaves Today as before.
392
+ if (O.pushed && how !== 'pop' && how !== 'leave') {
393
+ var n = O.depth > 1 ? O.depth : 1;
394
+ O.pushed = false;
395
+ quietBack(n);
396
+ }
218
397
  if (how === 'pop' || how === 'leave') O.pushed = false;
219
398
  clearTimeout(O.expireTimer);
220
399
  // Settled lines are done once the overlay closes; Today reads the server again if anything changed here.
@@ -231,17 +410,35 @@
231
410
  if (el && el.focus) { try { el.focus({ preventScroll: true }); } catch (_e) { el.focus(); } }
232
411
  }
233
412
  // Leaving for a tab from inside the overlay replaces the overlay's history entry, so Back from there returns to Today.
413
+ // A session opened off the pet's list holds two entries: the list's comes off first (6.99.30, QA: it stayed under
414
+ // the new tab, and Back from there landed on it and showed nothing), then the tab replaces the overlay's own.
234
415
  function leaveTo(pane, args) {
235
416
  var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
236
- var replace = O.pushed;
417
+ var replace = O.pushed, extra = O.pushed && O.depth > 1 ? O.depth - 1 : 0;
237
418
  close('leave');
238
- if (replace) location.replace(hash); else W.go(pane, args || []);
419
+ O.depth = 1;
420
+ if (!replace) { W.go(pane, args || []); return; }
421
+ if (extra) { quietBack(extra, function () { location.replace(hash); }); return; }
422
+ location.replace(hash);
423
+ }
424
+ // A Back step the overlay takes itself (X, Back to sessions, leaving for a tab): its popstate is ours, not the phone's
425
+ // Back, and `then` runs once it lands. If it never arrives (history.go with nothing to go back to, or a browser that
426
+ // drops it) the wait lapses after POP_WAIT_MS and `then` runs anyway, so a real Back later is never swallowed
427
+ // (6.99.30, QA: ignorePops stayed counted and ate the next Back).
428
+ var POP_WAIT_MS = 700;
429
+ function quietBack(n, then) {
430
+ var w = { then: then || null, timer: 0 };
431
+ var drop = function () { var i = O.pops.indexOf(w); if (i < 0) return false; O.pops.splice(i, 1); O.ignorePops = O.pops.length; return true; };
432
+ w.timer = setTimeout(function () { if (drop() && w.then) w.then(); }, POP_WAIT_MS);
433
+ O.pops.push(w); O.ignorePops = O.pops.length;
434
+ try { if (n > 1 && typeof history.go === 'function') history.go(-n); else history.back(); }
435
+ catch (_e) { clearTimeout(w.timer); if (drop() && w.then) w.then(); }
239
436
  }
240
437
  // A reload with the overlay open keeps its history entry, and the entry before it is Today at the same address, so
241
438
  // Back from there showed nothing (measured in Chrome 11/07: a same-document popstate). On load, app.js hands that
242
439
  // entry here: open the same thing again, so the first Back closes it as it did before the reload. What cannot be
243
440
  // 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 };
441
+ var KINDS = { need: 1, run: 1, wait: 1, pet: 1, meeting: 1, intake: 1 };
245
442
  function restore(st) {
246
443
  if (O.open || !st || !st.cosTodayOverlay) return Promise.resolve(false);
247
444
  var forget = function () { try { history.replaceState(null, ''); } catch (_e) {} return false; };
@@ -249,23 +446,36 @@
249
446
  var started = Date.now();
250
447
  return new Promise(function (resolve) {
251
448
  (function wait() {
252
- var d = T.data(), ready = d.at > 0 && (st.kind === 'need' || st.kind === 'run' || st.kind === 'wait' || d.intakeLoaded);
449
+ var d = T.data(), ready = d.at > 0 && (st.kind === 'need' || st.kind === 'run' || st.kind === 'wait' || st.kind === 'pet' || d.intakeLoaded);
253
450
  if (W.parseRoute().pane !== 'today' || O.open) return resolve(forget());
254
451
  if (!ready) { if (Date.now() - started > 15000) return resolve(forget()); return setTimeout(wait, 150); }
255
452
  O.pushed = true; // this entry is the overlay's own
256
- try { open(st.kind, String(st.key || ''), null); resolve(true); }
453
+ // A session opened off the pet's list keeps its Back to sessions: the list's entry is still under it.
454
+ if (st.from === 'pet' && (st.kind === 'wait' || st.kind === 'run')) O.depth = 2;
455
+ try { open(st.kind, String(st.key || ''), null, st.from === 'pet' && O.depth === 2 ? { from: { scroll: 0 }, again: true } : undefined); resolve(true); }
257
456
  catch (err) { console.error('[today] could not reopen the overlay after a reload.', err); O.pushed = false; resolve(forget()); }
258
457
  })();
259
458
  });
260
459
  }
261
460
  window.addEventListener('popstate', function () {
262
- if (O.ignorePops > 0) { O.ignorePops--; return; }
461
+ if (O.pops.length) { var w = O.pops.shift(); clearTimeout(w.timer); O.ignorePops = O.pops.length; if (w.then) w.then(); return; }
462
+ // The phone's Back from a session opened off the pet's list goes back to that list, as Back to sessions does.
463
+ if (O.open && O.back) { backToList(); return; }
263
464
  if (O.open) close('pop');
264
465
  });
466
+ // The pet's list again, where it was scrolled to. From the button the session's entry is taken off history too.
467
+ function backToList(fromButton) {
468
+ var b = O.back;
469
+ if (!b) return;
470
+ if (fromButton && O.depth > 1) quietBack(1);
471
+ O.depth = 1;
472
+ open('pet', '', O.opener, { again: true });
473
+ var body = $('ta-body'); if (body) body.scrollTop = b.scroll || 0;
474
+ }
265
475
  W.onRoute(function (r) { if (O.open && r.pane !== 'today') close('leave'); });
266
476
  // Escape closes the overlay, unless the Ask sheet is open over it (app.js closes that first).
267
477
  document.addEventListener('keydown', function (e) {
268
- if (!O.open || e.key !== 'Escape') return;
478
+ if (!O.open || e.key !== 'Escape' || (W.readerMedia && W.readerMedia.isOpen())) return;
269
479
  var ask = $('ask-sheet');
270
480
  if (ask && !ask.classList.contains('hidden')) return;
271
481
  e.preventDefault();
@@ -295,6 +505,7 @@
295
505
  if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) paintIntake(); else paintLive();
296
506
  }
297
507
  if (what === 'close') return close('user');
508
+ if (what === 'back') return backToList(true);
298
509
  if (what === 'create' || what === 'link') return decide(id, 'accept');
299
510
  if (what === 'dismiss') {
300
511
  // Without Undo on this server a Dismiss cannot be taken back, so it asks once: tap, then tap again.
@@ -306,7 +517,20 @@
306
517
  if (what === 'open-work') return leaveTo('work', ['sort']);
307
518
  if (what === 'open-card') return leaveTo('work', id ? [id] : []);
308
519
  if (what === 'open-session') return O.r ? leaveTo('sessions', [O.r.provider, O.r.id]) : null;
520
+ if (what === 'open-sessions') return leaveTo('sessions', []);
521
+ // 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)
522
+ // returning to the list where it was scrolled; X still closes the overlay.
523
+ if (what === 'pet-open') {
524
+ var pk = b.getAttribute('data-kind'), lb = $('ta-body');
525
+ return open(pk === 'wait' ? 'wait' : 'run', id, O.opener, { from: { scroll: lb ? lb.scrollTop || 0 : 0 } });
526
+ }
309
527
  if (what === 'send') return sendReply();
528
+ if (what === 'att-remove' || what === 'att-retry') {
529
+ var fb = filesBox(); if (!fb) return;
530
+ O.note = { text: '', kind: '' }; paintNote();
531
+ if (what === 'att-remove') KIT().remove(fb, b.getAttribute('data-att')); else KIT().retry(fb, b.getAttribute('data-att'));
532
+ return;
533
+ }
310
534
  if (what === 'stop') {
311
535
  // A tap arms it (Stop ends the agent's turn on your Mac); the second sends it.
312
536
  if (!confirmTap('stop', paintLive)) return;
@@ -325,10 +549,18 @@
325
549
  function onSubmit(e) {
326
550
  if (e.target && e.target.id === 'ta-form') { e.preventDefault(); sendReply(); }
327
551
  }
328
- // The keyboard rises over the bottom of the sheet: keep the box in view as it does.
552
+ // The keyboard rises over the bottom of the sheet: keep the box in view as it does. Any field that types counts
553
+ // (the reply box, a question card's Other): a hardware keyboard's strip is known only by focus (6.99.30).
329
554
  function onFocusIn(e) {
330
- if (!e.target || e.target.id !== 'ta-input') return;
331
- setTimeout(function () { fit(); var f = $('ta-form'); if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' }); }, 250);
555
+ if (!e.target || !typing()) return;
556
+ fit();
557
+ var reply = e.target.id === 'ta-input';
558
+ setTimeout(function () {
559
+ if (!O.open) return;
560
+ applyFit();
561
+ var f = reply ? $('ta-form') : e.target;
562
+ if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' });
563
+ }, 250);
332
564
  }
333
565
 
334
566
  // ═════════════ From today's meetings ═════════════
@@ -359,7 +591,8 @@
359
591
  probeLoop().then(function () { if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) paintIntake(); });
360
592
  paintIntake();
361
593
  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>';
594
+ // A waiting sheet opened before may have hidden the foot (its reply box); this one always shows (QA round 1).
595
+ 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
596
  // Read Intake again: what was decided on another screen since Today loaded shows as handled, never as a phantom.
364
597
  var asked = T.reloadIntake();
365
598
  if (asked && asked.then) asked.then(function () { if (O.open && (O.open.kind === 'meeting' || O.open.kind === 'intake')) { noteVanished(); paintIntake(); } });
@@ -562,37 +795,84 @@
562
795
  O.row = id ? sessionRowFor(provider, id) : null;
563
796
  O.r = id ? K.session(provider, id, VIEW) : null;
564
797
  // 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') };
798
+ // 6.99.29: the row is one signal of the card's one state (app.js sxTruth), timed by its own evidence.
799
+ if (O.r) { O.r.live = {}; K.feed.noteList(O.r, O.row); K.feed.settle(O.r); }
566
800
  // What was typed, and a send whose answer never came, survive a close, Back or reload: the same words resend under
567
801
  // the same key (skeptic W3, qa2-user N1).
568
802
  var kept = O.r ? draftOf(O.r) : null;
569
- if (kept && kept.unsettled) O.r.draft = { text: kept.unsettled.text, key: kept.unsettled.key };
803
+ if (kept && kept.unsettled) {
804
+ O.r.draft = { text: kept.unsettled.text, key: kept.unsettled.key };
805
+ if (Array.isArray(kept.unsettled.ids) && kept.unsettled.ids.length) { O.r.draft.ids = kept.unsettled.ids.slice(); O.r.draft.restored = true; }
806
+ }
570
807
  O.last = null; O.lastState = 'idle';
571
808
  setHead(kind === 'need' ? (O.item ? str(O.item.sessionTitle) || str(O.item.title) || 'Handoff' : 'Needs you')
572
809
  : kind === 'wait' ? (O.wait ? O.wait.title : 'A session is waiting for you') : (O.row ? K.title(O.row) : 'Session'));
573
810
  var body = $('ta-body');
574
- if (body) body.innerHTML = '<div id="ta-meta"></div><div id="ta-q" class="ta-q" hidden></div><div id="ta-live"></div><div id="ta-last"></div>';
811
+ if (body) body.innerHTML = '<div id="ta-meta"></div><div id="ta-notsent" class="ta-notsent" role="status"></div><div id="ta-q" class="ta-q" hidden></div><div id="ta-live"></div><div id="ta-last"></div>';
575
812
  var foot = $('ta-foot');
576
- if (foot) foot.innerHTML = O.r ? composerHtml(kind) : '';
813
+ if (foot) { foot.innerHTML = O.r ? composerHtml(kind) : ''; foot.hidden = false; }
577
814
  var input = $('ta-input');
578
815
  if (input && kept && kept.text) { input.value = kept.text; W.autoGrow(input); }
816
+ if (O.r) {
817
+ paintFiles();
818
+ // The Mac's answer about files may not be in yet: ask once, then draw the clip.
819
+ var kit = KIT(), r0 = O.r;
820
+ if (kit && !kit.turnFiles()) kit.ensureCaps().then(function () { if (O.r === r0) paintFiles(); }, function () {});
821
+ // Files of a send a reload cut off go again with the same words; say so, since their chips are gone.
822
+ if (O.r.draft && O.r.draft.restored) {
823
+ var nf = O.r.draft.ids.length;
824
+ O.note = { text: 'Your last message did not finish sending. Send sends it again with the ' + (nf === 1 ? 'file' : nf + ' files') + ' you attached.', kind: '' };
825
+ paintNote();
826
+ }
827
+ }
579
828
  paintMeta();
580
829
  if (!O.r) return;
581
830
  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(); });
831
+ // mount() draws at once and calls back before it returns, so the counts come from the callback, not O.qMount:
832
+ // the questions to answer here, and those the Mac's own prompt waits on (drawn read-only).
833
+ O.qMount = W.sessionQuestions.mount($('ta-q'), id, function (n, mac) { O.qCount = n; O.macCount = mac || 0; paintMeta(); });
584
834
  }
585
835
  if (kind === 'run') { paintLive(); watchStream(O.r); }
586
836
  loadLast(O.r);
587
837
  K.probe(O.r);
838
+ loadNotSent(O.r);
588
839
  if (kind === 'need') recheckNeed(key);
589
840
  }
841
+ // 6.99.30: a queued message for this session that the server refused or let expire, said here as in the reader
842
+ // ("Not sent: <reason>"), from the same queue read (app.js sessionKit.notSent). The Sessions list chip follows it.
843
+ function loadNotSent(r) {
844
+ if (!r || !K.notSent || !K.answer) return;
845
+ K.answer(K.path(r.provider, r.id, '/queued-turns')).then(function (q) {
846
+ if (O.r !== r) return;
847
+ var rows = (q && q.status === 200 && q.body && q.body.turns) || [];
848
+ if (q && q.status === 200) K.notSent.note(r.provider, r.id, rows);
849
+ var box = $('ta-notsent'); if (!box) return;
850
+ box.innerHTML = K.notSent.rows(rows).map(function (t) {
851
+ return '<p class="ta-note ta-notsent-line"><strong>' + esc(clip(t.preview || t.prompt || 'A queued message', 80)) + '</strong> ' + esc(K.notSent.line(t)) + '</p>';
852
+ }).join('');
853
+ }, function () {});
854
+ }
855
+ // Back from the back-forward cache with a session open (pageshow persisted): the stream again, the last reply, the
856
+ // write gate and the questions, as on opening.
857
+ function revive() {
858
+ var r = O.r;
859
+ if (!r || !O.open) return;
860
+ if (O.open.kind === 'run') {
861
+ if (O.stream) { try { O.stream.close(); } catch (_e) {} O.stream = null; }
862
+ watchStream(r); paintLive();
863
+ }
864
+ loadLast(r);
865
+ K.probe(r);
866
+ loadNotSent(r);
867
+ if (W.sessionQuestions && W.sessionQuestions.nudge) W.sessionQuestions.nudge();
868
+ }
590
869
  function stopSession() {
870
+ if (K.imagesStop) K.imagesStop($('ta-last'));
591
871
  if (O.stream) { try { O.stream.close(); } catch (_e) {} O.stream = null; }
592
872
  clearInterval(O.clock); O.clock = null;
593
873
  clearInterval(O.watch); O.watch = null;
594
874
  if (O.qMount) { O.qMount.unmount(); O.qMount = null; }
595
- O.qCount = 0;
875
+ O.qCount = 0; O.macCount = 0;
596
876
  O.r = null; O.row = null; O.item = null;
597
877
  }
598
878
  // The Needs you list may be minutes old: read it again, and say so if this one is no longer waiting.
@@ -612,12 +892,73 @@
612
892
  var label = kind === 'run' ? 'Send a note to this session' : 'Reply to this session';
613
893
  return '<div id="ta-verdict"></div>' +
614
894
  '<form class="composer ta-composer" id="ta-form">' +
895
+ '<div class="cc-chips ta-files hidden" id="ta-files"></div>' +
615
896
  '<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>' +
897
+ '<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
898
  '</form>' +
618
899
  '<p class="status-line" id="ta-status" role="status" aria-live="polite"></p>';
619
900
  }
620
901
 
902
+ // ── files with a reply or a note (the COS chat's attach kit) ──
903
+ // One box per session, kept while this tab is open, so closing and reopening the sheet keeps what was attached.
904
+ var FILES = {};
905
+ function KIT() { return W.cosChat && W.cosChat.attach ? W.cosChat.attach : null; }
906
+ // Only where the Mac takes files on a session turn: on an older server the words go alone, and no clip says otherwise.
907
+ function filesOn() { var k = KIT(); return !!(k && k.turnFiles() > 0 && O.r); }
908
+ function filesBox(r) {
909
+ r = r || O.r; var kit = KIT();
910
+ if (!r || !kit) return null;
911
+ var key = r.provider + '|' + r.id;
912
+ // 6.99.27: any file the Mac takes on a turn (threadTurnFileBytes), sent as it is; else the chat's list.
913
+ if (!FILES[key]) FILES[key] = kit.box({
914
+ changed: function (box) { if (O.r && FILES[O.r.provider + '|' + O.r.id] === box) paintFiles(); },
915
+ removed: function () {},
916
+ 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(); } }
917
+ }, { anyBytes: kit.turnFileBytes() });
918
+ // The Mac's answer can come after the box was made: a box with nothing in it yet takes it then.
919
+ if (!FILES[key].anyBytes && !FILES[key].atts.length && kit.turnFileBytes()) FILES[key].anyBytes = kit.turnFileBytes();
920
+ return FILES[key];
921
+ }
922
+ function filesState(box) {
923
+ var kit = KIT();
924
+ if (!kit || !box || !box.atts.length) return 'none';
925
+ return kit.pending(box) ? 'waiting' : kit.failed(box) ? 'failed' : 'ready';
926
+ }
927
+ function paintClip() {
928
+ var slot = $('ta-clip'); if (!slot) return;
929
+ var kit = KIT();
930
+ if (!filesOn()) { slot.innerHTML = ''; return; }
931
+ var box = filesBox(), full = !!(box && box.atts.length >= kit.max);
932
+ var mode = String(box && box.anyBytes || 0);
933
+ 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); }
934
+ var lbls = slot.querySelectorAll ? slot.querySelectorAll('.cc-attach') : [], inps = slot.querySelectorAll ? slot.querySelectorAll('[data-ta-file]') : [];
935
+ Array.prototype.forEach.call(lbls, function (l) { l.classList.toggle('full', full); });
936
+ Array.prototype.forEach.call(inps, function (i) { i.disabled = full || !!(O.r && O.r.sending); });
937
+ }
938
+ function paintFiles() {
939
+ var el = $('ta-files'), kit = KIT(), box = filesBox();
940
+ if (el && kit && box) {
941
+ el.innerHTML = kit.chipsHtml(box, 'data-ta') + (box.atts.length ? '<p class="att-limit">' + esc(kit.limitLine(box)) + '</p>' : '');
942
+ el.classList.toggle('hidden', !box.atts.length);
943
+ }
944
+ paintClip(); paintSendButton();
945
+ }
946
+ // Send waits while a file uploads. A failed file never greys it out (QA 6.99.27): a tap says why, as the question
947
+ // card does.
948
+ function paintSendButton() {
949
+ var send = $('ta-send'); if (!send) return;
950
+ var sending = !!(O.r && O.r.sending), st = filesState(filesBox());
951
+ send.disabled = sending || st === 'waiting';
952
+ send.textContent = sending ? 'Sending' : st === 'waiting' ? 'Uploading' : 'Send';
953
+ }
954
+ function addFiles(list) {
955
+ var kit = KIT(), box = filesBox();
956
+ if (!kit || !box || !list || !list.length || !filesOn()) return;
957
+ var refusals = kit.add(box, list);
958
+ if (refusals.length) { O.note = { text: refusals.join(' '), kind: 'error' }; paintNote(); }
959
+ paintFiles();
960
+ }
961
+
621
962
  function needLine(it) {
622
963
  var why = T.needWhy(it) + (it.domain ? ' in ' + W.domainLabel(it.domain) : '');
623
964
  var age = W.ago(it.updatedAt || it.createdAt);
@@ -638,7 +979,7 @@
638
979
  function runWords() {
639
980
  var r = O.r, f = r && r.feed;
640
981
  if (f && (f.connected || f.lastSeq > 0)) {
641
- var w = K.feed.word(r), since = K.feed.elapsed(f);
982
+ var w = K.feed.word(r), since = K.feed.elapsed(f, r);
642
983
  if (w === 'Working') return 'Working now' + (since ? ', for ' + since : '');
643
984
  if (w === 'Waiting') return 'Waiting for you' + (since ? ', for ' + since : '');
644
985
  if (w === 'Failed') return 'Stopped with an error';
@@ -662,10 +1003,15 @@
662
1003
  var p = it.progress && !it.progress.unreadable ? it.progress : null;
663
1004
  if (p && p.evidence) html += '<blockquote class="ta-quote">' + esc(p.evidence) + '</blockquote>';
664
1005
  } 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).
1006
+ // A session waiting on you: what it waits on, its question to answer here when the Mac holds one for this page;
1007
+ // when the Mac's own prompt has it, that, read-only; else (a reply is the answer) the reply box below. No live
1008
+ // peek and no Stop: it is not at work (W4-r2).
667
1009
  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>';
1010
+ var where = waitWhere();
1011
+ // The Mac card says it itself when there is one; say it here only when there is none.
1012
+ if (where === 'mac' && !O.macCount) html += '<p class="ta-sub ta-mac-line">' + esc(macLine()) + '</p>';
1013
+ if (where === 'answered') html += '<p class="ta-sub ta-mac-line">' + esc((W.sessionQuestions && W.sessionQuestions.answeredLine) || 'Answered. The session carries on.') + '</p>';
1014
+ if (where === 'reply') html += '<p class="ta-sub">' + esc('Answer it in a reply below, or open the session.') + '</p>';
669
1015
  } else if (O.row || O.r) {
670
1016
  // Once the live feed speaks, the line says what it says, so the header and the peek never disagree (qa2-user N3).
671
1017
  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 +1024,108 @@
678
1024
  if (links) html += '<div class="ta-links">' + links + '</div>';
679
1025
  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
1026
  box.innerHTML = html;
1027
+ // The reply box shows only where a reply can be the answer; what was typed stays in it, hidden.
1028
+ var foot = $('ta-foot');
1029
+ if (foot && O.open.kind === 'wait') foot.hidden = waitWhere() !== 'reply';
1030
+ }
1031
+ // Where a waiting session's question is answered: 'here' (its cards in this sheet), 'answered' (on some screen just
1032
+ // now), 'mac' (a Claude session's own prompt, or a held question that went back to the Mac), or 'reply' (a Codex or
1033
+ // Cursor session asking in words).
1034
+ function waitWhere() {
1035
+ if (O.qCount) return 'here';
1036
+ if (O.r && W.sessionQuestions && W.sessionQuestions.answeredFor && W.sessionQuestions.answeredFor(O.r.id)) return 'answered';
1037
+ if (O.macCount || (O.r && O.r.provider === 'claude') || (O.wait && O.wait.where === 'mac')) return 'mac';
1038
+ return 'reply';
1039
+ }
1040
+ function macLine() { return (W.sessionQuestions && W.sessionQuestions.macLine) || "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens)."; }
1041
+
1042
+ // ═════════════ The pet's sessions ═════════════
1043
+ // The same sessions the pet's caption counts (app.js petPlay): waiting on you, running, then done today. Each row
1044
+ // opens that session's own sheet.
1045
+ var PET_DONE_SHOWN = 5;
1046
+ function openPet() {
1047
+ setHead('Sessions');
1048
+ var p = T.petPlay ? T.petPlay() : { waiting: [], running: [], done: [] };
1049
+ var row = function (s, kind, line) {
1050
+ var k = T.splitSessionId(s.provider, s.session_id || s.id);
1051
+ return '<button type="button" class="ta-pet-row" data-ta="pet-open" data-kind="' + kind + '" data-id="' + esc(k.provider + '|' + k.id) + '">' +
1052
+ '<span class="ta-pet-top">' + T.badge(k.provider) + '<span class="ta-pet-title">' + esc(T.sessionTitle(s)) + '</span></span>' +
1053
+ '<span class="ta-pet-line">' + esc(line) + '</span></button>';
1054
+ };
1055
+ var group = function (title, rows) { return rows.length ? '<section class="ta-pet-group"><h3 class="ta-pet-head">' + esc(title) + '</h3>' + rows.join('') + '</section>' : ''; };
1056
+ // 6.99.27: the questions this page holds come first, one row per session, each opening that session's cards.
1057
+ var asked = petAsked(), keyOf = function (s) { var k = T.splitSessionId(s.provider, s.session_id || s.id); return (k.provider + '|' + k.id).toLowerCase(); };
1058
+ var questions = asked.map(function (a) {
1059
+ var s = sessionRowFor(a.provider, a.id);
1060
+ var k = esc(a.provider + '|' + a.id);
1061
+ return '<button type="button" class="ta-pet-row" data-ta="pet-open" data-kind="wait" data-id="' + k + '">' +
1062
+ '<span class="ta-pet-top">' + T.badge(a.provider) + '<span class="ta-pet-title">' + esc(s ? T.sessionTitle(s) : a.title) + '</span></span>' +
1063
+ '<span class="ta-pet-line">' + esc(a.line) + '</span></button>';
1064
+ });
1065
+ var held = {}; asked.forEach(function (a) { held[(a.provider + '|' + a.id).toLowerCase()] = 1; });
1066
+ var waiting = p.waiting.filter(function (s) { return !held[keyOf(s)]; }).map(function (s) { return row(s, 'wait', stateWords(s)); });
1067
+ // The pet counts a session as running from the Running card's test; one of those paused on you opens as waiting.
1068
+ 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)); });
1069
+ var done = p.done.slice(0, PET_DONE_SHOWN).map(function (s) { return row(s, 'run', 'Last active ' + doneWhen(s)); });
1070
+ var body = $('ta-body');
1071
+ 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) +
1072
+ (!questions.length && !waiting.length && !running.length ? '<p class="ta-note">Nothing is running or waiting on you now.</p>' : '') +
1073
+ '<div class="ta-links"><button type="button" class="link-btn" data-ta="open-sessions">All sessions</button></div>';
1074
+ var foot = $('ta-foot'); if (foot) { foot.innerHTML = ''; foot.hidden = false; }
1075
+ }
1076
+ // The held questions by session (panes/questions.js), oldest first: the words of each session's first question.
1077
+ function petAsked() {
1078
+ var q = W.sessionQuestions, items = q && q.itemsFor ? q.itemsFor('') : [], out = [], at = {};
1079
+ (items || []).forEach(function (it) {
1080
+ if (!it || !it.sessionId) return;
1081
+ var k = T.splitSessionId(it.provider, it.sessionId), key = (k.provider + '|' + k.id).toLowerCase();
1082
+ if (at[key] !== undefined) { out[at[key]].n++; return; }
1083
+ var first = it.kind === 'approval' ? (it.approval && it.approval.summary ? 'Wants to run ' + it.approval.summary : 'Wants your permission')
1084
+ : String(((it.questions || [])[0] || {}).question || 'Asked you a question');
1085
+ at[key] = out.length;
1086
+ 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' });
1087
+ });
1088
+ out.forEach(function (a) { if (a.n > 1) a.line = a.n + ' questions. ' + a.line; });
1089
+ return out;
1090
+ }
1091
+ function doneWhen(s) {
1092
+ var a = W.ago(s.last_activity_at || s.state_since);
1093
+ if (!a || a === 'now') return 'just now';
1094
+ return /( min| h)$/.test(a) ? a + ' ago' : a;
681
1095
  }
682
1096
 
683
1097
  // The session's last reply, from the same read the reader makes (?turns=N, lib/agent-session-turns.ts).
684
1098
  function loadLast(r) {
685
- O.lastState = 'loading'; paintLast();
1099
+ var request = r.lastRead = {};
1100
+ O.lastState = 'loading'; O.lastWhy = ''; paintLast();
686
1101
  return K.answer(K.path(r.provider, r.id, '?turns=6'), { timeoutMs: 20000 }).then(function (a) {
687
- if (O.r !== r) return;
1102
+ if (O.r !== r || r.lastRead !== request) return;
688
1103
  if (a.status === 404) { O.lastState = 'none'; O.lastWhy = 'This session is not on your Mac any more.'; paintLast(); return; }
689
1104
  if (a.status !== 200) { O.lastState = 'error'; paintLast(); return; }
690
1105
  var d = a.body || {};
691
1106
  if (!O.row) { O.row = d; paintMeta(); if (O.open && O.open.kind === 'run') setHead(K.title(d)); }
692
1107
  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;
1108
+ var lastA = turns.filter(function (t) { return lower(t.role) !== 'user' && (String(t.text || t.content || '').trim() || (K.imageHtml && K.imageHtml(t))); }).pop();
1109
+ O.last = lastA ? { text: String(lastA.text || lastA.content || '').trim(), at: lastA.at, outputImages: lastA.outputImages } : null;
695
1110
  O.lastState = O.last ? 'ready' : 'none';
696
1111
  O.lastWhy = '';
697
1112
  paintLast();
698
- }, function () { if (O.r === r) { O.lastState = 'error'; paintLast(); } });
1113
+ }, function () { if (O.r === r && r.lastRead === request) { O.lastState = 'error'; paintLast(); } });
699
1114
  }
700
1115
  function paintLast() {
701
1116
  var box = $('ta-last'); if (!box) return;
1117
+ // Retain the actual reply nodes (and its open image) during refreshes and transient errors.
1118
+ // Only a successful changed reply replaces them. Metadata changes count as changed too.
1119
+ var signature = O.last ? JSON.stringify([O.last.text, O.last.at, K.imageHtml ? K.imageHtml(O.last) : '']) : '';
1120
+ if (signature && box.__taLastSignature === signature) {
1121
+ var refresh = $('ta-last-refresh');
1122
+ if (refresh) refresh.textContent = O.lastState === 'loading' ? 'Checking for a newer reply…'
1123
+ : O.lastState === 'error' ? 'The latest reply could not load. Showing the previous reply.'
1124
+ : O.lastState === 'none' ? (O.lastWhy || '') : '';
1125
+ return;
1126
+ }
1127
+ box.__taLastSignature = signature;
1128
+ if (K.imagesStop) K.imagesStop(box);
702
1129
  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
1130
  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
1131
  if (O.lastState === 'none') { box.innerHTML = O.lastWhy ? '<p class="ta-sub">' + esc(O.lastWhy) + '</p>' : ''; return; }
@@ -706,7 +1133,8 @@
706
1133
  var at = W.toDate(O.last.at), long = O.last.text.length > 700;
707
1134
  box.innerHTML = '<section class="ta-last" aria-label="Last reply"><p class="ta-last-head">' + esc('Last reply' + (at ? ', ' + W.clock(at) : '')) + '</p>' +
708
1135
  '<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>';
1136
+ (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>';
1137
+ if (K.imagesMount) K.imagesMount(box, O.r);
710
1138
  }
711
1139
 
712
1140
  // Running: the same live stream the reader follows, read with its own reducer (applyFeed, sxFeedWord and friends).
@@ -721,15 +1149,17 @@
721
1149
  K.feed.apply(r, e);
722
1150
  r.live = r.live || {};
723
1151
  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;
1152
+ // 6.99.29: the card's one state (app.js sxTruth) decides working and waiting: the agent's own
1153
+ // waiting still wins over a busy file (W4-r2), and the stream's echo of a Stop still cannot
1154
+ // reopen it, but a stale idle no longer outranks fresher working evidence.
1155
+ var wasWorking = !!r.live.working, wasWaiting = !!r.live.waiting;
1156
+ K.feed.settle(r);
1157
+ if (wasWorking && !r.live.working) loadLast(r);
1158
+ // Gone to waiting: its question may be parked for this page now, so the card reads at once (6.99.27).
1159
+ if (r.live.waiting && !wasWaiting && W.sessionQuestions && W.sessionQuestions.nudge) W.sessionQuestions.nudge();
731
1160
  } else if (e.kind === 'prose' && e.text) r.live.prose = String(e.text);
732
1161
  paintLive();
1162
+ paintVerdict();
733
1163
  };
734
1164
  es.onerror = function () {
735
1165
  if (O.r !== r || O.stream !== es) return;
@@ -759,10 +1189,10 @@
759
1189
  // Idle: the reader's line repeats the last reply, which the peek shows in full just below.
760
1190
  var word = K.feed.word(r), detail = word === 'Idle' ? '' : K.feed.detail(f, word);
761
1191
  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>' +
1192
+ '<span class="sx-act-detail">' + esc(detail) + '</span><span class="sx-act-clock" id="ta-clock">' + esc(K.feed.elapsed(f, r)) + '</span>' +
763
1193
  (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
1194
  (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>' : '') +
1195
+ (f.prompt ? '<p class="ta-act-prompt">' + esc(K.feed.promptLine(r, clip(f.prompt, 240))) + '</p>' : '') +
766
1196
  '<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
1197
  (live.working && live.prose ? '<div class="sx-act-prose">' + esc(String(live.prose).slice(-500)) + '</div>' : '') + '</div>';
768
1198
  // The feed stopped or was refused: say why under the last state it showed.
@@ -775,7 +1205,9 @@
775
1205
  if (w === 'Working' || w === 'Waiting') {
776
1206
  O.clock = setInterval(function () {
777
1207
  if (O.r !== r) { clearInterval(O.clock); return; }
778
- var c = $('ta-clock'); if (c) c.textContent = K.feed.elapsed(r.feed);
1208
+ if (document.hidden) return; // a hidden page spends nothing; coming back repaints at once (visibilitychange)
1209
+ var c = $('ta-clock'); if (c) c.textContent = K.feed.elapsed(r.feed, r);
1210
+ if (K.feed.gateStale(r)) K.probe(r);
779
1211
  var rs = $('ta-run-state'); if (rs) rs.textContent = runWords();
780
1212
  }, 1000);
781
1213
  }
@@ -795,14 +1227,26 @@
795
1227
  paintNote();
796
1228
  },
797
1229
  clear: function (r, text) {
798
- // The Mac took it: the send is settled, and the kept draft goes with the words that were sent.
1230
+ // The Mac took it: the send is settled, and the kept draft goes with the words that were sent. So do the files
1231
+ // that went with them; any added since stay for the next message.
1232
+ var sent = r.draft && r.draft.text === text ? r.draft.ids || [] : [];
799
1233
  if (r.draft && r.draft.text === text) r.draft = null;
1234
+ var fb = sent.length ? filesBox(r) : null, kit = KIT();
1235
+ if (fb && kit) fb.atts.filter(function (t) { return sent.indexOf(t.mediaId) >= 0; }).forEach(function (t) { kit.remove(fb, t.id); });
800
1236
  var box = r === O.r ? $('ta-input') : null;
801
1237
  var left = box ? (box.value.trim() === String(text).trim() ? '' : box.value) : '';
802
1238
  if (box && !left) { box.value = ''; W.autoGrow(box); }
803
1239
  keepDraft(r, left, null);
804
1240
  },
805
1241
  verdict: function (r) { if (r === O.r) paintVerdict(); },
1242
+ // 6.99.29: the card's one state moved (a gate answer, a parked send, a live delivery): repaint all of it.
1243
+ stateChanged: function (r) {
1244
+ if (r !== O.r) return;
1245
+ var wasWorking = !!r.live && !!r.live.working;
1246
+ K.feed.settle(r);
1247
+ if (wasWorking && !r.live.working) loadLast(r);
1248
+ paintLive(); paintVerdict();
1249
+ },
806
1250
  hold: function (r) {
807
1251
  if (r !== O.r) return;
808
1252
  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 +1255,7 @@
811
1255
  watch: function (r, bindingId, key) { watchTurn(r, bindingId, key); },
812
1256
  queue: function () {}
813
1257
  };
814
- function paintNote() { W.setStatus($('ta-status'), O.note.text, O.note.kind === 'error'); }
1258
+ function paintNote() { W.setStatus($('ta-status'), O.note.text, O.note.kind === 'error'); if (KB.on) fit(); }
815
1259
  // A copy of the reader's renderVerdict (app.js): the same boxes and words, with Open session where the reader offers
816
1260
  // Fork and Refresh, which live only in the reader.
817
1261
  function paintVerdict() {
@@ -830,7 +1274,8 @@
830
1274
  ((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
1275
  '</div></div>';
832
1276
  } 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>';
1277
+ // 6.99.29: "Working right now" only while the card's one state says so.
1278
+ if (K.feed.parkNoteShows(r)) html = '<p class="sx-park">' + esc(K.parkCopy(r.provider)) + '</p>';
834
1279
  } else if (v && v.attachable === false && v.reasonCopy) {
835
1280
  html = '<div class="sx-box refusal" role="status"><p>' + esc(v.reasonCopy) + '</p></div>';
836
1281
  } else if (v && v.caution) {
@@ -838,16 +1283,26 @@
838
1283
  : 'Another app on this Mac has this session open. COS asks before the first send.') + '</p>';
839
1284
  }
840
1285
  box.innerHTML = html;
1286
+ if (KB.on) fit(); // the reply box's height changed: the conversation's room is measured again
841
1287
  }
842
1288
  function setSending(on) {
843
- var send = $('ta-send'), input = $('ta-input');
844
- if (send) { send.disabled = on; send.textContent = on ? 'Sending' : 'Send'; }
1289
+ var input = $('ta-input');
845
1290
  if (input) input.readOnly = on;
1291
+ paintSendButton(); paintClip();
846
1292
  }
847
1293
  function sendReply(opts) {
848
1294
  var r = O.r; if (!r) return;
849
1295
  var input = $('ta-input'), text = input ? input.value.trim() : '';
850
1296
  if (!text) { O.note = { text: 'Write a message first.', kind: 'error' }; paintNote(); return; }
1297
+ // Files: never before every upload has finished, and never with one that failed.
1298
+ var box = filesOn() ? filesBox(r) : null, st = filesState(box);
1299
+ if (st === 'waiting') { O.note = { text: 'A file is still uploading. Send when it finishes.', kind: '' }; paintNote(); return; }
1300
+ if (st === 'failed') { O.note = { text: 'A file did not upload. Retry it or remove it.', kind: 'error' }; paintNote(); return; }
1301
+ var ids = st === 'ready' ? KIT().ids(box) : [];
1302
+ // A send with files cut off by a reload: the chips are gone, the draft kept their ids. The same words go again with
1303
+ // the same files under the same key, so a send that did land is answered, never repeated.
1304
+ if (!ids.length && r.draft && r.draft.restored && r.draft.text === text && r.draft.ids && r.draft.ids.length) ids = r.draft.ids.slice();
1305
+ if (ids.length) opts = Object.assign({}, opts || {}, { attachmentIds: ids });
851
1306
  // Control's caution: another app holds this session and looks idle, so the first send asks.
852
1307
  if (r.verdict && r.verdict.caution && !r.cautionAck) { O.cautionAsk = true; O.armedAt = { caution: Date.now() }; paintVerdict(); return; }
853
1308
  if (r.sending) return;
@@ -881,7 +1336,7 @@
881
1336
  var b = c.body || {};
882
1337
  O.stopKey = '';
883
1338
  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); }
1339
+ 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
1340
  paintNote(); paintLive();
886
1341
  }, function (err) {
887
1342
  O.stopping = false;
@@ -896,11 +1351,19 @@
896
1351
  clearInterval(O.watch);
897
1352
  var started = Date.now();
898
1353
  O.watch = setInterval(function () {
1354
+ // Hidden: no reads, and the three minutes count only while the page is in view (6.99.30).
1355
+ if (document.hidden) { started += 3000; return; }
899
1356
  if (O.r !== r || Date.now() - started > WATCH_MS) { clearInterval(O.watch); return; }
900
1357
  K.answer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
901
1358
  if (O.r !== r) return;
902
1359
  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); }
1360
+ if (o === 'completed') {
1361
+ clearInterval(O.watch);
1362
+ // 6.99.29: a turn delivered LIVE is in the open session, which now works on it; it is not done.
1363
+ 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: '' }; }
1364
+ else { O.note = { text: 'Done. The reply is below.', kind: '' }; loadLast(r); }
1365
+ paintNote(); VIEW.stateChanged(r);
1366
+ }
904
1367
  else if (o === 'refused') { clearInterval(O.watch); O.note = { text: s.body.reasonCopy || 'The session refused that message.', kind: 'error' }; paintNote(); }
905
1368
  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
1369
  }).catch(function () {});
@@ -924,7 +1387,9 @@
924
1387
  lines.push(rows.length ? 'Needs you (' + rows.length + (later ? ', ' + later + ' of them can wait until tomorrow' : '') + '):'
925
1388
  : m && !m.settled ? 'Needs you: nothing found so far.' : 'Needs you: nothing.');
926
1389
  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; }
1390
+ // 6.99.26: where a waiting session can be answered, as the row says it.
1391
+ 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.' : '';
1392
+ if (r.kind !== 'work') { lines.push('- ' + end(clip(r.title, 140)) + (r.why ? ' ' + end(clip(r.why, 160)) : '') + where); return; }
928
1393
  var it = r.item;
929
1394
  var title = str(it.sessionTitle) || str(it.title) || 'Handoff';
930
1395
  var bits = [end(clip(title, 120)) + ' ' + end(needLine(it))];
@@ -967,6 +1432,6 @@
967
1432
  open: open, close: close, restore: restore, summary: summary, summaryMax: SUMMARY_MAX,
968
1433
  isOpen: function () { return !!O.open; },
969
1434
  _state: O, _decide: decide, _undo: undo, _intakeHtml: intakeHtml, _send: sendReply, _view: VIEW, _paintVerdict: paintVerdict,
970
- _paintLive: paintLive, _paintMeta: paintMeta, _onClick: onClick, _expire: expireLater
1435
+ _paintLive: paintLive, _paintMeta: paintMeta, _onClick: onClick, _expire: expireLater, _kb: KB, _fitNow: applyFit, _revive: revive
971
1436
  });
972
1437
  })();