@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
@@ -7,17 +7,52 @@
7
7
  --pv-claude: rgb(199, 115, 56);
8
8
  --pv-codex: rgb(26, 140, 122);
9
9
  --pv-cursor: rgb(107, 97, 219);
10
+ /* 6.99.30 (design QA, light mode): the provider badges' 9 px words, about 25% darker than the glyph colour, so they
11
+ read at 4.5:1 or better on their tint. Dark mode keeps the glyph colour (below). */
12
+ --pv-claude-ink: rgb(143, 83, 40);
13
+ --pv-codex-ink: rgb(20, 105, 92);
14
+ --pv-cursor-ink: rgb(80, 73, 164);
10
15
  --run-green: rgb(56, 145, 99);
11
16
  }
12
- :root[data-theme='dark'] .sx { --pv-claude: rgb(226, 146, 92); --pv-codex: rgb(72, 190, 168); --pv-cursor: rgb(158, 150, 245); --run-green: rgb(108, 207, 143); }
17
+ :root[data-theme='dark'] .sx { --pv-claude: rgb(226, 146, 92); --pv-codex: rgb(72, 190, 168); --pv-cursor: rgb(158, 150, 245); --run-green: rgb(108, 207, 143);
18
+ --pv-claude-ink: var(--pv-claude); --pv-codex-ink: var(--pv-codex); --pv-cursor-ink: var(--pv-cursor); }
13
19
  @media (prefers-color-scheme: dark) {
14
- :root:not([data-theme='light']) .sx { --pv-claude: rgb(226, 146, 92); --pv-codex: rgb(72, 190, 168); --pv-cursor: rgb(158, 150, 245); --run-green: rgb(108, 207, 143); }
20
+ :root:not([data-theme='light']) .sx { --pv-claude: rgb(226, 146, 92); --pv-codex: rgb(72, 190, 168); --pv-cursor: rgb(158, 150, 245); --run-green: rgb(108, 207, 143);
21
+ --pv-claude-ink: var(--pv-claude); --pv-codex-ink: var(--pv-codex); --pv-cursor-ink: var(--pv-cursor); }
15
22
  }
16
23
 
17
24
  /* ── header, views, clock ─────────────────────────────── */
18
25
  .sx-views { justify-content: space-between; margin: 10px 0 8px; }
19
26
  .sx-clock { margin-left: auto; }
20
27
  .sx-split { margin-top: 4px; }
28
+ /* Duo tables: the divider between the list and the reader (app.js sxSplitKit). Only where both show side by side. The
29
+ column it sits in replaces the split's 18 px gap; a dragged width is a share of the split, clamped so the list keeps
30
+ 240 px and the reader 380 px (app.js SX_LIST_MIN, SX_READER_MIN, SX_HANDLE_W), so a turned screen never squeezes either. */
31
+ .sx-handle, .sx-full-btn { display: none; }
32
+ @media (min-width: 760px) {
33
+ .split.sx-split { grid-template-columns: minmax(260px, 340px) 18px minmax(0, 1fr); column-gap: 0; }
34
+ .split.sx-split.sx-sized { grid-template-columns: clamp(240px, var(--sx-list-share, 30%), calc(100% - 398px)) 18px minmax(0, 1fr); }
35
+ .sx-handle {
36
+ display: flex; justify-content: center; align-items: center; position: sticky; top: calc(var(--safe-top) + 12px); align-self: start;
37
+ height: calc(100dvh - var(--safe-top) - 24px); cursor: col-resize; touch-action: none; user-select: none; -webkit-user-select: none;
38
+ -webkit-touch-callout: none; outline: none; z-index: 2;
39
+ }
40
+ /* The line is the split's own hairline; the grip shows where to take it. */
41
+ .sx-handle::after { content: ''; width: 4px; height: 36px; border-radius: 2px; background: var(--panel-border); transition: background 120ms ease, height 120ms ease; }
42
+ .sx-handle:hover::after, .sx-handle:focus-visible::after, .sx-dragging .sx-handle::after { background: var(--accent); height: 52px; }
43
+ .sx-handle:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); border-radius: 6px; }
44
+ /* A finger gets a 44 px target, 13 px over each pane's edge; a mouse keeps the 18 px column. */
45
+ @media (pointer: coarse) { .sx-handle::before { content: ''; position: absolute; top: 0; bottom: 0; left: -13px; right: -13px; } }
46
+ /* While dragging, nothing in the panes selects or takes the pointer. */
47
+ .sx-split.sx-dragging { user-select: none; -webkit-user-select: none; cursor: col-resize; }
48
+ .sx-split.sx-dragging > .split-list, .sx-split.sx-dragging > .reader { pointer-events: none; }
49
+ .sx-full-btn { display: inline-flex; margin-left: auto; flex: none; }
50
+ @media (pointer: coarse) { .sx-full-btn { min-height: 44px; } }
51
+ /* Full width: the reader takes the split; the list and the divider come back with Show list. */
52
+ .split.sx-split.sx-full.reading { grid-template-columns: minmax(0, 1fr); }
53
+ .sx-split.sx-full.reading > .split-list, .sx-split.sx-full.reading > .sx-handle { display: none; }
54
+ }
55
+ @media (prefers-reduced-motion: reduce) { .sx-handle::after { transition: none; } }
21
56
  @media (max-width: 599px) {
22
57
  .sx-views { gap: 2px 18px; }
23
58
  .sx-clock { margin-left: 0; }
@@ -41,7 +76,7 @@
41
76
  @media (max-width: 599px) {
42
77
  .sx-search { order: 0; flex: 1 1 auto; }
43
78
  .sx-search .cx-clear { min-height: 40px; padding: 0 2px; }
44
- .sx-select { min-height: 40px; height: 40px; font-size: 16px; background-position: calc(100% - 16px) 17px, calc(100% - 11px) 17px; }
79
+ .sx-select { background-position: calc(100% - 16px) 19px, calc(100% - 11px) 19px; }
45
80
  #sessions-recency { max-width: 128px; }
46
81
  .sx-ufilters .sx-select { flex: 1 1 150px; max-width: none; }
47
82
  .sx-utable .hp { display: none; }
@@ -62,12 +97,14 @@
62
97
  .sx-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font: 600 15px/1.3 var(--body); color: var(--ink); }
63
98
  .sx-snippet { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; margin-top: 3px; font-size: 12.5px; line-height: 1.4; color: var(--sub); }
64
99
  .sx-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 5px; min-width: 0; }
