@gotcos/glasses-server 6.99.29 → 7.0.7

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 (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -75,7 +75,7 @@
75
75
  var O = {
76
76
  open: null, // { kind: 'need' | 'run' | 'wait' | 'pet' | 'meeting' | 'intake', key, all }
77
77
  opener: null, openerSel: '',
78
- pushed: false, ignorePops: 0,
78
+ pushed: false, ignorePops: 0, pops: [], // pops: our own Back steps still to arrive (quietBack)
79
79
  depth: 1, // history entries the overlay holds: 2 while a session opened from the pet's list shows
80
80
  back: null, // { scroll } the pet's list that session came from, for Back to sessions
81
81
  dirty: false, // something was decided or sent: Today reloads on close
@@ -144,6 +144,8 @@
144
144
  layer.addEventListener('click', onClick);
145
145
  layer.addEventListener('submit', onSubmit);
146
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(); });
147
149
  layer.addEventListener('input', function (e) {
148
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);
149
151
  });
@@ -169,46 +171,103 @@
169
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 });
170
172
  return layer;
171
173
  }
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.
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.
182
194
  var KB_MIN_PX = 120;
195
+ var KB_STRIP_MIN_PX = 24;
183
196
  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
- }
197
+ var KB = { on: false, strip: false, tight: false, frame: 0, listening: false, width: 0, inset: 0, typed: false };
190
198
  function tall(el) { return (el && el.offsetHeight) || 0; }
191
199
  // What the conversation gets once the compact header and the reply box have theirs.