65
- .sx-badge { display: inline-flex; align-items: center; height: 17px; padding: 0 6px; border-radius: 9px; font: 700 9px/1 var(--mono); letter-spacing: 0.06em; color: var(--pv-claude); background: color-mix(in srgb, var(--pv-claude) 14%, transparent); white-space: nowrap; }
66
- .sx-badge.codex { color: var(--pv-codex); background: color-mix(in srgb, var(--pv-codex) 14%, transparent); }
67
- .sx-badge.cursor { color: var(--pv-cursor); background: color-mix(in srgb, var(--pv-cursor) 14%, transparent); }
100
+ .sx-badge { display: inline-flex; align-items: center; height: 17px; padding: 0 6px; border-radius: 9px; font: 700 9px/1 var(--mono); letter-spacing: 0.06em; color: var(--pv-claude-ink, var(--pv-claude)); background: color-mix(in srgb, var(--pv-claude) 14%, transparent); white-space: nowrap; }
101
+ .sx-badge.codex { color: var(--pv-codex-ink, var(--pv-codex)); background: color-mix(in srgb, var(--pv-codex) 14%, transparent); }
102
+ .sx-badge.cursor { color: var(--pv-cursor-ink, var(--pv-cursor)); background: color-mix(in srgb, var(--pv-cursor) 14%, transparent); }
68
103
  .sx-pinned { display: inline-flex; align-items: center; height: 17px; padding: 0 6px; border-radius: 9px; font: 700 8.5px/1 var(--mono); letter-spacing: 0.06em; color: var(--sub); background: color-mix(in srgb, var(--ink) 8%, transparent); }
69
104
  .sx-chip { font: 600 11.5px/1.3 var(--body); color: var(--sub); }
70
105
  .sx-chip.waiting { color: var(--amber); }
106
+ /* 6.99.30: a queued message the server refused or let expire (app.js notSentFor). */
107
+ .sx-chip.refused { color: var(--hot); }
71
108
  .sx-chip.running { color: var(--run-green); }
72
109
  .sx-when, .sx-ws { font-size: 11.5px; line-height: 1.3; color: var(--sub); }
73
110
  .sx-ws { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
@@ -116,7 +153,9 @@
116
153
  .sx-act-tool { flex: none; font: 400 11px/16px var(--mono); color: var(--sub); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
117
154
  .sx-act-prose { flex: none; margin-top: 2px; font-size: 13px; line-height: 16px; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
118
155
  .sx-act-fallback { height: calc(var(--act-state) + var(--act-prompt) + var(--act-tail) + 8px); margin: 0; padding-top: 6px; font-size: 12.5px; line-height: 1.4; color: var(--sub); overflow: hidden; box-sizing: border-box; }
119
- @media (max-width: 599px) { .sx-activity { --act-state: 38px; } .sx-act-stop.cx-btn { height: 36px; } }
156
+ /* 6.99.32 (QA Completionist W4): below 600 px every .cx-btn is at least 44 px (app.css), so the row that holds Stop is 46 px;
157
+ at 38 it clipped Stop's border top and bottom. The fallback's height follows the variable. */
158
+ @media (max-width: 599px) { .sx-activity { --act-state: 46px; } }
120
159
  .sx-note { font-size: 12px; color: var(--sub); margin: 0 2px 12px; }
121
160
 
122
161
  /* YOU and ASSISTANT cards (Control: card fill, a tinted hairline; sessions tint for you, green for the reply). */
@@ -153,9 +192,10 @@
153
192
  .sx-md a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
154
193
  .sx-md del { color: var(--sub); }
155
194
  .sx-table { overflow-x: auto; max-width: 100%; margin: 0 0 0.8em; }
156
- .sx-table table { border-collapse: collapse; font-size: 13.5px; line-height: 1.4; min-width: 100%; }
157
- .sx-table th, .sx-table td { border: 1px solid var(--hairline); padding: 6px 9px; text-align: left; vertical-align: top; }
158
- .sx-table th { font-weight: 600; background: var(--qopen); }
195
+ /* Duo tables: the grid lines, word wrapping and the held first column are control.css (table.tf, table.tf-grid). */
196
+ .sx-table table { font-size: 13.5px; line-height: 1.4; min-width: 100%; }
197
+ .sx-table th, .sx-table td { padding: 6px 9px; text-align: left; vertical-align: top; }
198
+ .sx-table th, .sx-table table.tf thead tr > :first-child { font-weight: 600; background: var(--qopen); }
159
199
 
160
200
  .sx-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 16px 0 4px; }
161
201
  .sx-foot-note { font-size: 11.5px; color: var(--tertiary); }
@@ -91,9 +91,10 @@
91
91
  Promise.all([
92
92
  soft(api('/api/decision-provider')), soft(api('/api/live-cues/v2/settings')), soft(api('/api/morning-brief')),
93
93
  soft(api('/api/domains')), soft(api('/api/agent-sessions/fences')), soft(api('/api/meeting-import/fireflies/status')),
94
- soft(api('/api/health')), soft(api('/api/drops/settings/p2')), soft(api('/api/today/brief/settings'))
94
+ soft(api('/api/health')), soft(api('/api/drops/settings/p2')), soft(api('/api/today/brief/settings')),
95
+ soft(api('/api/session-files/settings')), soft(api('/api/preview-folders/settings'))
95
96
  ]).then(function (r) {
96
- S.data = { dp: r[0], cues: r[1], mb: r[2], domains: r[3], fences: r[4], ff: r[5], health: r[6], p2: r[7], tb: r[8] };
97
+ S.data = { dp: r[0], cues: r[1], mb: r[2], domains: r[3], fences: r[4], ff: r[5], health: r[6], p2: r[7], tb: r[8], sf: r[9], pf: r[10] };
97
98
  S.state = 'ready';
98
99
  render();
99
100
  });
@@ -211,22 +212,68 @@
211
212
  if (!st) return '';
212
213
  var at = function (iso) { var d = new Date(iso); return isNaN(d) ? '' : d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); };
213
214
  if (st.kind === 'paused') return 'Paused after three failed tries. It tries again at ' + at(st.until) + '.';
214
- if (st.kind === 'daily_cap') return 'Today\u2019s ' + st.count + ' are used. It writes again tomorrow.';
215
+ // 7.0.5: the number is the "Summaries per day" setting (an older server sends only the count).
216
+ if (st.kind === 'daily_cap') { var n = typeof st.cap === 'number' ? st.cap : st.count; return 'Today\u2019s ' + n + (n === 1 ? ' summary is' : ' summaries are') + ' used. It writes again tomorrow.'; }
215
217
  if (st.kind === 'failed') return 'The last try failed at ' + at(st.at) + (st.code === 'timeout' ? ' (it took too long).' : st.code === 'unavailable' ? ' (Claude Code was not found on your Mac).' : '.');
216
218
  return '';
217
219
  }