192
- function bodyRoom(vv) {
200
+ function bodyRoom(height) {
193
201
  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);
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
+ // 7.0.2: `wide` keeps its 6.99.30 meaning, 600 px wide or more, so old and new reports compare. From 7.0.2 a short
215
+ // touch screen at that width counts as a phone (app.js isPhone, A8): that is shortTouch.
216
+ function crumbWide() { return window.matchMedia ? !!window.matchMedia('(min-width: 600px)').matches : !phone(); }
217
+ function crumb(what, v, focus) {
218
+ if (!W.layoutCrumb || !O.open || !v) return;
219
+ W.layoutCrumb(what, { kind: O.open.kind, kb: KB.on, strip: KB.strip, tight: KB.tight, focus: !!focus, inset: v.inset, below: v.below,
220
+ lh: v.layoutH, top: v.top, h: v.height, zoomed: v.zoomed, wide: crumbWide(), shortTouch: crumbWide() && phone() });
195
221
  }
196
222
  function applyFit() {
197
223
  KB.frame = 0;
198
- var vv = window.visualViewport;
199
- if (!layer || !vv) return;
200
- layer.style.setProperty('--ta-vh', Math.round(vv.height) + 'px');
201
- layer.style.setProperty('--ta-vt', Math.round(vv.offsetTop) + 'px');
202
- var inset = keyboardInset(vv), on = inset >= KB_MIN_PX;
203
- layer.style.setProperty('--ta-kb', (on ? inset : 0) + 'px');
224
+ if (!layer || !O.open) return null;
225
+ var v = viewport();
226
+ if (!v) return null;
227
+ layer.style.setProperty('--ta-vh', v.height + 'px');
228
+ layer.style.setProperty('--ta-vt', v.top + 'px');
229
+ layer.style.setProperty('--ta-below', v.below + 'px');
230
+ var focus = typing(), on = v.inset >= KB_MIN_PX, strip = !on && focus && v.inset >= KB_STRIP_MIN_PX;
231
+ // A turned screen measures the room again: what was tight in landscape is not in portrait (QA 6.99.29).
232
+ var w = window.innerWidth || 0, turned = !!(KB.width && w && w !== KB.width);
233
+ if (w) KB.width = w;
234
+ var was = KB.on + '|' + KB.strip + '|' + KB.tight;
235
+ if (turned) KB.tight = false;
236
+ // A rise is what covers the bottom growing, by any amount, while the keyboard layout is on: not a class flip (a
237
+ // 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
238
+ // (a keyboard animating up in 20 px frames never crossed one, Codex's root regression on 98f3cde). The reveal is
239
+ // scrollIntoView 'nearest', so a frame where the field is already in view moves nothing. Shrinking never reveals.
240
+ var rose = (on || strip) && v.inset > (KB.inset || 0);
241
+ KB.inset = v.inset;
204
242
  if (on !== KB.on) { KB.on = on; KB.tight = false; layer.classList.toggle('ta-kb', on); }
243
+ if (strip !== KB.strip) { KB.strip = strip; layer.classList.toggle('ta-kb-strip', strip); }
244
+ // 6.99.32 (QA 6.99.30 canary W2): the keyboard rises after the focus, so onFocusIn's scroll (250 ms) can land before
245
+ // the layer shrinks and leave a question card's Other field under the keyboard. Once the layout has the keyboard,
246
+ // bring the focused field into view again.
247
+ if (rose && focus) revealFocused();
205
248
  // Measured with the compact header in place. Once tight it stays so while the keyboard is up: showing the note
206
249
  // again would take the room back and flicker.
207
- if (on && !KB.tight) KB.tight = bodyRoom(vv) < BODY_MIN_PX;
250
+ if (on && !KB.tight) KB.tight = bodyRoom(v.height) < BODY_MIN_PX;
208
251
  layer.classList.toggle('ta-kb-tight', on && KB.tight);
252
+ // 7.0.2 (A1): the breadcrumb goes on every keyboard open and close in the sheet (a field typing with KB_MIN_PX or more
253
+ // covered), not only when a class flips: a keyboard that came while ta-kb was already on used to send nothing.
254
+ var typed = focus && v.inset >= KB_MIN_PX, opened = typed !== KB.typed;
255
+ KB.typed = typed;
256
+ if (opened || was !== KB.on + '|' + KB.strip + '|' + KB.tight) crumb('kb', v, focus);
257
+ return v;
258
+ }
259
+ function revealFocused() {
260
+ // What has focus when the frame runs, not when it was asked for (QA 6.99.32 Skeptic N-2).
261
+ var go = function () {
262
+ if (!O.open || !typing()) return;
263
+ var a = document.activeElement, f = a && a.id === 'ta-input' ? $('ta-form') : a;
264
+ if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' });
265
+ };
266
+ // The next frame, so the scroll measures the layout with ta-kb (or ta-kb-strip) applied.
267
+ if (window.requestAnimationFrame) window.requestAnimationFrame(go); else go();
209
268
  }
210
269
  function fit() {
211
- if (KB.frame) return;
270
+ if (KB.frame || !O.open) return;
212
271
  if (!window.requestAnimationFrame) return applyFit(); // every browser with visualViewport has frames
213
272
  KB.frame = window.requestAnimationFrame(applyFit);
214
273
  }
@@ -222,11 +281,34 @@
222
281
  function unfit() {
223
282
  listenViewport(false);
224
283
  if (KB.frame && window.cancelAnimationFrame) window.cancelAnimationFrame(KB.frame);
225
- KB.frame = 0; KB.on = false; KB.tight = false;
284
+ KB.frame = 0; KB.on = false; KB.strip = false; KB.tight = false; KB.width = 0; KB.inset = 0; KB.typed = false;
226
285
  if (!layer) return;
227
- layer.classList.remove('ta-kb'); layer.classList.remove('ta-kb-tight');
228
- layer.style.setProperty('--ta-kb', '0px');
229
- }
286
+ layer.classList.remove('ta-kb'); layer.classList.remove('ta-kb-strip'); layer.classList.remove('ta-kb-tight');
287
+ }
288
+ // The rest of the moments the visible viewport can change without a visualViewport event of its own (QA 6.99.29: the
289
+ // layer stayed sized for a keyboard that had gone). Each is a no-op while the overlay is closed.
290
+ window.addEventListener('resize', function () { fit(); });
291
+ window.addEventListener('orientationchange', function () { if (O.open) { KB.tight = false; fit(); } });
292
+ document.addEventListener('visibilitychange', function () {
293
+ if (document.hidden || !O.open) return;
294
+ fit();
295
+ if (O.r && O.open.kind === 'run') paintLive(); // the clock and the state, at once rather than on the next tick
296
+ });
297
+ // Back from the back-forward cache (a swipe back, the app switcher): the live stream is closed and everything shown
298
+ // is as old as the visit away. Open the stream again and read the session again.
299
+ window.addEventListener('pageshow', function (e) {
300
+ if (!O.open) return;
301
+ fit();
302
+ if (e && e.persisted) revive();
303
+ });
304
+ // 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
305
+ // live line says the Mac is not answering, and back up the stream opens again and the session is read again.
306
+ if (W.onServer) W.onServer(function (up) {
307
+ var r = O.r;
308
+ if (!O.open || !r) return;
309
+ if (!up) { r.feed.connected = false; r.feed.fallback = 'unreachable'; if (r.live) r.live.working = false; paintLive(); paintVerdict(); }
310
+ else revive();
311
+ });
230
312
 
231
313
  function openerSelector(t) {
232
314
  if (!t || !t.getAttribute) return '';
@@ -275,6 +357,8 @@
275
357
  var sheet = $('ta-sheet');
276
358
  O.settleAt = Date.now() + settleMs(sheet);
277
359
  if (sheet && sheet.focus) sheet.focus({ preventScroll: true });
360
+ // Measured once the sheet is drawn, and sent home as a breadcrumb (app.js layoutCrumb, 6.99.30).
361
+ crumb('open', applyFit(), typing());
278
362
  }
279
363
  // How long taps inside are ignored after opening: the longest open motion of the sheet and its backdrop (their computed
280
364
  // animation and transition, delay included: today-actions.css ta-rise or ta-slide, 220 ms, and fade, 160 ms), plus
@@ -314,8 +398,8 @@
314
398
  // 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
399
  if (O.pushed && how !== 'pop' && how !== 'leave') {
316
400
  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--; }
401
+ O.pushed = false;
402
+ quietBack(n);
319
403
  }