220
+ // 6.99.35: "Show files and images your sessions make" (GET/POST /api/session-files/settings, lib/session-files-setting.ts).
221
+ // On by default. COS_WEB_SESSION_FILES=0 on the Mac forces it off; the row then says so and cannot be changed here.
222
+ function sessionFilesGroup(d) {
223
+ var sf = d && d.sf;
224
+ if (!sf || typeof sf.enabled !== 'boolean') return '';
225
+ var env = sf.source === 'env';
226
+ // QA 7.0.0: a saved setting the Mac could not read fails closed (off). The row says so; turning it on saves it again.
227
+ return group('Session files', '',
228
+ // 7.0.4: the switch also covers screenshots, images a session opened and images attached to prompts, so it says so.
229
+ row('Show session files, screenshots and images',
230
+ env ? 'Turned off on the Mac by the server environment (COS_WEB_SESSION_FILES=0).'
231
+ : sf.source === 'damaged' ? 'Couldn’t read the saved setting, so previews are off. Turn it on to save it again.'
232
+ : 'Lets this page show the files and images your sessions send you, the screenshots and images they open, and images attached to your prompts. Files stay on your Mac.',
233
+ toggle(sf.enabled, 'data-set="session-files"' + (env ? ' disabled' : ''), 'Show session files, screenshots and images')) +
234
+ previewFoldersRows(d.pf));
235
+ }
236
+ // 7.0.5 (preview engine, Miles decision 2b): extra folders a session's linked or sent files may open from, besides its
237
+ // own project folder (GET/POST /api/preview-folders/settings). Empty by default; each folder is listed by name and
238
+ // removed one at a time. The server refuses broad folders (home, Documents, Library, /) and says why.
239
+ function previewFoldersRows(pf) {
240
+ if (!pf || !Array.isArray(pf.folders)) return '';
241
+ var list = pf.folders.slice(0, 20);
242
+ var blurb = pf.source === 'damaged' ? 'Couldn’t read the saved list, so no extra folder counts. Add a folder to save it again.'
243
+ : list.length ? 'Sessions can also open files from these folders.' : 'None. Sessions open files only from their own project folder.';
244
+ return '<div class="cx-set-row cx-set-folders"><div class="cx-set-text"><span class="cx-set-name">Extra folders</span><span class="cx-set-desc" id="pf-blurb">' + esc(blurb) + '</span></div></div>' +
245
+ list.map(function (f, i) {
246
+ return '<div class="cx-set-row cx-set-folder"><div class="cx-set-text"><span class="cx-set-name pf-name">' + esc(f) + '</span></div>' +
247
+ '<button type="button" class="cx-btn quiet" data-set="pf-remove" data-index="' + i + '" aria-label="' + esc('Remove ' + f) + '">Remove</button></div>';
248
+ }).join('') +
249
+ (list.length < 20 ? '<form class="cx-set-row pf-add" id="pf-add"><label class="cx-set-text" for="pf-input"><span class="cx-set-desc">Add a project folder, like ~/cos-worktrees. Not your whole home or Documents folder.</span></label>' +
250
+ '<span class="pf-add-line"><input id="pf-input" type="text" inputmode="url" autocapitalize="off" autocorrect="off" spellcheck="false" placeholder="~/cos-worktrees" aria-label="Folder to add">' +
251
+ '<button type="submit" class="cx-btn" data-set="pf-add">Add</button></span></form>' : '');
252
+ }
253
+ function savePreviewFolders(next, done) {
254
+ api('/api/preview-folders/settings', { method: 'POST', body: { folders: next } }).then(function (v) {
255
+ if (S.data && v && Array.isArray(v.folders)) S.data.pf = v;
256
+ W.toast('Saved');
257
+ render();
258
+ if (done) done(true);
259
+ }, function (err) { say((err && err.message) || 'That folder was not saved.', true); if (done) done(false); });
260
+ }
218
261
  function categoryBody(cat) {
219
262
  var d = S.data || {};
220
263
  var out = '';
221
264
  if (cat.id === 'reach') return reachBody();
222
265
  if (cat.id === 'pet') return petBody() + onMac(cat.mac);
223
- // The Today summary (routes/today-brief.ts): Haiku on the Mac writes Today's top lines. Off unless switched on here or
266
+ if (cat.id === 'privacy') out += sessionFilesGroup(d);
267
+ // The Today summary (routes/today-brief.ts): Sonnet on the Mac writes Today's top lines (7.0.1; Haiku before). Off unless switched on here or
224
268
  // by COS_TODAY_BRIEF_LLM on the Mac, which wins when set.
225
269
  if (cat.id === 'general' && d.tb && typeof d.tb.enabled === 'boolean') {
226
270
  out += group('Today summary', '',
227
271
  row('Write the Today summary with a model', d.tb.source === 'env' ? 'Set on the Mac by the server environment.' :
228
- 'Haiku writes two or three sentences after the count of what needs you. It reads what Today shows (session titles and states, each session\u2019s latest reply, the questions sessions ask you, what needs you) and, on your Mac, each session\u2019s sub-agents and the Work cards in progress. It runs only while Today is open, only when something changed, never during a recording, at most every 10 minutes and 60 times a day.',
272
+ 'Sonnet writes two or three sentences after the count of what needs you. It reads what Today shows (session titles and states, each session\u2019s latest reply, the questions sessions ask you, what needs you) and, on your Mac, each session\u2019s sub-agents and the Work cards in progress. It runs only while Today is open, only when something changed, never during a recording, at most every 10 minutes and no more than Summaries per day allows. Refresh on Today asks for a new one at once, at most every 30 seconds.',
229
273
  toggle(d.tb.enabled, 'data-set="today-brief"' + (d.tb.source === 'env' ? ' disabled' : ''), 'Write the Today summary with a model')) +
274
+ // 7.0.5 "Summaries per day" (GET/PUT /api/today/brief/settings/cap), 1 to 100, default 100. Shown when the server has it.
275
+ (typeof d.tb.dailyCap === 'number' ? row('Summaries per day', 'At most this many new summaries a day, Refresh included. 1 to 100.',
276
+ '<input type="number" class="cx-set-num" id="today-brief-cap" data-set="today-brief-cap" min="1" max="100" step="1" inputmode="numeric" value="' + esc(String(d.tb.dailyCap)) + '" aria-label="Summaries per day">') : '') +
230
277
  (briefStatus(d.tb) ? '<p class="cx-set-blurb" id="today-brief-status">' + esc(briefStatus(d.tb)) + '</p>' : ''));
231
278
  }
232
279
  if (cat.id === 'ai' && d.dp) {
@@ -346,6 +393,25 @@
346
393
  .then(function () { W.toast(!on ? 'Live cues on' : 'Live cues off'); }, function (err) { t.setAttribute('aria-checked', String(on)); say(err.message, true); });
347
394
  return;
348
395
  }
396
+ if (act === 'session-files') {
397
+ var son = t.getAttribute('aria-checked') === 'true';
398
+ t.setAttribute('aria-checked', String(!son));
399
+ api('/api/session-files/settings', { method: 'POST', body: { enabled: !son } })
400
+ .then(function (v) {
401
+ if (S.data && v) S.data.sf = v;
402
+ W.toast(!son ? 'Session files and images on' : 'Session files and images off');
403
+ render();
404
+ }, function (err) { t.setAttribute('aria-checked', String(son)); say(err.message, true); });
405
+ return;
406
+ }
407
+ if (act === 'pf-remove') {
408
+ var cur = (S.data && S.data.pf && S.data.pf.folders) || [], idx = Number(t.getAttribute('data-index'));
409
+ if (!(idx >= 0 && idx < cur.length)) return;
410
+ t.disabled = true;
411
+ savePreviewFolders(cur.filter(function (_f, i) { return i !== idx; }), function (ok) { if (!ok) t.disabled = false; });
412
+ return;
413
+ }
414
+ if (act === 'pf-add') return; // the form's submit handler saves
349
415
  if (act === 'today-brief') {
350
416
  var ton = t.getAttribute('aria-checked') === 'true';
351
417
  t.setAttribute('aria-checked', String(!ton));
@@ -380,6 +446,15 @@
380
446
  }
381
447
  });
382
448
 
449
+ pane.addEventListener('submit', function (e) {
450
+ if (!e.target || e.target.id !== 'pf-add') return;
451
+ e.preventDefault();
452
+ var input = $('pf-input'), v = input ? input.value.trim() : '';
453
+ if (!v) { say('Type a folder, like ~/cos-worktrees.', true); return; }
454
+ var cur = (S.data && S.data.pf && S.data.pf.folders) || [];
455
+ savePreviewFolders(cur.concat([v]));
456
+ });
457
+
383
458
  // A gallery picture that will not load shows the pet's name alone, never a broken image.
384
459
  pane.addEventListener('error', function (e) {
385
460
  var img = e.target;
@@ -391,6 +466,22 @@
391
466
 
392
467
  // Number, time, day and steer fields save when they change (a number is clamped by the server, then shown as saved).
393
468
  pane.addEventListener('change', function (e) {
469
+ // 7.0.5 "Summaries per day": the server clamps to 1 to 100 and answers what it saved; the field shows that.
470
+ if (e.target && e.target.id === 'today-brief-cap') {
471
+ // QA 7.0.5: an emptied field (or letters, which a number field reads as '') is Number('') = 0, which the server
472
+ // clamps to 1. So an empty, non-numeric or below-1 value sends nothing, keeps the saved number and says why.
473
+ var capField = e.target, capRaw = String(capField.value == null ? '' : capField.value).trim(), wanted = Number(capRaw);
474
+ if (capRaw === '' || !isFinite(wanted) || wanted < 1) {
475
+ if (S.data && S.data.tb && typeof S.data.tb.dailyCap === 'number') capField.value = String(S.data.tb.dailyCap);
476
+ say('Type a number from 1 to 100.', true);
477
+ return;
478
+ }
479
+ api('/api/today/brief/settings/cap', { method: 'PUT', body: { dailyCap: wanted } }).then(function (v) {
480
+ if (v && typeof v.dailyCap === 'number') { capField.value = String(v.dailyCap); if (S.data && S.data.tb) S.data.tb.dailyCap = v.dailyCap; }
481
+ W.toast('Saved');
482
+ }, function (err) { say((err && err.message) || 'That did not save.', true); });
483
+ return;
484
+ }
394
485
  var f = e.target && e.target.closest ? e.target.closest('[data-p2]') : null;
395
486
  if (!f || f.getAttribute('data-set') === 'p2' || !S.data || !S.data.p2) return;
396
487
  var path = f.getAttribute('data-p2'), raw = f.value;
@@ -221,8 +221,7 @@
221
221
  #pane-speakers .sp-mrow { grid-template-columns: 32px minmax(0, 1fr) 12px; }
222
222
  #pane-speakers .sp-tools { width: 100%; }
223
223
  #pane-speakers .sp-search { max-width: none; flex-basis: 100%; }
224
- #pane-speakers .sp-search .field { min-height: 40px; font-size: 16px; }
225
- .sp-select select.field { min-height: 38px; font-size: 14px; background-position: calc(100% - 16px) 16px, calc(100% - 11px) 16px; }
224
+ .sp-select select.field { background-position: calc(100% - 16px) 19px, calc(100% - 11px) 19px; }
226
225
  #pane-speakers .sp-text { min-height: 36px; font-size: 14.5px; }
227
226
  #pane-speakers .sp-field { min-height: 40px; font-size: 16px; width: 100%; }
228
227
  #pane-speakers .cx-switch { gap: 12px; flex-wrap: wrap; }
@@ -242,11 +241,10 @@
242
241
  .sp-pplay { width: 36px; height: 36px; }
243
242
  .sp-pplay-space { width: 36px; }
244
243
  .sp-opt { min-height: 40px; font-size: 15px; }
245
- .sp-vacts .cx-btn { height: 36px; font-size: 14px; }
244
+ .sp-vacts .cx-btn { font-size: 14px; }
246
245
  .sp-leg { min-height: 30px; }
247
246
  .sp-racts { justify-content: flex-start; }
248
- .sp-rhead .cx-btn { height: 36px; }
249
- .sp-policy .field { min-height: 38px; font-size: 14px; background-position: calc(100% - 16px) 16px, calc(100% - 11px) 16px; }
247
+ .sp-policy .field { background-position: calc(100% - 16px) 19px, calc(100% - 11px) 19px; }
250
248
  }
251
249
  @media (min-width: 600px) {
252
250
  .sp-pills-in { display: none !important; }
@@ -107,7 +107,8 @@
107
107
  var off = new Error(OFFLINE); off.code = 'offline'; throw off;
108
108
  });
109
109
  }
110
- var OFFLINE = 'Could not reach your Mac. Check Tailscale on this phone and that the Mac is awake.';
110
+ // The one sentence Control Web says for a request that never reached the Mac (app.js COSWeb.unreachableCopy, 6.99.30).
111
+ var OFFLINE = W.unreachableCopy || 'Could not reach COS on your Mac. Check that it is awake and on Tailscale, then try again.';
111
112
  // The server answered, but not with JSON (a proxy page, a crash): say that, never that it stopped.
112
113
  function unreadable(r) { return new Error('The server answered ' + r.status + ' without a readable reply.'); }
113
114
  // A 401 hands the page to the shell's sign-in and stops every timer here, so nothing polls a signed-out session.
@@ -264,7 +265,9 @@
264
265
  namedVoices: vr ? int(vr.namedVoices) || 0 : null, humanTouched: vr ? vr.humanTouched === true : null
265
266
  };