320
404
  if (how === 'pop' || how === 'leave') O.pushed = false;
321
405
  clearTimeout(O.expireTimer);
@@ -333,11 +417,29 @@
333
417
  if (el && el.focus) { try { el.focus({ preventScroll: true }); } catch (_e) { el.focus(); } }
334
418
  }
335
419
  // Leaving for a tab from inside the overlay replaces the overlay's history entry, so Back from there returns to Today.
420
+ // A session opened off the pet's list holds two entries: the list's comes off first (6.99.30, QA: it stayed under
421
+ // the new tab, and Back from there landed on it and showed nothing), then the tab replaces the overlay's own.
336
422
  function leaveTo(pane, args) {
337
423
  var hash = '#/' + [pane].concat(args || []).map(encodeURIComponent).join('/');
338
- var replace = O.pushed;
424
+ var replace = O.pushed, extra = O.pushed && O.depth > 1 ? O.depth - 1 : 0;
339
425
  close('leave');
340
- if (replace) location.replace(hash); else W.go(pane, args || []);
426
+ O.depth = 1;
427
+ if (!replace) { W.go(pane, args || []); return; }
428
+ if (extra) { quietBack(extra, function () { location.replace(hash); }); return; }
429
+ location.replace(hash);
430
+ }
431
+ // A Back step the overlay takes itself (X, Back to sessions, leaving for a tab): its popstate is ours, not the phone's
432
+ // Back, and `then` runs once it lands. If it never arrives (history.go with nothing to go back to, or a browser that
433
+ // drops it) the wait lapses after POP_WAIT_MS and `then` runs anyway, so a real Back later is never swallowed
434
+ // (6.99.30, QA: ignorePops stayed counted and ate the next Back).
435
+ var POP_WAIT_MS = 700;
436
+ function quietBack(n, then) {
437
+ var w = { then: then || null, timer: 0 };
438
+ 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; };
439
+ w.timer = setTimeout(function () { if (drop() && w.then) w.then(); }, POP_WAIT_MS);
440
+ O.pops.push(w); O.ignorePops = O.pops.length;
441
+ try { if (n > 1 && typeof history.go === 'function') history.go(-n); else history.back(); }
442
+ catch (_e) { clearTimeout(w.timer); if (drop() && w.then) w.then(); }
341
443
  }
342
444
  // A reload with the overlay open keeps its history entry, and the entry before it is Today at the same address, so
343
445
  // Back from there showed nothing (measured in Chrome 11/07: a same-document popstate). On load, app.js hands that
@@ -362,8 +464,12 @@
362
464
  })();
363
465
  });
364
466
  }