266
267
  }
267
- function dateLine(m) { return [m.date, m.time ? clock12(m.time) : '', m.duration].filter(Boolean).join(' · '); }
268
+ // A row's day in plain words ("Today", "Nov 7"), never the ISO date (6.99.30).
269
+ function day(v) { return W.plainDay ? W.plainDay(v) : v; }
270
+ function dateLine(m) { return [day(m.date), m.time ? clock12(m.time) : '', m.duration].filter(Boolean).join(' · '); }
268
271
  function countsSummary(m) {
269
272
  var parts = [];
270
273
  if (m.topicCount > 0) parts.push(m.topicCount + ' topic' + s(m.topicCount));
@@ -579,7 +582,7 @@
579
582
  var message = str(r.body.message) || str(r.body.error) || (r.status === 200 ? 'Naming receipt ready' : 'Naming refused (' + r.status + ')');
580
583
  return { message: message, receipt: parseReceipt(r.body, r.status) };
581
584
  }, function (err) {
582
- if (err && err.code === 'offline') throw new Error(OFFLINE + ' Then refresh to review any interrupted naming before retrying.');
585
+ if (err && err.code === 'offline') throw new Error('Could not reach COS on your Mac. Check that it is awake and on Tailscale, then refresh to review any interrupted naming before retrying.');
583
586
  throw err;
584
587
  });
585
588
  }
@@ -2049,7 +2052,7 @@
2049
2052
  out += '<div class="cx-list sp-list">' + p.appearances.map(function (a) {
2050
2053
  return '<button type="button" class="cx-row sp-arow" data-sp="open-appearance" data-sid="' + esc(a.sessionId) + '">' +
2051
2054
  '<span class="cx-row-glyph' + (a.needsReview ? ' sp-amber' : '') + '">' + icon('speakers') + '</span><span class="cx-row-body">' +
2052
- '<span class="cx-row-title strong sp-one">' + esc(a.title) + '</span><span class="sp-mono">' + esc(a.date + ' · ' + a.segments + ' segments · ' + formatDuration(a.speakingMs)) + '</span></span>' +
2055
+ '<span class="cx-row-title strong sp-one">' + esc(a.title) + '</span><span class="sp-mono">' + esc([day(a.date), a.segments + ' segments', formatDuration(a.speakingMs)].filter(Boolean).join(' · ')) + '</span></span>' +
2053
2056
  '<span class="sp-match"><b>' + esc(a.observedMatch == null ? '–' : pct(a.observedMatch)) + '</b><small>' + (a.confirmedByHuman ? 'human confirmed' : a.needsReview ? 'review' : 'observed match') + '</small></span>' +
2054
2057
  '<span class="chev"></span></button>';
2055
2058
  }).join('') + '</div>';
@@ -118,14 +118,15 @@
118
118
  '<span class="cx-row-glyph"><svg class="icon"><use href="#i-threads"/></svg></span><span class="cx-row-body">' +
119
119
  '<span class="cx-row-title strong">' + esc(t.name || '(untitled)') + '</span>' +
120
120
  (subtitle(t) ? '<span class="cx-row-sub">' + esc(subtitle(t)) + '</span>' : '') +
121
- '<span class="cx-id">' + esc(t.id) + '</span></span><span class="chev"></span></button>';
121
+ // 6.99.30 (design QA): no raw id ("t1") on the row; the thread's own page still shows it, to copy.
122
+ '</span><span class="chev"></span></button>';
122
123
  }
123
124
  function hitRow(h) {
124
125
  return '<button type="button" class="cx-row" data-th="open" data-id="' + esc(h.id) + '">' +
125
126
  '<span class="cx-row-glyph"><svg class="icon"><use href="#i-threads"/></svg></span><span class="cx-row-body">' +
126
127
  '<span class="cx-row-title strong">' + esc(h.title || h.name || '(untitled)') + '</span>' +
127
128
  (h.snippet ? '<span class="cx-row-sub">' + esc(h.snippet) + '</span>' : '') +
128
- '<span class="cx-meta" style="margin-top:4px"><span class="cx-id" style="margin:0">' + esc(h.id) + '</span><span class="cx-cap">' + esc(matchLabel(h.match)) + '</span></span>' +
129
+ '<span class="cx-meta" style="margin-top:4px"><span class="cx-cap">' + esc(matchLabel(h.match)) + '</span></span>' +
129
130
  '</span><span class="chev"></span></button>';
130
131
  }
131
132
  function list() {
@@ -12,28 +12,35 @@
12
12
 
13
13
  /* The page behind does not scroll. Only the root: hidden on body too would make body a scroll container and unstick the rail. */
14
14
  html.ta-open { overflow: hidden; }
15
+ /* 6.99.30 (design QA): Today's own question card is drawn in the sheet; the one on the page under it waits. */
16
+ html.ta-open #sq-host { display: none; }
15
17
 
16
18
  .ta-layer {
17
- /* 6.99.29 (Miles's unfolded Duo, IMG_0503, 11/07 12:48): with the keyboard closed the layer is the whole fixed
18
- viewport, down to the physical bottom edge, whatever visualViewport reports. Sized from visualViewport.height it
19
- ended about 106 px short and Today showed below the backdrop and the sheet. The home indicator is cleared once, by
20
- the padding inside the sheet (.ta-foot, or .ta-body when there is no reply box), never by the layer or the sheet. */
21
- position: fixed; inset: 0; z-index: 45;
19
+ /* 6.99.30: the layer is the VISIBLE part of the page, keyboard up or down, in any browser: top and height from
20
+ visualViewport (today-actions.js applyFit, app.js viewportFit), the dynamic viewport where there is none. Whatever
21
+ covers the bottom (a keyboard, its strip, Comet's or any browser's bottom bar) stays below the reply box and Send.
22
+ Only the backdrop and the sheet's colour reach on down to the physical bottom (::before and .ta-sheet::after,
23
+ --ta-below plus the bleed), so no band of Today shows (IMG_0503). The home indicator is cleared once, by the padding
24
+ inside the sheet (.ta-foot, or the sheet when there is no reply box), never by the layer or the sheet. */
25
+ position: fixed; left: 0; right: 0; z-index: 45;
26
+ top: var(--ta-vt, 0px); height: var(--ta-vh, 100vh);
22
27
  --ta-bleed: 120px;
23
28
  --run-green: rgb(56, 145, 99);
24
29
  }
25
- /* The keyboard is up (today-actions.js applyFit): the layer is the visible viewport, so it never covers the reply box. */
26
- .ta-layer.ta-kb { top: var(--ta-vt, 0px); height: var(--ta-vh, 100dvh); }
30
+ /* Before the first reading (or with script failing), the dynamic viewport where the browser has it. A var() fallback
31
+ the browser cannot parse would void the whole height, so the dvh one sits behind @supports. */
32
+ @supports (height: 100dvh) { .ta-layer { height: var(--ta-vh, 100dvh); } }
27
33
  :root[data-theme='dark'] .ta-layer { --run-green: rgb(108, 207, 143); }
28
34
  @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) .ta-layer { --run-green: rgb(108, 207, 143); } }
29
35
  .ta-layer[hidden] { display: none; }
30
36
  .ta-scrim { position: absolute; inset: 0; background: rgba(23, 18, 14, 0.45); touch-action: none; animation: fade 160ms ease; }
31
- /* Past the bottom edge, in case Safari's fixed viewport ever stops short of it: the dim and the sheet run on below the
32
- screen, so no band of Today can show under them. Off with the keyboard up (the keyboard covers that edge).
37
+ /* Below the visible part, down to the physical bottom and past it: whatever lies there (a browser's bar the page shows
38
+ through, a keyboard, a fixed viewport that stops short) shows the dim and the sheet's own colour, never Today.
39
+ --ta-below is the layout viewport under the layer; the bleed covers a bottom edge reported short.
33
40
  QA 6.99.29: the dim's bleed is the LAYER's own pseudo-element, never the scrim, so it is visual only: a tap on that
34
41
  band lands on the layer (no data-ta, so nothing happens) instead of closing the overlay or reaching Today below. */
35
- .ta-layer:not(.ta-kb)::before { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: var(--ta-bleed); background: rgba(23, 18, 14, 0.45); touch-action: none; animation: fade 160ms ease; }
36
- .ta-layer:not(.ta-kb) .ta-sheet::after { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: var(--ta-bleed); background: var(--page); pointer-events: none; }
42
+ .ta-layer::before { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: calc(var(--ta-below, 0px) + var(--ta-bleed)); background: rgba(23, 18, 14, 0.45); touch-action: none; animation: fade 160ms ease; }
43
+ .ta-layer .ta-sheet::after { content: ''; position: absolute; left: 0; right: 0; top: 100%; height: calc(var(--ta-below, 0px) + var(--ta-bleed)); background: var(--page); pointer-events: none; }
37
44
 
38
45
  .ta-sheet {
39
46
  position: absolute; left: 0; right: 0; bottom: 0;
@@ -62,9 +69,9 @@ html.ta-open { overflow: hidden; }
62
69
  .ta-foot .status-line:empty { display: none; }
63
70
  .ta-foot .sx-box, .ta-foot .sx-park { margin: 0 0 10px; }
64
71
  /* No reply box (the session list, a question answered in its card): the sheet itself keeps the home indicator's strip,
65
- so the end of the list and the question card's footer stop above it and nothing scrolls under it. With the keyboard
66
- up the keyboard covers that strip. */
67
- .ta-layer:not(.ta-kb) .ta-sheet:has(> .ta-foot:empty), .ta-layer:not(.ta-kb) .ta-sheet:has(> .ta-foot[hidden]) { padding-bottom: var(--safe-bottom); }
72
+ so the end of the list and the question card's footer stop above it and nothing scrolls under it. With a keyboard or
73
+ its strip up, that covers the strip. */
74
+ .ta-layer:not(.ta-kb):not(.ta-kb-strip) .ta-sheet:has(> .ta-foot:empty), .ta-layer:not(.ta-kb):not(.ta-kb-strip) .ta-sheet:has(> .ta-foot[hidden]) { padding-bottom: var(--safe-bottom); }
68
75
 
69
76
  .ta-sub { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 0 0 8px; color: var(--sub); font-size: 14.5px; line-height: 1.4; }
70
77
  .ta-note { margin: 6px 0 0; color: var(--hot); font-size: 13.5px; line-height: 1.4; }
@@ -126,9 +133,9 @@ html.ta-open { overflow: hidden; }
126
133
  .ta-foot { padding-bottom: calc(var(--safe-bottom) + 14px); }
127
134
  }