365
- window.addEventListener('popstate', function () {
366
- if (O.ignorePops > 0) { O.ignorePops--; return; }
467
+ // Back from an enlarged image (panes/reader-media.js pushes one entry over the sheet's) is the image's Back: the
468
+ // viewer closes and the sheet, its scroll and a half-typed note stay (11/08). The viewer says which pops are its own.
469
+ function imagePop(e) { return !!(W.readerMedia && typeof W.readerMedia.popIsMine === 'function' && W.readerMedia.popIsMine(e)); }
470
+ window.addEventListener('popstate', function (e) {
471
+ if (O.pops.length) { var w = O.pops.shift(); clearTimeout(w.timer); O.ignorePops = O.pops.length; if (w.then) w.then(); return; }
472
+ if (imagePop(e)) return;
367
473
  // The phone's Back from a session opened off the pet's list goes back to that list, as Back to sessions does.
368
474
  if (O.open && O.back) { backToList(); return; }
369
475
  if (O.open) close('pop');
@@ -372,7 +478,7 @@
372
478
  function backToList(fromButton) {
373
479
  var b = O.back;
374
480
  if (!b) return;
375
- if (fromButton && O.depth > 1) { O.ignorePops++; try { history.back(); } catch (_e) { O.ignorePops--; } }
481
+ if (fromButton && O.depth > 1) quietBack(1);
376
482
  O.depth = 1;
377
483
  open('pet', '', O.opener, { again: true });
378
484
  var body = $('ta-body'); if (body) body.scrollTop = b.scroll || 0;
@@ -423,6 +529,7 @@
423
529
  if (what === 'open-card') return leaveTo('work', id ? [id] : []);
424
530
  if (what === 'open-session') return O.r ? leaveTo('sessions', [O.r.provider, O.r.id]) : null;
425
531
  if (what === 'open-sessions') return leaveTo('sessions', []);
532
+ if (what === 'q-retry') { if (W.sessionQuestions && W.sessionQuestions.retry) W.sessionQuestions.retry(); return; }
426
533
  // 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
534
  // returning to the list where it was scrolled; X still closes the overlay.
428
535
  if (what === 'pet-open') {
@@ -454,11 +561,18 @@
454
561
  function onSubmit(e) {
455
562
  if (e.target && e.target.id === 'ta-form') { e.preventDefault(); sendReply(); }
456
563
  }
457
- // The keyboard rises over the bottom of the sheet: keep the box in view as it does.
564
+ // The keyboard rises over the bottom of the sheet: keep the box in view as it does. Any field that types counts
565
+ // (the reply box, a question card's Other): a hardware keyboard's strip is known only by focus (6.99.30).
458
566
  function onFocusIn(e) {
459
- if (!e.target || e.target.id !== 'ta-input') return;
567
+ if (!e.target || !typing()) return;
460
568
  fit();
461
- setTimeout(function () { if (!O.open) return; applyFit(); var f = $('ta-form'); if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' }); }, 250);
569
+ var reply = e.target.id === 'ta-input';
570
+ setTimeout(function () {
571
+ if (!O.open) return;
572
+ applyFit();
573
+ var f = reply ? $('ta-form') : e.target;
574
+ if (f && f.scrollIntoView) f.scrollIntoView({ block: 'nearest' });
575
+ }, 250);
462
576
  }
463
577
 
464
578
  // ═════════════ From today's meetings ═════════════
@@ -706,7 +820,7 @@
706
820
  setHead(kind === 'need' ? (O.item ? str(O.item.sessionTitle) || str(O.item.title) || 'Handoff' : 'Needs you')
707
821
  : kind === 'wait' ? (O.wait ? O.wait.title : 'A session is waiting for you') : (O.row ? K.title(O.row) : 'Session'));
708
822
  var body = $('ta-body');
709
- if (body) body.innerHTML = '<div id="ta-meta"></div><div id="ta-q" class="ta-q" hidden></div><div id="ta-live"></div><div id="ta-last"></div>';
823
+ 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>';
710
824
  var foot = $('ta-foot');
711
825
  if (foot) { foot.innerHTML = O.r ? composerHtml(kind) : ''; foot.hidden = false; }
712
826
  var input = $('ta-input');
@@ -733,8 +847,37 @@
733
847
  if (kind === 'run') { paintLive(); watchStream(O.r); }
734
848
  loadLast(O.r);
735
849
  K.probe(O.r);
850
+ loadNotSent(O.r);
736
851
  if (kind === 'need') recheckNeed(key);
737
852
  }
853
+ // 6.99.30: a queued message for this session that the server refused or let expire, said here as in the reader
854
+ // ("Not sent: <reason>"), from the same queue read (app.js sessionKit.notSent). The Sessions list chip follows it.
855
+ function loadNotSent(r) {
856
+ if (!r || !K.notSent || !K.answer) return;
857
+ K.answer(K.path(r.provider, r.id, '/queued-turns')).then(function (q) {
858
+ if (O.r !== r) return;
859
+ var rows = (q && q.status === 200 && q.body && q.body.turns) || [];
860
+ if (q && q.status === 200) K.notSent.note(r.provider, r.id, rows);
861
+ var box = $('ta-notsent'); if (!box) return;
862
+ box.innerHTML = K.notSent.rows(rows).map(function (t) {
863
+ 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>';
864
+ }).join('');
865
+ }, function () {});
866
+ }
867
+ // Back from the back-forward cache with a session open (pageshow persisted): the stream again, the last reply, the
868
+ // write gate and the questions, as on opening.
869
+ function revive() {
870
+ var r = O.r;
871
+ if (!r || !O.open) return;
872
+ if (O.open.kind === 'run') {
873
+ if (O.stream) { try { O.stream.close(); } catch (_e) {} O.stream = null; }
874
+ watchStream(r); paintLive();
875
+ }
876
+ loadLast(r);
877
+ K.probe(r);
878
+ loadNotSent(r);
879
+ if (W.sessionQuestions && W.sessionQuestions.nudge) W.sessionQuestions.nudge();
880
+ }
738
881
  function stopSession() {
739
882
  if (K.imagesStop) K.imagesStop($('ta-last'));
740
883
  if (O.stream) { try { O.stream.close(); } catch (_e) {} O.stream = null; }
@@ -811,6 +954,8 @@
811
954
  el.classList.toggle('hidden', !box.atts.length);
812
955
  }
813
956
  paintClip(); paintSendButton();
957
+ // QA 7.0.0: the "Working right now" note says whether files go with the message.
958
+ if (O.r && O.r.verdict) paintVerdict();
814
959
  }
815
960
  // 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
961
  // card does.
@@ -877,8 +1022,15 @@
877
1022
  // peek and no Stop: it is not at work (W4-r2).
878
1023
  html += '<p class="ta-sub">' + T.badge(O.r ? O.r.provider : 'claude') + ' <span>' + esc(O.wait ? O.wait.why : 'Waiting for you') + '</span></p>';
879
1024
  var where = waitWhere();
1025
+ // W13 (QA week of 11/01-11/08): the words Today's row showed for it (its `ask`) stay here, read-only, when no card
1026
+ // draws them: the Mac line and a failed read alike. Never twice: not when the line above already says them.
1027
+ var ask = waitAsk();
1028
+ if (ask && (where === 'mac' || where === 'failed') && !O.macCount) html += '<blockquote class="ta-quote ta-mac-ask">' + esc(ask) + '</blockquote>';
880
1029
  // The Mac card says it itself when there is one; say it here only when there is none.
881
1030
  if (where === 'mac' && !O.macCount) html += '<p class="ta-sub ta-mac-line">' + esc(macLine()) + '</p>';
1031
+ // W13: the questions could not be read, so whether one waits here is unknown. Say so, never "at the Mac", and read again.
1032
+ if (where === 'failed') html += '<p class="ta-note ta-q-failed" role="status">' + esc('Couldn\u2019t load this session\u2019s questions, so one may be waiting here.') +
1033
+ ' <button type="button" class="link-btn" data-ta="q-retry">Retry</button></p>';
882
1034
  if (where === 'answered') html += '<p class="ta-sub ta-mac-line">' + esc((W.sessionQuestions && W.sessionQuestions.answeredLine) || 'Answered. The session carries on.') + '</p>';
883
1035
  if (where === 'reply') html += '<p class="ta-sub">' + esc('Answer it in a reply below, or open the session.') + '</p>';
884
1036
  } else if (O.row || O.r) {
@@ -895,17 +1047,25 @@
895
1047
  box.innerHTML = html;
896
1048
  // The reply box shows only where a reply can be the answer; what was typed stays in it, hidden.
897
1049
  var foot = $('ta-foot');
898
- if (foot && O.open.kind === 'wait') foot.hidden = waitWhere() !== 'reply';
1050
+ if (foot && O.open.kind === 'wait') { var w = waitWhere(); foot.hidden = w === 'failed' ? !!(O.r && O.r.provider === 'claude') : w !== 'reply'; }
899
1051
  }
900
1052
  // Where a waiting session's question is answered: 'here' (its cards in this sheet), 'answered' (on some screen just
901
1053
  // now), 'mac' (a Claude session's own prompt, or a held question that went back to the Mac), or 'reply' (a Codex or
902
1054
  // Cursor session asking in words).
1055
+ // W13: 'failed' when the last questions read failed and no card is drawn: a Claude reply still answers nothing, so the
1056
+ // reply box stays a Codex or Cursor one.
903
1057
  function waitWhere() {
904
1058
  if (O.qCount) return 'here';
905
1059
  if (O.r && W.sessionQuestions && W.sessionQuestions.answeredFor && W.sessionQuestions.answeredFor(O.r.id)) return 'answered';
1060
+ if (!O.macCount && W.sessionQuestions && W.sessionQuestions.loadState && W.sessionQuestions.loadState() === 'failed') return 'failed';
906
1061
  if (O.macCount || (O.r && O.r.provider === 'claude') || (O.wait && O.wait.where === 'mac')) return 'mac';
907
1062
  return 'reply';
908
1063
  }
1064
+ // The question in Today's own words (its Needs you row), unless the line above the sheet already reads exactly that.
1065
+ function waitAsk() {
1066
+ var a = O.wait && O.wait.ask ? String(O.wait.ask).trim() : '';
1067
+ return a && !(O.wait.why && String(O.wait.why).trim() === a) ? a : '';
1068
+ }
909
1069
  function macLine() { return (W.sessionQuestions && W.sessionQuestions.macLine) || "Waiting on your Mac's prompt. Answer it on the Mac (or through Screens)."; }
910
1070
 
911
1071
  // ═════════════ The pet's sessions ═════════════
@@ -967,7 +1127,7 @@
967
1127
  function loadLast(r) {
968
1128
  var request = r.lastRead = {};
969
1129
  O.lastState = 'loading'; O.lastWhy = ''; paintLast();
970
- return K.answer(K.path(r.provider, r.id, '?turns=6'), { timeoutMs: 20000 }).then(function (a) {
1130
+ return K.answer(K.path(r.provider, r.id, '?turns=6&cards=1'), { timeoutMs: 20000 }).then(function (a) {
971
1131
  if (O.r !== r || r.lastRead !== request) return;
972
1132
  if (a.status === 404) { O.lastState = 'none'; O.lastWhy = 'This session is not on your Mac any more.'; paintLast(); return; }
973
1133
  if (a.status !== 200) { O.lastState = 'error'; paintLast(); return; }
@@ -975,7 +1135,8 @@
975
1135
  if (!O.row) { O.row = d; paintMeta(); if (O.open && O.open.kind === 'run') setHead(K.title(d)); }
976
1136
  var turns = Array.isArray(d.recent_turns) ? d.recent_turns : [];
977
1137
  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;
1138
+ O.last = lastA ? { text: String(lastA.text || lastA.content || '').trim(), at: lastA.at, toolImages: lastA.toolImages, toolImagesMore: lastA.toolImagesMore, outputLinks: lastA.outputLinks,
1139
+ outputImages: lastA.outputImages, outputFiles: lastA.outputFiles, previewsOff: lastA.previewsOff } : null;
979
1140
  O.lastState = O.last ? 'ready' : 'none';
980
1141
  O.lastWhy = '';
981
1142
  paintLast();
@@ -985,7 +1146,7 @@
985
1146
  var box = $('ta-last'); if (!box) return;
986
1147
  // Retain the actual reply nodes (and its open image) during refreshes and transient errors.
987
1148
  // 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) : '']) : '';
1149
+ var signature = O.last ? JSON.stringify([O.last.text, O.last.at, K.imageHtml ? K.imageHtml(O.last) : '', K.linksAttr ? K.linksAttr(O.last) : '']) : '';
989
1150
  if (signature && box.__taLastSignature === signature) {
990
1151
  var refresh = $('ta-last-refresh');
991
1152
  if (refresh) refresh.textContent = O.lastState === 'loading' ? 'Checking for a newer reply…'
@@ -1001,8 +1162,10 @@
1001
1162
  if (!O.last) { box.innerHTML = ''; return; }
1002
1163
  var at = W.toDate(O.last.at), long = O.last.text.length > 700;
1003
1164
  box.innerHTML = '<section class="ta-last" aria-label="Last reply"><p class="ta-last-head">' + esc('Last reply' + (at ? ', ' + W.clock(at) : '')) + '</p>' +
1004
- '<div class="sx-md ta-last-text' + (long ? ' clamped' : '') + '" id="ta-last-text">' + K.markdown(O.last.text.slice(0, 12000)) + '</div>' +
1005
- (long ? '<button type="button" class="link-btn" data-ta="more">Show all of it</button>' : '') + (K.imageHtml ? K.imageHtml(O.last) : '') + '<p id="ta-last-refresh" class="ta-sub" role="status"></p></section>';
1165
+ (K.imageHtml ? K.imageHtml(O.last, { part: 'before' }) : '') +
1166
+ // Preview engine phase 1: the reply's link cards, as in the reader.
1167
+ '<div class="sx-md ta-last-text' + (long ? ' clamped' : '') + '" id="ta-last-text"' + (K.linksAttr ? K.linksAttr(O.last) : '') + '>' + K.markdown(O.last.text.slice(0, 12000), O.last.outputLinks) + '</div>' +
1168
+ (long ? '<button type="button" class="link-btn" data-ta="more">Show all of it</button>' : '') + (K.imageHtml ? K.imageHtml(O.last, { part: 'after' }) : '') + '<p id="ta-last-refresh" class="ta-sub" role="status"></p></section>';
1006
1169
  if (K.imagesMount) K.imagesMount(box, O.r);
1007
1170
  }
1008
1171
 
@@ -1074,6 +1237,7 @@
1074
1237
  if (w === 'Working' || w === 'Waiting') {
1075
1238
  O.clock = setInterval(function () {
1076
1239
  if (O.r !== r) { clearInterval(O.clock); return; }
1240
+ if (document.hidden) return; // a hidden page spends nothing; coming back repaints at once (visibilitychange)
1077
1241
  var c = $('ta-clock'); if (c) c.textContent = K.feed.elapsed(r.feed, r);
1078
1242
  if (K.feed.gateStale(r)) K.probe(r);
1079
1243
  var rs = $('ta-run-state'); if (rs) rs.textContent = runWords();
@@ -1143,7 +1307,8 @@
1143
1307
  '</div></div>';
1144
1308
  } else if (v && v.attachable === false && /^(native_thread_working|native_target_busy)$/.test(v.reason)) {
1145
1309
  // 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>';
1310
+ // QA 7.0.0: files in this sheet's box wait for the turn end, so the note says so (app.js sxParkLine).
1311
+ if (K.feed.parkNoteShows(r)) html = '<p class="sx-park">' + esc(K.parkCopy(r.provider, r, !!(filesOn() && filesState(filesBox(r)) !== 'none'))) + '</p>';
1147
1312
  } else if (v && v.attachable === false && v.reasonCopy) {
1148
1313
  html = '<div class="sx-box refusal" role="status"><p>' + esc(v.reasonCopy) + '</p></div>';
1149
1314
  } else if (v && v.caution) {
@@ -1219,6 +1384,8 @@
1219
1384
  clearInterval(O.watch);
1220
1385
  var started = Date.now();
1221
1386
  O.watch = setInterval(function () {
1387
+ // Hidden: no reads, and the three minutes count only while the page is in view (6.99.30).
1388
+ if (document.hidden) { started += 3000; return; }
1222
1389
  if (O.r !== r || Date.now() - started > WATCH_MS) { clearInterval(O.watch); return; }
1223
1390
  K.answer('/api/agent-sessions/bindings/' + encodeURIComponent(bindingId) + '/turns/' + encodeURIComponent(key)).then(function (s) {
1224
1391
  if (O.r !== r) return;
@@ -1298,6 +1465,6 @@
1298
1465
  open: open, close: close, restore: restore, summary: summary, summaryMax: SUMMARY_MAX,
1299
1466
  isOpen: function () { return !!O.open; },
1300
1467
  _state: O, _decide: decide, _undo: undo, _intakeHtml: intakeHtml, _send: sendReply, _view: VIEW, _paintVerdict: paintVerdict,
1301
- _paintLive: paintLive, _paintMeta: paintMeta, _onClick: onClick, _expire: expireLater, _kb: KB, _fitNow: applyFit
1468
+ _paintLive: paintLive, _paintMeta: paintMeta, _onClick: onClick, _expire: expireLater, _kb: KB, _fitNow: applyFit, _revive: revive
1302
1469
  });
1303
1470
  })();
@@ -59,7 +59,7 @@
59
59
  .wk-filters { width: 100%; justify-content: flex-start; }
60
60
  .wk-filters .cx-search { max-width: none; flex-basis: 100%; }
61
61
  .wk-pick { flex: 1 1 140px; flex-direction: column; align-items: stretch; gap: 4px; font-size: 11.5px; }
62
- .wk-pick select.field { min-height: 38px; font-size: 15px; width: 100%; background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px; }
62
+ .wk-pick select.field { font-size: 15px; width: 100%; background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px; }
63
63
  .wk-text-btn { min-height: 36px; font-size: 14px; }
64
64
  }
65
65
 
@@ -313,3 +313,13 @@
313
313
  .wk-pick-row { display: flex; align-items: center; gap: 10px; padding: 8px 2px; border-top: 1px solid var(--hairline); }
314
314
  .wk-pick-row > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
315
315
  .wk-pick-row strong { font-size: 13.5px; }
316
+
317
+ /* Duo tables: the divider between a card and its side panel (app.js splitKit, styled in app.css; work.js DIVIDER). From
318
+ 900 px only, where both show. Its 22 px column is the old gap, so the default layout is unchanged; a dragged width
319
+ sizes the side panel, clamped so it keeps 320 px and the card 380 px. */
320
+ @media (min-width: 900px) {
321
+ .wk-detail.split-side { grid-template-columns: minmax(0, 1fr) 22px minmax(320px, 400px); column-gap: 0; }
322
+ .wk-detail.split-side.split-sized { grid-template-columns: minmax(0, 1fr) 22px clamp(320px, var(--split-side-share, 30%), calc(100% - 402px)); }
323
+ .wk-detail.split-side > .split-handle { display: flex; }
324
+ }
325
+ @media (min-width: 1300px) { .wk-detail.split-side { grid-template-columns: minmax(0, 1fr) 22px 452px; } }
@@ -1126,6 +1126,13 @@
1126
1126
  (str(t.source) ? fact('Source', t.source) : '') + (str(t.runAt) ? fact('Scheduled', t.runAt) : '') +
1127
1127
  '<p class="wk-fine">Task editing, scheduling, and completion stay attached to the original task. Agent output does not change its completion state.</p></section>';
1128
1128
  }
1129
+ // Duo tables: the divider between a card and its side panel (app.js splitKit, 900 px and up). The side panel is the
1130
+ // column on the right, so it grows as the line moves left; it keeps 320 px and the card 380 px, past a 22 px divider
1131
+ // that is the old gap. A repaint in place keeps the same divider. (DIVIDER, since SPLIT is the Sort split.)
1132
+ // A page without the kit (a test's stand-in W) draws no divider and keeps working.
1133
+ var SPLIT_KIT = W.splitKit || { handle: function () { return ''; }, mount: function () { return null; } };
1134
+ var DIVIDER = { key: 'cos_web_work_split', label: 'Side panel', side: 'end', sized: '.wk-detail-side', cssVar: '--split-side-share', media: '(min-width: 900px)', listMin: 320, readerMin: 380, handleW: 22 };
1135
+ var SPLIT_HANDLE = SPLIT_KIT.handle({ id: 'wk-handle', controls: 'wk-part-side', label: DIVIDER.label });
1129
1136
  function renderDetail(arg) {
1130
1137
  var it = itemFor(arg);
1131
1138
  S.viewWaiting = !it && (S.loading || !S.tasks);
@@ -1151,11 +1158,12 @@
1151
1158
  } else {
1152
1159
  S.chatDeferred = false;
1153
1160
  pane.innerHTML = '<div class="cx wk" data-tint="work">' + backBar(backLabel()) + '<div id="wk-part-notices" class="wk-part">' + parts.notices + '</div>' +
1154
- '<div class="wk-detail" data-wid="' + esc(it.wid) + '"><div class="wk-detail-main"><div id="wk-part-main" class="wk-part">' + parts.main + '</div>' + chatSectionHtml(it) + '</div>' +
1155
- '<aside class="wk-detail-side" id="wk-part-side">' + parts.side + '</aside></div></div>';
1161
+ '<div class="wk-detail' + (SPLIT_HANDLE ? ' split-side' : '') + '" data-wid="' + esc(it.wid) + '"><div class="wk-detail-main"><div id="wk-part-main" class="wk-part">' + parts.main + '</div>' + chatSectionHtml(it) + '</div>' +
1162
+ SPLIT_HANDLE + '<aside class="wk-detail-side" id="wk-part-side">' + parts.side + '</aside></div></div>';
1156
1163
  [['wk-part-notices', parts.notices], ['wk-part-main', parts.main], ['wk-part-side', parts.side]].forEach(function (p) { var el = $(p[0]); if (el) el.__html = p[1]; });
1157
1164
  var hist = $('wk-part-chat'); if (hist) hist.__html = hist.innerHTML;
1158
1165
  }
1166
+ SPLIT_KIT.mount(pane.querySelector('.wk-detail'), $('wk-handle'), DIVIDER);
1159
1167
  chatSyncComposer(it);
1160
1168
  if (!S.activity[it.wid] && !S.activityError[it.wid] && !S.activityLoading[it.wid]) {
1161
1169
  S.activityLoading[it.wid] = true;
@@ -77,10 +77,13 @@
77
77
  }
78
78
  // About 16 reads: never two runs at once, never more than one a minute, two at a time through storyApi, and never
79
79
  // ahead of the pane the person just opened (Safari keeps 6 connections to the Mac over HTTP/1.1).
80
- // On the closed Duo (below 600px) the stories show only inside the section menu, so they load
81
- // when the menu opens rather than costing the pane its connections on every visit.
80
+ // Below 600 px wide (the folded Duo) the rail is hidden and the stories show only inside the section menu, so they
81
+ // load when the menu opens rather than costing the pane its connections on every visit. This is the WIDTH test on
82
+ // purpose, not W.isPhone: from 7.0.2 a short touch screen at any width counts as a phone (the unfolded Duo, 951x539),
83
+ // and it still has the rail, its story lines and hot dots.
84
+ function railHidden() { return !window.matchMedia('(min-width: 600px)').matches; }
82
85
  function loadStories(force) {
83
- if (!force && W.isPhone()) return;
86
+ if (!force && railHidden()) return;
84
87
  if (Date.now() - storiesAt < 60000) return;
85
88
  storiesAt = Date.now();
86
89
  var now = new Date();
@@ -210,17 +213,17 @@
210
213
  setStory('threads', active + ' active' + (list[0] && list[0].name ? ', newest ' + clip(list[0].name, 40) : ''), false);
211
214
  });
212
215
 
213
- // Memories (TodayHome.memoriesStory): n is the gated needs-you count (capped, from the
214
- // bridge's status, which runs the same CLI Control's helper runs); total is the number of
215
- // rows to review. "Next n of total" when there are more rows than n, else "n to review".
216
- Promise.all([
217
- soft(storyApi('/api/web-bridge/memories', { method: 'POST', body: { id: 'rail-status', op: 'status', args: {} } })),
218
- soft(storyApi('/api/context/learning/review?limit=200'))
219
- ]).then(function (res) {
220
- var n = res[0] && res[0].details ? res[0].details.learningToReview : null;
216
+ // Memories (TodayHome.memoriesStory): n is the needs-review count (capped, from the bridge's
217
+ // status, which runs the same CLI Control's helper runs, judged by memory-review.js); total is
218
+ // every item that needs review by the same predicate, never the To review list's length (that
219
+ // list also holds suggestions and proposals with no decision waiting). "Next n of total" when
220
+ // more need review than are shown, else "n to review".
221
+ soft(storyApi('/api/web-bridge/memories', { method: 'POST', body: { id: 'rail-status', op: 'status', args: {} } })).then(function (r) {
222
+ var d = r && r.details ? r.details : {};
223
+ var n = d.learningToReview;
221
224
  if (typeof n !== 'number') return setStory('memories', FALLBACK.memories, false);
222
225
  if (n <= 0) return setStory('memories', 'Nothing to review', false);
223
- var total = res[1] && Array.isArray(res[1].events) ? res[1].events.length : null;
226
+ var total = typeof d.learningToReviewTotal === 'number' ? d.learningToReviewTotal : null;
224
227
  setStory('memories', total != null && total > n ? 'Next ' + n + ' of ' + total : n + ' to review', true);
225
228
  });
226
229
  // Dictation lives on the Mac (its history and suggestions are local files).
@@ -260,9 +263,9 @@
260
263
  }
261
264
  crumbs.innerHTML = parts.join('');
262
265
  $('nav-back').disabled = onToday;
263
- // The phone top bar names the detail too, so a reader says where it is.
266
+ // The phone top bar (below 600 px, the same width test) names the detail too, so a reader says where it is.
264
267
  var label = $('section-name');
265
- if (label && detail && W.isPhone()) label.textContent = detail;
268
+ if (label && detail && railHidden()) label.textContent = detail;
266
269
  }
267
270
 
268
271
  document.addEventListener('click', function (e) {
@@ -286,6 +289,9 @@
286
289
  });
287
290
 
288
291
  W.onRoute(paintNavbar);
292
+ // 6.99.30: the server line moves the moment a request fails to reach the Mac or reaches it again (app.js noteServer),
293
+ // not on the five-minute check. Back up, the health read confirms it and refreshes what rides on it.
294
+ if (W.onServer) W.onServer(function (up) { setServer(up); if (up) checkServer(); });
289
295
  W.onShell(function () {
290
296
  Object.keys(FALLBACK).forEach(function (k) { setStory(k, FALLBACK[k], false); });
291
297
  setTimeout(function () { loadStories(); checkServer(); }, 2500);