128
135
  /* 6.99.29 (Miles's unfolded Duo, 11/07): the on-screen keyboard is up, at any width (today-actions.js applyFit marks the
129
- layer ta-kb when the visible viewport is 120 px or more shorter than the layout one). The sheet fills what is visible,
130
- the header folds to one compact row, the reply box stays pinned at the bottom just above the keyboard, and the
131
- conversation takes the rest and scrolls. The keyboard covers the home indicator, so the foot drops that inset. */
136
+ layer ta-kb when 120 px or more of the page is covered at the bottom). The sheet fills what is visible, the header
137
+ folds to one compact row, the reply box stays pinned at the bottom just above the keyboard, and the conversation
138
+ takes the rest and scrolls. The keyboard covers the home indicator, so the foot drops that inset. */
132
139
  .ta-layer.ta-kb .ta-sheet { top: 0; bottom: 0; max-height: none; overflow: hidden; }
133
140
  .ta-layer.ta-kb .ta-grip { display: none; }
134
141
  .ta-layer.ta-kb .ta-head { align-items: center; padding-top: calc(var(--safe-top) + 6px); padding-bottom: 4px; }
@@ -139,6 +146,9 @@ html.ta-open { overflow: hidden; }
139
146
  .ta-layer.ta-kb .ta-body { padding-bottom: 8px; }
140
147
  .ta-layer.ta-kb .ta-foot { padding-top: 8px; padding-bottom: 8px; }
141
148
  .ta-layer.ta-kb .ta-foot .composer textarea { max-height: 96px; }
149
+ /* 6.99.30: a hardware keyboard's strip with a field focused (ta-kb-strip): the header stays, the strip covers the home
150
+ indicator, so the reply box drops that inset. The layer already ends above the strip. */
151
+ .ta-layer.ta-kb-strip .ta-foot { padding-bottom: 8px; }
142
152
  /* Under about three lines left for the conversation: the "Working right now" note waits until the keyboard closes. */
143
153
  .ta-layer.ta-kb-tight .ta-foot .sx-park { display: none; }
144
154
  @keyframes ta-rise { from { transform: translateY(32px); opacity: 0.6; } }
@@ -160,8 +170,29 @@ html.ta-open { overflow: hidden; }
160
170
  /* Files with a reply or a note (11/07): the COS chat's own chips and clip (cos-chat.css .cc-chip, .cc-attach), placed in
161
171
  the reply box. The chips take their own row above the words; the clip sits beside Send. */
162
172
  .ta-composer { flex-wrap: wrap; }
163
- .ta-composer .ta-files { flex: 1 1 100%; padding: 2px 0 4px; }
173
+ /* 6.99.30 (canary BLOCKER): five files pushed the reply box and Send off the sheet at every size (the foot never
174
+ shrinks and the keyboard's sheet clips). The chips scroll inside a capped box, so the words and Send always fit: about
175
+ two chip rows with room, one with the keyboard up. */
176
+ .ta-composer .ta-files { flex: 1 1 100%; padding: 2px 0 4px; max-height: min(30vh, 132px); overflow-y: auto; overscroll-behavior: contain; }
177
+ .ta-layer.ta-kb .ta-composer .ta-files { max-height: 60px; }
164
178
  .ta-composer .ta-files.hidden { display: none; }
165
179
  .ta-clip:empty { display: none; }
166
180
  .ta-clip .cc-attach { height: 38px; width: 42px; border-radius: var(--radius-sm); }
181
+ /* 6.99.30 (canary): with the keyboard up a sticky question footer covered all but part of one option; it scrolls with
182
+ the card instead while typing. */
183
+ .ta-layer.ta-kb .ta-q .sq-foot, .ta-layer.ta-kb-strip .ta-q .sq-foot { position: static; }
184
+ /* The "Not sent" line for a refused queued message (today-actions.js loadNotSent). */
185
+ .ta-notsent:empty { display: none; }
186
+ .ta-notsent { margin: 4px 0 8px; }
167
187
  .ta-composer.dragging { outline: 2px dashed var(--accent); outline-offset: 4px; }
188
+ /* 7.0.1 live state: Refresh and the line beside it under Today's date (app.js todayRefresh, liveNoteText). The button
189
+ keeps the 44 px target of every link button; the line says Updating, Reconnecting, "As of 2 min ago" or the server's
190
+ summary limit, in the date's own size and colour. */
191
+ .today-live { display: flex; align-items: center; flex-wrap: wrap; gap: 0 12px; margin: 2px 0 0; min-height: 44px; }
192
+ .today-refresh { min-height: 44px; min-width: 44px; padding: 0 2px; font-size: 15px; }
193
+ .today-refresh:disabled { opacity: 0.55; cursor: default; }
194
+ .today-live-note { color: var(--sub); font-size: 14px; line-height: 1.3; }
195
+ .today-live-note:empty { display: none; }
196
+ .today-live-note.warn { color: var(--warn, var(--sub)); }
197
+ /* A working row's clock ticks every second: tabular figures so the line does not shimmer. */
198
+ .run-clock { font-variant-numeric: tabular-nums; }