@gotcos/glasses-server 6.99.29 → 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.
- package/CHANGELOG.md +58 -0
- package/package.json +1 -1
- package/server/web-control/app.css +40 -9
- package/server/web-control/app.js +245 -20
- package/server/web-control/control.css +12 -6
- package/server/web-control/index.html +1 -0
- package/server/web-control/panes/cos-chat.css +10 -4
- package/server/web-control/panes/cos-chat.js +1 -1
- package/server/web-control/panes/meetings.css +5 -2
- package/server/web-control/panes/meetings.js +1 -1
- package/server/web-control/panes/memories.css +1 -1
- package/server/web-control/panes/memories.js +3 -8
- package/server/web-control/panes/questions.css +20 -4
- package/server/web-control/panes/questions.js +102 -21
- package/server/web-control/panes/sessions.css +18 -7
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +38 -18
- package/server/web-control/panes/today-actions.js +176 -42
- package/server/web-control/panes/work.css +1 -1
- package/server/web-control/shell.js +3 -0
|
@@ -58,6 +58,19 @@
|
|
|
58
58
|
if (diff === 1) return 'tomorrow';
|
|
59
59
|
return d.toLocaleDateString(undefined, { weekday: 'long' });
|
|
60
60
|
}
|
|
61
|
+
// 6.99.30 (design QA): a day in a row, in plain words: "Today", else "Nov 7" ("Nov 7, 2025" in another year). Takes
|
|
62
|
+
// a YYYY-MM-DD (a meeting's date, read as that calendar day, never shifted by time zone) or a Date. Anything else comes
|
|
63
|
+
// back as it was.
|
|
64
|
+
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
65
|
+
function plainDay(v, now) {
|
|
66
|
+
var y, m, d;
|
|
67
|
+
if (v instanceof Date && isFinite(v.getTime())) { y = v.getFullYear(); m = v.getMonth(); d = v.getDate(); }
|
|
68
|
+
else { var p = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(v == null ? '' : v)); if (!p) return v == null ? '' : String(v); y = +p[1]; m = +p[2] - 1; d = +p[3]; }
|
|
69
|
+
if (m < 0 || m > 11 || d < 1 || d > 31) return String(v);
|
|
70
|
+
var n = now || new Date();
|
|
71
|
+
if (y === n.getFullYear() && m === n.getMonth() && d === n.getDate()) return 'Today';
|
|
72
|
+
return MONTHS[m] + ' ' + d + (y === n.getFullYear() ? '' : ', ' + y);
|
|
73
|
+
}
|
|
61
74
|
var PROVIDERS = { claude: 'Claude Code', codex: 'Codex', cursor: 'Cursor', glasses: 'Glasses', cos: 'COS' };
|
|
62
75
|
function providerLabel(p) { return PROVIDERS[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : 'Session'); }
|
|
63
76
|
function badge(p) { var cls = PROVIDERS[p] ? (p === 'cos' ? 'glasses' : p) : 'work'; return '<span class="badge ' + esc(cls) + '">' + esc(providerLabel(p)) + '</span>'; }
|
|
@@ -100,9 +113,12 @@
|
|
|
100
113
|
var limit = o.timeoutMs != null ? o.timeoutMs : (method === 'GET' ? API_READ_TIMEOUT_MS : 0);
|
|
101
114
|
var ctrl = limit && window.AbortController ? new AbortController() : null;
|
|
102
115
|
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, limit) : null;
|
|
116
|
+
var sentAt = Date.now();
|
|
103
117
|
return fetch(path, { method: method, headers: headers, credentials: 'same-origin', body: body, signal: ctrl ? ctrl.signal : undefined }).then(function (res) {
|
|
104
118
|
// Blob bodies keep their deadline after headers: a stalled download must release its queue slot.
|
|
105
119
|
if (timer && o.responseType !== 'blob') clearTimeout(timer);
|
|
120
|
+
sxNoteClock(res, sentAt);
|
|
121
|
+
noteServer(true);
|
|
106
122
|
if (res.status === 401 && path.indexOf('/api/web-session/') !== 0) { showAuth(); var gone = new Error('Signed out. Connect this browser again.'); gone.status = 401; throw gone; }
|
|
107
123
|
if (o.responseType === 'blob' && res.ok) return res.blob();
|
|
108
124
|
return res.text().then(function (text) {
|
|
@@ -126,14 +142,68 @@
|
|
|
126
142
|
}, function (err) {
|
|
127
143
|
if (timer) clearTimeout(timer);
|
|
128
144
|
if (err && err.name === 'AbortError' && ctrl && ctrl.signal.aborted) {
|
|
145
|
+
// 6.99.32 (QA 6.99.30 canary W1): a read the Mac never answered in time means it is not answering. A write has
|
|
146
|
+
// no deadline unless its caller set one, and a slow save is not a Mac that went away, so only reads say so.
|
|
147
|
+
if (method === 'GET' && !(SERVER.lastUpAt >= sentAt)) noteServer(false);
|
|
129
148
|
var late = new Error('Your Mac did not answer in ' + Math.round(limit / 1000) + ' seconds. Check that it is awake, then try again.');
|
|
130
149
|
late.code = 'timeout'; throw late;
|
|
131
150
|
}
|
|
151
|
+
// 6.99.30: the browser's own words for a request that never reached the Mac ("Failed to fetch" in Chrome and
|
|
152
|
+
// Comet, "Load failed" in Safari, "NetworkError..." in Firefox) never reach the page. Anything with no status
|
|
153
|
+
// that is not a timeout or an abort the caller asked for is the Mac out of reach, said once, here.
|
|
154
|
+
if (err && err.status == null && !err.code && err.name !== 'AbortError') { noteServer(false); throw unreachableError(err); }
|
|
132
155
|
throw err;
|
|
133
156
|
});
|
|
134
157
|
}
|
|
135
158
|
var API_READ_TIMEOUT_MS = 20000;
|
|
136
159
|
|
|
160
|
+
// ── what every request learns about the Mac (6.99.30) ──
|
|
161
|
+
// api() above and apiAnswer() (the Sessions reader's transport) share these; they sit here, between api() and the
|
|
162
|
+
// state, so a test that loads api() alone has them.
|
|
163
|
+
// 6.99.30: the one sentence for a request that never reached the Mac (api() and apiAnswer() below, and the panes with
|
|
164
|
+
// their own fetch: COSWeb.unreachableCopy). Never the browser's own "Failed to fetch" or "Load failed".
|
|
165
|
+
var UNREACHABLE_COPY = 'Could not reach COS on your Mac. Check that it is awake and on Tailscale, then try again.';
|
|
166
|
+
function unreachableError(cause) { var e = new Error(UNREACHABLE_COPY); e.code = 'unreachable'; e.cause = cause; return e; }
|
|
167
|
+
|
|
168
|
+
// 6.99.30 (QA: clock skew): the Mac's clock, as this page's offset from it. The session card's one state (sxTruth)
|
|
169
|
+
// weighs the Mac's own times (state_since, a row's activity) against moments this page stamped with its own clock (a
|
|
170
|
+
// gate answer, a Stop). A phone a minute behind made every Mac time look a minute newer, so a stale Idle beat a
|
|
171
|
+
// Working the page had just been told about. Each answer's Date header (whole seconds, read at the midpoint of the
|
|
172
|
+
// request) gives the offset; the answer with the shortest round trip in the last ten minutes wins, so one slow answer
|
|
173
|
+
// never moves it. Zero until the first answer. A missing or unreadable header changes nothing.
|
|
174
|
+
var SX_CLOCK = { skew: 0, rtt: Infinity, at: 0 };
|
|
175
|
+
var SX_CLOCK_KEEP_MS = 600000;
|
|
176
|
+
function sxNoteClock(res, sentAt) {
|
|
177
|
+
var got = Date.now(), h = null;
|
|
178
|
+
try { h = res && res.headers && typeof res.headers.get === 'function' ? res.headers.get('date') : null; } catch (_e) { h = null; }
|
|
179
|
+
var server = h ? Date.parse(h) : NaN;
|
|
180
|
+
if (!isFinite(server) || typeof sentAt !== 'number') return;
|
|
181
|
+
var rtt = Math.max(0, got - sentAt);
|
|
182
|
+
// The header drops the milliseconds: on average the Mac's moment is half a second after the stamped second.
|
|
183
|
+
var skew = Math.round(server + 500 - (sentAt + rtt / 2));
|
|
184
|
+
if (rtt <= SX_CLOCK.rtt || got - SX_CLOCK.at > SX_CLOCK_KEEP_MS) { SX_CLOCK.skew = skew; SX_CLOCK.rtt = rtt; SX_CLOCK.at = got; }
|
|
185
|
+
}
|
|
186
|
+
// 6.99.30 (QA: "Server connected" stayed green for five minutes after the Mac went away): every request says whether
|
|
187
|
+
// the Mac answered. A request that never reached it marks it down; any HTTP answer, whatever its status, marks it up.
|
|
188
|
+
// QA 6.99.30 (Skeptic): never a 502. Nothing sits in front of the Mac (direct HTTPS over Tailscale) and COS sends its
|
|
189
|
+
// own 502s (an unsupported Memories workspace, a bad Meetings Ask answer), so a 502 flapped the rail, the question
|
|
190
|
+
// cards and an open sheet down and up again. The rail's server line, Today's sheet and the question cards hear each change at once
|
|
191
|
+
// (COSWeb.onServer). null until the first answer.
|
|
192
|
+
// lastUpAt (6.99.32, QA Skeptic W-2): when the Mac last answered anything, on this page's clock. A read that timed out
|
|
193
|
+
// marks it down only if nothing has answered since that read went out (a slow image whose headers already came, a busy
|
|
194
|
+
// Mac whose question poll keeps answering), so a slow but alive Mac never flaps.
|
|
195
|
+
var SERVER = { up: null, hooks: [], lastUpAt: 0 };
|
|
196
|
+
function noteServer(up) {
|
|
197
|
+
up = !!up;
|
|
198
|
+
if (up) SERVER.lastUpAt = Date.now();
|
|
199
|
+
if (SERVER.up === up) return;
|
|
200
|
+
SERVER.up = up;
|
|
201
|
+
SERVER.hooks.slice().forEach(function (fn) { try { fn(up); } catch (err) { console.error(err); } });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** This page's moment `t` (its own Date.now()) on the Mac's clock. */
|
|
205
|
+
function sxServerTime(t) { return t == null ? t : t + SX_CLOCK.skew; }
|
|
206
|
+
|
|
137
207
|
// ── state ───────────────────────────────────────────────
|
|
138
208
|
var state = {
|
|
139
209
|
pane: 'today', serverName: '', tailnetUrl: '',
|
|
@@ -209,6 +279,17 @@
|
|
|
209
279
|
if (location.hash === hash) route(); else location.hash = hash;
|
|
210
280
|
}
|
|
211
281
|
function isPhone() { return !window.matchMedia('(min-width: 600px)').matches; }
|
|
282
|
+
/** A keyboard and a fine pointer are the way in (a Mac, a PC): Return sends. Touch screens type a new line. */
|
|
283
|
+
function keyboardFirst() { return !!(window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches); }
|
|
284
|
+
// Return in a text box sends only where a keyboard and a pointer are the way in (cos-chat.js keyboardFirst): never on
|
|
285
|
+
// a touch screen, whatever its width (6.99.30: the unfolded Duo's on-screen keyboard sent on Return), and never
|
|
286
|
+
// mid-composition (an IME's Return picks the word; keyCode 229 is Safari's composing key). Shift-Return is a new line.
|
|
287
|
+
// QA 6.99.30 (Skeptic): Cmd-Return or Ctrl-Return sends on every device, so a Bluetooth keyboard on the Duo or an iPad
|
|
288
|
+
// (a coarse pointer, so plain Return is a new line there) still has a send key.
|
|
289
|
+
function returnSends(e) {
|
|
290
|
+
if (!e || e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229 || !e.target || e.target.tagName !== 'TEXTAREA') return false;
|
|
291
|
+
return !!(e.metaKey || e.ctrlKey || keyboardFirst());
|
|
292
|
+
}
|
|
212
293
|
function route(forceChanged) {
|
|
213
294
|
var r = parseRoute();
|
|
214
295
|
var changedPane = forceChanged === true || r.pane !== state.pane || !document.querySelector('#pane-' + r.pane + ':not(.hidden)');
|
|
@@ -627,6 +708,7 @@
|
|
|
627
708
|
renderRunning(working, open);
|
|
628
709
|
if (!offline) renderPet(sessions);
|
|
629
710
|
paintNeeds();
|
|
711
|
+
renderWeather(welcome);
|
|
630
712
|
renderNext(welcome);
|
|
631
713
|
loadTodayIntake();
|
|
632
714
|
setBadge('sessions', working.length > 0);
|
|
@@ -955,18 +1037,32 @@
|
|
|
955
1037
|
var html = visible.map(needRowHtml).join('');
|
|
956
1038
|
if (shown.length > visible.length) html += '<button type="button" class="need-more" data-today-needs="all">Show ' + (shown.length - visible.length) + ' more</button>';
|
|
957
1039
|
if (m.later.length && !TODAY.showLater) html += '<button type="button" class="need-more" data-today-needs="later">' + esc(m.later.length + (m.later.length === 1 ? ' thing can' : ' things can') + ' wait until tomorrow. Show') + '</button>';
|
|
958
|
-
|
|
959
|
-
|
|
1040
|
+
// 6.99.30 (design QA): no empty line above a failed read (it left a 45 px gap), and one retry row, never one per
|
|
1041
|
+
// source: with the Mac not answering (sessions and Work both unread, or more than two sources) it says so once.
|
|
1042
|
+
if (!m.rows.length) html = m.failed.length ? '' : '<p class="card-empty">' + esc(m.settled ? 'Nothing is waiting on you.' : 'Reading sessions, Work, voices and memories.') + '</p>';
|
|
1043
|
+
if (m.failed.length) {
|
|
1044
|
+
var silent = TODAY.offline || m.failed.length > 2;
|
|
1045
|
+
html += '<button type="button" class="need-more need-failed" data-today-needs="retry">' + esc(silent ? 'Your Mac is not answering. Try again.' : 'Couldn’t read ' + listWords(m.failed) + '. Try again') + '</button>';
|
|
1046
|
+
}
|
|
960
1047
|
list.innerHTML = html;
|
|
961
1048
|
}
|
|
1049
|
+
// Weather sits under the date at the top left (Miles, 11/07), where the eye starts, not inside Next up.
|
|
1050
|
+
// It shows whether or not a meeting is next.
|
|
1051
|
+
function renderWeather(w) {
|
|
1052
|
+
var el = $('today-weather'); if (!el) return;
|
|
1053
|
+
var x = w && w.weather;
|
|
1054
|
+
if (!x || !x.temp) { el.classList.add('hidden'); el.textContent = ''; return; }
|
|
1055
|
+
var desc = String(x.desc || '').toLowerCase(), place = x.location ? String(x.location).split(',')[0] : '';
|
|
1056
|
+
el.textContent = x.temp + (desc ? ' and ' + desc : '') + (place ? ' in ' + place : '');
|
|
1057
|
+
el.classList.remove('hidden');
|
|
1058
|
+
}
|
|
962
1059
|
function renderNext(w) {
|
|
963
1060
|
var card = $('next-card');
|
|
964
1061
|
var n = w && w.nextEvent; var d = n && toDate(n.time);
|
|
965
1062
|
if (!d || !n.title) { card.classList.add('hidden'); return; }
|
|
966
|
-
var weather = w.weather && w.weather.temp ? w.weather.temp + ' and ' + String(w.weather.desc || '').toLowerCase() + (w.weather.location ? ' in ' + w.weather.location.split(',')[0] : '') : '';
|
|
967
1063
|
var dw = dayWord(d);
|
|
968
1064
|
$('next-body').innerHTML = '<span class="when">' + esc(clock(d)) + '</span><span><span class="what">' + esc(n.title) + '</span>' +
|
|
969
|
-
'<span class="sub" style="display:block">' + esc(
|
|
1065
|
+
'<span class="sub" style="display:block">' + esc(dw.charAt(0).toUpperCase() + dw.slice(1)) + '</span></span>';
|
|
970
1066
|
// Control's Next up (todayPrepButtons): Prep me opens the Prep composer in the COS chat (panes/cos-chat.js prepFor),
|
|
971
1067
|
// Open meeting opens Meetings. Without a server to answer, Prep me is not offered.
|
|
972
1068
|
var prep = !TODAY.offline && window.COSWeb && window.COSWeb.cosChat && window.COSWeb.cosChat.prepFor;
|
|
@@ -1275,6 +1371,7 @@
|
|
|
1275
1371
|
var meta = sxBadge(key.provider) +
|
|
1276
1372
|
(s.pinned ? '<span class="sx-pinned">PINNED</span>' : '') +
|
|
1277
1373
|
(label ? '<span class="sx-chip ' + st + '">' + esc(label) + '</span>' : '') +
|
|
1374
|
+
(notSentFor(key.provider, key.id) ? '<span class="sx-chip refused" title="' + esc(notSentFor(key.provider, key.id).line) + '">Not sent</span>' : '') +
|
|
1278
1375
|
(upd ? '<span class="sx-when">Updated ' + esc(sxShortDate(upd)) + '</span>' : '') +
|
|
1279
1376
|
(ambiguous[title] && opened ? '<span class="sx-opened" title="Another session on screen has the same name. This one was opened ' + esc(sxShortDate(opened)) + '.">Opened ' + esc(sxShortDate(opened)) + '</span>' : '') +
|
|
1280
1377
|
(s.project && String(s.project) !== title ? '<span class="sx-ws">' + esc(s.project) + '</span>' : '');
|
|
@@ -1551,18 +1648,49 @@
|
|
|
1551
1648
|
}
|
|
1552
1649
|
function sessionPath(provider, id, tail) { return '/api/agent-sessions/' + encodeURIComponent(provider) + '/' + encodeURIComponent(id) + (tail || ''); }
|
|
1553
1650
|
|
|
1651
|
+
// 6.99.30 (QA, held from Codex for .30): a queued message the server refused or let expire is said where the person
|
|
1652
|
+
// looks, not only in the Sessions reader: a "Not sent" line in Today's sheet for that session and a chip on its row in
|
|
1653
|
+
// the Sessions list. What the server's queue said last, per session, in this browser for a day; each read of the
|
|
1654
|
+
// queue replaces it, so a row the server drops clears the mark.
|
|
1655
|
+
var NOT_SENT_KEY = 'cos_web_not_sent', NOT_SENT_KEEP_MS = 86400000;
|
|
1656
|
+
function notSentRows(rows) { return (rows || []).filter(function (t) { return t && (t.status === 'refused' || t.status === 'expired'); }); }
|
|
1657
|
+
function notSentLine(t) {
|
|
1658
|
+
var why = String((t && t.reasonCopy) || (t && t.status === 'expired' ? 'the thread stayed busy too long.' : 'the session refused it.')).trim();
|
|
1659
|
+
return /^not sent\b/i.test(why) ? why : 'Not sent: ' + why.charAt(0).toLowerCase() + why.slice(1);
|
|
1660
|
+
}
|
|
1661
|
+
function noteNotSent(provider, id, rows) {
|
|
1662
|
+
var all = readStore(NOT_SENT_KEY), k = provider + '|' + id, now = Date.now(), list = notSentRows(rows);
|
|
1663
|
+
Object.keys(all).forEach(function (x) { if (!all[x] || now - (Number(all[x].at) || 0) > NOT_SENT_KEEP_MS) delete all[x]; });
|
|
1664
|
+
if (list.length) all[k] = { n: list.length, line: notSentLine(list[list.length - 1]).slice(0, 240), at: now }; else delete all[k];
|
|
1665
|
+
writeStore(NOT_SENT_KEY, all);
|
|
1666
|
+
}
|
|
1667
|
+
function notSentFor(provider, id) {
|
|
1668
|
+
var v = readStore(NOT_SENT_KEY)[provider + '|' + id];
|
|
1669
|
+
return v && Date.now() - (Number(v.at) || 0) <= NOT_SENT_KEEP_MS ? v : null;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1554
1672
|
// Like api(), but a 4xx refusal comes back as { status, body } instead of throwing, because
|
|
1555
1673
|
// these routes answer "busy", "changed" or "not now" with a body the person should read.
|
|
1674
|
+
// A timeout here stays the caller's (an abort: "may have saved", a probe that gives up), never a down mark; api()
|
|
1675
|
+
// reads and the question poll are what say a hung Mac is not answering (6.99.32).
|
|
1556
1676
|
function apiAnswer(path, opts) {
|
|
1557
1677
|
var o = opts || {};
|
|
1558
1678
|
var ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1559
1679
|
var timer = ctrl && o.timeoutMs ? setTimeout(function () { ctrl.abort(); }, o.timeoutMs) : null;
|
|
1560
1680
|
var headers = { 'X-COS-Web': '1' };
|
|
1561
1681
|
if (o.body !== undefined) headers['Content-Type'] = 'application/json';
|
|
1682
|
+
var sentAt = Date.now();
|
|
1562
1683
|
return fetch(path, { method: o.method || 'GET', headers: headers, credentials: 'same-origin', body: o.body !== undefined ? JSON.stringify(o.body) : undefined, signal: ctrl ? ctrl.signal : undefined })
|
|
1563
1684
|
.then(function (res) {
|
|
1685
|
+
sxNoteClock(res, sentAt);
|
|
1686
|
+
noteServer(true);
|
|
1564
1687
|
if (res.status === 401) { showAuth(); throw new Error('Signed out. Connect this browser again.'); }
|
|
1565
1688
|
return res.text().then(function (t) { var b = {}; try { b = t ? JSON.parse(t) : {}; } catch (_e) { b = {}; } return { status: res.status, body: b || {} }; });
|
|
1689
|
+
}, function (err) {
|
|
1690
|
+
// A timeout this call asked for stays an abort (its callers say "may have saved"); the rest never reached the Mac.
|
|
1691
|
+
if (err && err.name === 'AbortError') throw err;
|
|
1692
|
+
noteServer(false);
|
|
1693
|
+
throw unreachableError(err);
|
|
1566
1694
|
})
|
|
1567
1695
|
.finally(function () { if (timer) clearTimeout(timer); });
|
|
1568
1696
|
}
|
|
@@ -1737,7 +1865,7 @@
|
|
|
1737
1865
|
// the same time are ordered gate, stream, list, hook, stop: the hook's word wins a tie. Idle yields
|
|
1738
1866
|
// only to working seen more than SX_IDLE_GRACE_MS after it, the server's rule for the flush that
|
|
1739
1867
|
// trails a Stop (TRANSCRIPT_AFTER_IDLE_GRACE_MS).
|
|
1740
|
-
var SX_IDLE_GRACE_MS = 5000, SX_STOP_ECHO_MS = 8000, SX_GATE_RECHECK_MS = 20000;
|
|
1868
|
+
var SX_IDLE_GRACE_MS = 5000, SX_STOP_ECHO_MS = 8000, SX_GATE_RECHECK_MS = 20000, SX_PROBE_TIMEOUT_MS = 15000;
|
|
1741
1869
|
var SX_TIE_RANK = { gate: 0, stream: 1, list: 2, hook: 3, stop: 4 };
|
|
1742
1870
|
function sxMs(v) {
|
|
1743
1871
|
var t = v instanceof Date ? v.getTime() : typeof v === 'number' ? v : typeof v === 'string' && v ? Date.parse(v) : NaN;
|
|
@@ -1750,9 +1878,11 @@
|
|
|
1750
1878
|
if (f.agentState && SX_HOOK_WORD[f.agentState]) out.push({ word: SX_HOOK_WORD[f.agentState], at: sxMs(f.stateSince) || sxMs(f.statusAt) || 0, src: 'hook', seen: sxMs(f.stateSeenAt) });
|
|
1751
1879
|
else if (f.statusAt) out.push({ word: f.transport === 'working' ? 'Working' : f.transport === 'done' ? 'Done' : 'Idle', at: sxMs(f.statusAt), src: 'stream', seen: sxMs(f.transportSeenAt) });
|
|
1752
1880
|
if (r.list && r.list.at != null) out.push({ word: r.list.word, at: r.list.at, src: 'list', seen: sxMs(r.list.seen) });
|
|
1753
|
-
|
|
1881
|
+
// A gate answer and a Stop are this page's own moments: ranked on the Mac's clock (sxServerTime) against the Mac's
|
|
1882
|
+
// own times, and kept on this page's clock as `seen` for the echo window (6.99.30, QA: clock skew).
|
|
1883
|
+
if (r.gate && r.gate.busy && r.gate.at != null) out.push({ word: 'Working', at: sxServerTime(r.gate.at), src: 'gate', seen: r.gate.at });
|
|
1754
1884
|
var stop = sxMs(r.stoppedAt);
|
|
1755
|
-
if (stop !== null) out.push({ word: 'Idle', at: stop, src: 'stop', seen: stop });
|
|
1885
|
+
if (stop !== null) out.push({ word: 'Idle', at: sxServerTime(stop), src: 'stop', seen: stop });
|
|
1756
1886
|
return out;
|
|
1757
1887
|
}
|
|
1758
1888
|
/** { word, at, src }: the one state the whole card shows. Null before any signal. */
|
|
@@ -1796,9 +1926,12 @@
|
|
|
1796
1926
|
// Arrival is the first time this page saw THIS word at THIS time: a re-read of the same row is not news.
|
|
1797
1927
|
r.list = { word: word, at: at != null ? at : 0, seen: was && was.word === word && was.at === (at != null ? at : 0) && was.seen != null ? was.seen : Date.now() };
|
|
1798
1928
|
}
|
|
1799
|
-
/** A gate busy answer that decides the card is re-asked
|
|
1800
|
-
*
|
|
1801
|
-
*
|
|
1929
|
+
/** A gate busy answer that decides the card is re-asked while the card shows it: 20 s after the last attempt, and
|
|
1930
|
+
* each failed attempt doubles the wait, up to 16 times (QA 6.99.29: a failing probe was re-asked every second).
|
|
1931
|
+
* Every attempt is stamped (gate.triedAt) and ends within SX_PROBE_TIMEOUT_MS (6.99.30: before that a probe that
|
|
1932
|
+
* never answered left r.probing set, and the gate was never asked again). So a busy gate stands only while the
|
|
1933
|
+
* server keeps answering busy, or keeps not answering, and a fresher signal (a Stop, the hook going idle more than
|
|
1934
|
+
* the grace after it) still outranks it. `at` stays the time of the last ANSWER: a failure is no evidence of busy. */
|
|
1802
1935
|
function sxGateStale(r) {
|
|
1803
1936
|
var t = sxTruth(r);
|
|
1804
1937
|
if (!(t && t.src === 'gate' && r.gate) || r.probing) return false;
|
|
@@ -1830,7 +1963,9 @@
|
|
|
1830
1963
|
var t = r ? sxTruth(r) : null;
|
|
1831
1964
|
var since = t && t.at ? new Date(t.at) : f.stateSince || f.lastEventAt;
|
|
1832
1965
|
if (!since) return '';
|
|
1833
|
-
|
|
1966
|
+
// QA 6.99.30 (Skeptic): every moment here is on the Mac's clock (the truth's own time, or the Mac's state_since), so
|
|
1967
|
+
// now is too; a phone a minute behind read "0s" for a minute.
|
|
1968
|
+
var s = Math.max(0, Math.floor((sxServerTime(Date.now()) - since.getTime()) / 1000));
|
|
1834
1969
|
if (s < 60) return s + 's';
|
|
1835
1970
|
if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
|
|
1836
1971
|
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
|
@@ -1840,6 +1975,7 @@
|
|
|
1840
1975
|
if (reason === 'http_503') return 'The server has no room for another live feed right now; showing the stored turns.';
|
|
1841
1976
|
if (reason === 'reconnecting') return 'Reconnecting to the live feed…';
|
|
1842
1977
|
if (reason === 'stream_lost') return 'The live feed stopped answering; showing the stored turns. Reopen the session to try again.';
|
|
1978
|
+
if (reason === 'unreachable') return 'Your Mac is not answering. This is the last state it sent.';
|
|
1843
1979
|
return 'Live feed unavailable (' + reason + '); showing the stored turns.';
|
|
1844
1980
|
}
|
|
1845
1981
|
// The block keeps one size whatever it holds (/qa: the turns below moved while Miles read, as tool
|
|
@@ -1975,7 +2111,8 @@
|
|
|
1975
2111
|
var waiting = rows.filter(function (t) { return t.status === 'waiting' || t.status === 'queued' || t.status === 'pending'; });
|
|
1976
2112
|
// QA 6.99.29: a queued message the server refused (or let expire) stays in view with the server's reason,
|
|
1977
2113
|
// instead of vanishing from the line.
|
|
1978
|
-
var notSent = rows
|
|
2114
|
+
var notSent = notSentRows(rows);
|
|
2115
|
+
if (q.status === 200) noteNotSent(provider, id, rows);
|
|
1979
2116
|
if (q.status === 200 && r.queued !== waiting.length) { r.queued = waiting.length; paintActivity(r); }
|
|
1980
2117
|
if (!waiting.length && !notSent.length) { box.innerHTML = ''; return; }
|
|
1981
2118
|
box.innerHTML = '<div class="sx-queue">' + (waiting.length ? '<p class="sx-queue-head">' + (waiting.length === 1 ? '1 follow-up queued. It sends when this session is available.' : waiting.length + ' follow-ups queued. They send in order when this session is available.') + '</p>' : '') +
|
|
@@ -1986,7 +2123,7 @@
|
|
|
1986
2123
|
}).join('') +
|
|
1987
2124
|
notSent.map(function (t) {
|
|
1988
2125
|
return '<div class="sx-queue-row sx-queue-refused"><span class="sx-queue-state">Not sent</span><span class="sx-queue-text">' + esc(t.preview || t.prompt || 'Message') +
|
|
1989
|
-
'<span class="sx-queue-reason">' + esc(
|
|
2126
|
+
'<span class="sx-queue-reason">' + esc(notSentLine(t)) + '</span></span></div>';
|
|
1990
2127
|
}).join('') + '</div>';
|
|
1991
2128
|
}).catch(function () {});
|
|
1992
2129
|
}
|
|
@@ -2000,7 +2137,7 @@
|
|
|
2000
2137
|
// Every attempt is stamped; a failed one backs the next off (sxGateStale). A gate answer resets both.
|
|
2001
2138
|
if (r.gate) r.gate.triedAt = Date.now();
|
|
2002
2139
|
var failed = function () { if (r.gate) r.gate.failures = (r.gate.failures || 0) + 1; };
|
|
2003
|
-
return apiAnswer(sessionPath(r.provider, r.id, '/attachability')).then(function (a) {
|
|
2140
|
+
return apiAnswer(sessionPath(r.provider, r.id, '/attachability'), { timeoutMs: SX_PROBE_TIMEOUT_MS }).then(function (a) {
|
|
2004
2141
|
if (state.reading !== r && !r.view) return;
|
|
2005
2142
|
if (a.status === 404) { failed(); r.verdict = { attachable: false, reason: 'route_absent', reasonCopy: 'Update the COS server to continue a thread from here.', ownerCount: 0 }; viewOf(r).verdict(r); return; }
|
|
2006
2143
|
var b = a.body || {};
|
|
@@ -4624,7 +4761,7 @@
|
|
|
4624
4761
|
.catch(function (err) {
|
|
4625
4762
|
// The server names what failed (wrong token, wrong code, the glasses code, lockout).
|
|
4626
4763
|
// No status means the request never reached COS (the browser's own "Load failed").
|
|
4627
|
-
showAuth(err && err.status ? err.message :
|
|
4764
|
+
showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
|
|
4628
4765
|
});
|
|
4629
4766
|
} else if (f.id === 'session-form') sendSessionTurn(false);
|
|
4630
4767
|
else if (f.id === 'sessions-search-form') { $('sessions-q').blur(); searchSessions(); }
|
|
@@ -4673,7 +4810,7 @@
|
|
|
4673
4810
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openAsk(); return; }
|
|
4674
4811
|
if (e.key === 'Escape' && !$('ask-sheet').classList.contains('hidden')) { closeAsk(); return; }
|
|
4675
4812
|
if (e.key === 'Escape' && !$('section-menu').classList.contains('hidden')) { closeMenu(); $('section-btn').focus(); return; }
|
|
4676
|
-
if (e
|
|
4813
|
+
if (returnSends(e)) { e.preventDefault(); var form = e.target.closest('form'); if (form) form.requestSubmit(); }
|
|
4677
4814
|
});
|
|
4678
4815
|
document.querySelectorAll('[data-mode]').forEach(function (b) {
|
|
4679
4816
|
b.addEventListener('click', function () {
|
|
@@ -4684,6 +4821,17 @@
|
|
|
4684
4821
|
});
|
|
4685
4822
|
window.addEventListener('hashchange', route);
|
|
4686
4823
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
4824
|
+
// 6.99.30: back from the browser's back-forward cache (a swipe back, the app switcher) the open reader's live stream
|
|
4825
|
+
// is closed and the page's last reading is however old the visit was. Open the stream again and read the turns.
|
|
4826
|
+
window.addEventListener('pageshow', function (e) {
|
|
4827
|
+
if (!e || !e.persisted) return;
|
|
4828
|
+
var r = state.reading;
|
|
4829
|
+
if (r && r.provider && r.id) {
|
|
4830
|
+
if (state.stream) { try { state.stream.close(); } catch (_e) {} state.stream = null; }
|
|
4831
|
+
watchSession(r.provider, r.id); refreshTurns(r.provider, r.id);
|
|
4832
|
+
}
|
|
4833
|
+
if (state.pane === 'today' && !$('app-shell').classList.contains('hidden')) loadToday();
|
|
4834
|
+
});
|
|
4687
4835
|
document.addEventListener('visibilitychange', function () {
|
|
4688
4836
|
if (document.hidden || $('app-shell').classList.contains('hidden')) return;
|
|
4689
4837
|
// Coming back to the page refreshes Today in place. Other panes keep what is on screen:
|
|
@@ -4724,10 +4872,80 @@
|
|
|
4724
4872
|
});
|
|
4725
4873
|
}
|
|
4726
4874
|
|
|
4875
|
+
// ── the visible viewport, read one way (6.99.30) ──────
|
|
4876
|
+
// Today's session sheet (panes/today-actions.js) and the question card's footer on Today (panes/questions.js) both
|
|
4877
|
+
// place things above whatever covers the bottom of the page: an on-screen keyboard, a hardware keyboard's strip, or a
|
|
4878
|
+
// browser's own bottom bar (Comet, Chrome, Safari's, whichever: nothing here knows any browser's bar). Only what the
|
|
4879
|
+
// browser reports is used: window.visualViewport's height, offsetTop and scale against the layout viewport.
|
|
4880
|
+
// layoutH the layout viewport's height. Read whenever the page is not pinch-zoomed (then innerHeight is the layout
|
|
4881
|
+
// viewport everywhere) and kept while it is, since some browsers report the zoomed size there.
|
|
4882
|
+
// inset what covers the bottom, in unzoomed CSS px: layoutH less the visible height times the zoom. A pinch
|
|
4883
|
+
// zoom alone is no inset (half the height at 2x is all of the page).
|
|
4884
|
+
// top, height the box to fill, in layout px. Unzoomed it is the visible viewport itself (offsetTop is where a
|
|
4885
|
+
// browser panned the page to keep a focused field in view). Zoomed it is the layout viewport less the
|
|
4886
|
+
// inset, so a pinch zoom magnifies the sheet instead of shrinking its layout into the zoomed window.
|
|
4887
|
+
// below what of the layout viewport lies under that box: the backdrop alone reaches down over it.
|
|
4888
|
+
// No visualViewport (old browsers): the whole layout viewport, nothing covered.
|
|
4889
|
+
var VP = { layoutH: 0 };
|
|
4890
|
+
function viewportFit() {
|
|
4891
|
+
var vv = window.visualViewport, de = document.documentElement;
|
|
4892
|
+
var inner = window.innerHeight || (de && de.clientHeight) || 0;
|
|
4893
|
+
var scale = vv && vv.scale > 0 ? vv.scale : 1, zoomed = scale > 1.01;
|
|
4894
|
+
if (!zoomed || !VP.layoutH) VP.layoutH = zoomed ? Math.max(inner, (de && de.clientHeight) || 0) : inner;
|
|
4895
|
+
var H = VP.layoutH;
|
|
4896
|
+
if (!vv || !(vv.height > 0)) return { layoutH: H, top: 0, height: H, inset: 0, below: 0, scale: 1, zoomed: false, vv: false };
|
|
4897
|
+
var inset = Math.max(0, Math.round(H - vv.height * scale));
|
|
4898
|
+
var top, height;
|
|
4899
|
+
if (!zoomed) { top = Math.max(0, Math.round(vv.offsetTop || 0)); height = Math.min(Math.round(vv.height), H); }
|
|
4900
|
+
else { height = H - inset; top = Math.min(Math.max(0, Math.round(vv.offsetTop || 0)), inset); }
|
|
4901
|
+
if (top + height > H) top = Math.max(0, H - height);
|
|
4902
|
+
return { layoutH: H, top: top, height: height, inset: inset, below: Math.max(0, H - top - height), scale: scale, zoomed: zoomed, vv: true };
|
|
4903
|
+
}
|
|
4904
|
+
|
|
4905
|
+
// ── layout breadcrumbs (6.99.30) ──────────────────────
|
|
4906
|
+
// When a sheet lands wrong on a phone nobody here holds, the numbers have to come back on their own. A breadcrumb
|
|
4907
|
+
// goes to the server's existing client diagnostics log (POST /api/diag/client, routes/diag.ts: 10 MB with one
|
|
4908
|
+
// rotation, 30 lines per 10 s per client) when an overlay opens and each time its keyboard state changes. Numbers and
|
|
4909
|
+
// a browser family only: no address, no text, no ids, never the user agent string itself. At most one identical
|
|
4910
|
+
// line per 2 s and LAYOUT_CRUMB_MAX a page load, fire and forget.
|
|
4911
|
+
var LAYOUT_CRUMB_MAX = 40, LAYOUT_CRUMB_GAP_MS = 2000;
|
|
4912
|
+
var crumbs = { sent: 0, last: '', lastAt: 0 };
|
|
4913
|
+
/** The browser family, from the UA string, for the breadcrumb. Comet first: it says Chrome or Safari too. */
|
|
4914
|
+
function uaClass(ua) {
|
|
4915
|
+
ua = String(ua == null ? (navigator && navigator.userAgent) || '' : ua);
|
|
4916
|
+
var ios = /iPhone|iPad|iPod/.test(ua) || (/Macintosh/.test(ua) && typeof navigator !== 'undefined' && navigator.maxTouchPoints > 1);
|
|
4917
|
+
var android = /Android/.test(ua);
|
|
4918
|
+
var os = ios ? 'ios' : android ? 'android' : /Mac OS X|Macintosh/.test(ua) ? 'mac' : /Windows/.test(ua) ? 'windows' : /Linux|CrOS/.test(ua) ? 'linux' : 'other';
|
|
4919
|
+
var hints = typeof navigator !== 'undefined' && navigator.userAgentData && navigator.userAgentData.brands ? navigator.userAgentData.brands.map(function (b) { return b && b.brand; }).join(' ') : '';
|
|
4920
|
+
var brand = /Comet\//i.test(ua) || /\bComet\b/i.test(ua) || /\bComet\b/i.test(hints) ? 'comet'
|
|
4921
|
+
: /CriOS\//.test(ua) ? 'crios' : /FxiOS\//.test(ua) ? 'firefox' : /EdgiOS\/|Edg\//.test(ua) ? 'edge'
|
|
4922
|
+
: /Firefox\//.test(ua) ? 'firefox' : /Chrome\/|Chromium\//.test(ua) ? 'chrome'
|
|
4923
|
+
: /Safari\//.test(ua) ? 'safari' : 'other';
|
|
4924
|
+
return { os: os, brand: brand };
|
|
4925
|
+
}
|
|
4926
|
+
/** One breadcrumb: `event` (open, kb), the viewport as the browser reports it, and the overlay's own state. */
|
|
4927
|
+
function layoutCrumb(event, extra) {
|
|
4928
|
+
try {
|
|
4929
|
+
var vv = window.visualViewport, de = document.documentElement, ua = uaClass();
|
|
4930
|
+
var r = function (n) { return typeof n === 'number' && isFinite(n) ? Math.round(n * 100) / 100 : null; };
|
|
4931
|
+
var o = (screen && screen.orientation && screen.orientation.type) || (typeof window.orientation === 'number' ? 'deg' + window.orientation : '');
|
|
4932
|
+
var data = { what: String(event || ''), ua: ua.brand, os: ua.os, iw: r(window.innerWidth), ih: r(window.innerHeight), ch: r(de && de.clientHeight),
|
|
4933
|
+
vvh: vv ? r(vv.height) : null, vvw: vv ? r(vv.width) : null, vvt: vv ? r(vv.offsetTop) : null, vvs: vv ? r(vv.scale) : null,
|
|
4934
|
+
orient: String(o).slice(0, 24), coarse: !!(window.matchMedia && window.matchMedia('(pointer: coarse)').matches) };
|
|
4935
|
+
var x = extra || {};
|
|
4936
|
+
Object.keys(x).forEach(function (k) { var v = x[k]; if (/^[a-z][a-zA-Z0-9]{0,15}$/.test(k) && (typeof v === 'number' || typeof v === 'boolean' || (typeof v === 'string' && v.length <= 24))) data[k] = typeof v === 'number' ? r(v) : v; });
|
|
4937
|
+
var key = JSON.stringify(data), now = Date.now();
|
|
4938
|
+
if (crumbs.sent >= LAYOUT_CRUMB_MAX || (key === crumbs.last && now - crumbs.lastAt < LAYOUT_CRUMB_GAP_MS)) return false;
|
|
4939
|
+
crumbs.sent++; crumbs.last = key; crumbs.lastAt = now;
|
|
4940
|
+
api('/api/diag/client', { method: 'POST', body: { ts: now, level: 'info', event: 'web.layout', data: data }, timeoutMs: 5000 }).catch(function () {});
|
|
4941
|
+
return true;
|
|
4942
|
+
} catch (_e) { return false; }
|
|
4943
|
+
}
|
|
4944
|
+
|
|
4727
4945
|
// ── the shared kit for pane modules (panes/*.js) ───────
|
|
4728
4946
|
window.COSWeb = {
|
|
4729
4947
|
$: $, esc: esc, md: md, plain: plain, api: api, toast: toast, ago: ago, clock: clock, toDate: toDate,
|
|
4730
|
-
plural: plural, countWord: countWord, dayWord: dayWord, providerLabel: providerLabel, domainLabel: domainLabel,
|
|
4948
|
+
plural: plural, countWord: countWord, dayWord: dayWord, plainDay: plainDay, providerLabel: providerLabel, domainLabel: domainLabel,
|
|
4731
4949
|
skeletonRows: skeletonRows, emptyBox: emptyBox, setStatus: setStatus, autoGrow: autoGrow,
|
|
4732
4950
|
readStore: readStore, writeStore: writeStore, copyText: copyText, isPhone: isPhone,
|
|
4733
4951
|
state: state, titles: TITLES, go: go, parseRoute: parseRoute, goBack: goBack,
|
|
@@ -4747,7 +4965,13 @@
|
|
|
4747
4965
|
// Control's session state (the Sessions pane's sxState) and warm-up test, so the rail's
|
|
4748
4966
|
// "N waiting on you, N working" counts exactly what the pane lists.
|
|
4749
4967
|
sessionState: function (s) { return sxState(s); },
|
|
4750
|
-
isKeepWarm: function (s) { return sxKeepWarm(s); }
|
|
4968
|
+
isKeepWarm: function (s) { return sxKeepWarm(s); },
|
|
4969
|
+
// 6.99.30: the visible viewport read one way, layout breadcrumbs, the one unreachable sentence, Return-sends.
|
|
4970
|
+
viewportFit: viewportFit, layoutCrumb: layoutCrumb, uaClass: uaClass, unreachableCopy: UNREACHABLE_COPY, keyboardFirst: keyboardFirst,
|
|
4971
|
+
serverUp: function () { return SERVER.up; },
|
|
4972
|
+
// Panes with their own fetch (the questions poll) say whether the Mac answered, so a "down" mark never outlives it.
|
|
4973
|
+
noteServer: function (up) { noteServer(up); },
|
|
4974
|
+
onServer: function (fn) { SERVER.hooks.push(fn); }
|
|
4751
4975
|
};
|
|
4752
4976
|
// Today as a place to act (panes/today-actions.js, Miles 11/06): what the Today cards show, a repaint after a
|
|
4753
4977
|
// decision, and the Sessions reader's own pieces, so the overlay sends and reads through the reader's code.
|
|
@@ -4784,6 +5008,7 @@
|
|
|
4784
5008
|
newKey: newTurnKey,
|
|
4785
5009
|
probe: function (r) { return sxProbeAttach(r); },
|
|
4786
5010
|
path: sessionPath, answer: apiAnswer,
|
|
5011
|
+
notSent: { rows: notSentRows, line: notSentLine, note: noteNotSent, get: notSentFor },
|
|
4787
5012
|
imageHtml: readerImagesHtml, imagesMount: readerImagesMount, imagesStop: readerImagesStop,
|
|
4788
5013
|
feed: { fresh: newFeed, apply: applyFeed, word: sxFeedWord, detail: sxFeedDetail, tool: sxToolLine, elapsed: sxElapsed, fallback: sxFallbackLine,
|
|
4789
5014
|
truth: sxTruth, settle: sxSettle, noteList: sxNoteList, noteGate: sxNoteGate, gateStale: sxGateStale, promptLine: sxPromptLine, parkNoteShows: sxParkNoteShows },
|
|
@@ -4799,7 +5024,7 @@
|
|
|
4799
5024
|
history.replaceState({}, document.title, location.pathname + location.hash);
|
|
4800
5025
|
api('/api/web-session/login', { method: 'POST', body: { code: linkCode } }).then(showShell)
|
|
4801
5026
|
.catch(function (err) {
|
|
4802
|
-
showAuth(err && err.status ? err.message :
|
|
5027
|
+
showAuth(err && err.status ? err.message : UNREACHABLE_COPY);
|
|
4803
5028
|
var box = $('auth-code'); if (box) box.value = linkCode;
|
|
4804
5029
|
});
|
|
4805
5030
|
return;
|
|
@@ -4811,7 +5036,7 @@
|
|
|
4811
5036
|
.catch(function (err) { showAuth(err && err.status ? err.message : 'That sign-in link did not work. Get a new one from COS Control on your Mac or the COS Glasses app, or use a code or the token.'); });
|
|
4812
5037
|
return;
|
|
4813
5038
|
}
|
|
4814
|
-
api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(
|
|
5039
|
+
api('/api/web-session/status').then(function (st) { if (st && st.authenticated) showShell(st); else showAuth(); }).catch(function () { showAuth(UNREACHABLE_COPY); });
|
|
4815
5040
|
}
|
|
4816
5041
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
|
|
4817
5042
|
})();
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
:root {
|
|
8
8
|
--tint: var(--accent);
|
|
9
|
-
|
|
9
|
+
/* 6.99.30 (design QA): #8b8378 measured 3.29:1 on the light page; this reads at 4.5:1 or better. Dark is unchanged. */
|
|
10
|
+
--tertiary: #6e675d;
|
|
10
11
|
--gold: #b98a3e;
|
|
11
12
|
--green: #2f7d4f;
|
|
12
13
|
--amber: #a8641c;
|
|
@@ -64,8 +65,9 @@
|
|
|
64
65
|
.rail-settings { min-height: 40px; }
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
/* Full rail (Control's at window width 1100 and up
|
|
68
|
-
|
|
68
|
+
/* Full rail (Control's at window width 1100 and up, on a screen tall enough for it: the same clause as app.css's desktop
|
|
69
|
+
step, so a wide short window keeps the icon rail and its short-screen sizes). */
|
|
70
|
+
@media (min-width: 1100px) and (min-height: 721px) {
|
|
69
71
|
.app-shell { grid-template-columns: 248px minmax(0, 1fr); }
|
|
70
72
|
.rail { align-items: stretch; padding: calc(var(--safe-top) + 18px) 12px 14px; gap: 2px; }
|
|
71
73
|
.rail-head { flex-direction: row; align-items: center; gap: 8px; padding: 0 8px 14px; }
|
|
@@ -145,7 +147,7 @@
|
|
|
145
147
|
.cx-btn:disabled { opacity: 0.45; }
|
|
146
148
|
.cx-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
|
|
147
149
|
.cx-btn.quiet { border-color: var(--panel-border); }
|
|
148
|
-
@media (max-width: 599px) { .cx-btn {
|
|
150
|
+
@media (max-width: 599px) { .cx-btn { font-size: 14.5px; } }
|
|
149
151
|
|
|
150
152
|
/* ── text switch (COSBrand: words with a 2pt gold rule, never pills) ── */
|
|
151
153
|
.cx-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin: 12px 0 10px; }
|
|
@@ -161,7 +163,6 @@
|
|
|
161
163
|
.cx-check input { width: 16px; height: 16px; accent-color: var(--gold); }
|
|
162
164
|
@media (max-width: 599px) {
|
|
163
165
|
.cx-search { max-width: none; flex-basis: 100%; order: 3; }
|
|
164
|
-
.cx-search .field { min-height: 40px; font-size: 16px; }
|
|
165
166
|
.cx-switch button { font-size: 15px; padding: 8px 0 9px; }
|
|
166
167
|
}
|
|
167
168
|
|
|
@@ -280,7 +281,7 @@
|
|
|
280
281
|
.cx-chip { display: inline-flex; align-items: center; gap: 4px; height: 28px; padding: 0 4px 0 10px; border-radius: 14px; background: var(--panel); border: 1px solid var(--panel-border); font-size: 13px; color: var(--ink); }
|
|
281
282
|
.cx-chip button, .cx-x { width: 24px; height: 24px; border-radius: 12px; color: var(--sub); font-size: 15px; line-height: 1; display: grid; place-items: center; }
|
|
282
283
|
.cx-chip.more { padding-right: 10px; color: var(--sub); }
|
|
283
|
-
@media (max-width: 599px) { .cx-
|
|
284
|
+
@media (max-width: 599px) { .cx-chip { height: 32px; } }
|
|
284
285
|
|
|
285
286
|
/* ── Settings (Control's hub categories) ──────────────── */
|
|
286
287
|
.cx-set-cat { margin-top: 8px; }
|
|
@@ -323,3 +324,8 @@ html, body { overscroll-behavior-y: none; }
|
|
|
323
324
|
.intake-meeting { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
|
324
325
|
.intake-n { font: 600 12px/1.6 var(--mono); color: var(--sub); }
|
|
325
326
|
.intake-foot { margin: 6px 0 0; font-size: 12.5px; color: var(--tertiary); }
|
|
327
|
+
|
|
328
|
+
/* 6.99.30 (design QA, the unfolded Duo at 1430 px): Settings and Dictation are forms and short reads, not boards, so
|
|
329
|
+
their content stops at 720 px instead of running lines to 137 characters; prose lines stop at about 68 characters. */
|
|
330
|
+
#pane-settings, #pane-dictation { max-width: 720px; }
|
|
331
|
+
.cx-set-desc, .cx-set-blurb, .wk-fine { max-width: 68ch; }
|
|
@@ -122,6 +122,7 @@
|
|
|
122
122
|
<div>
|
|
123
123
|
<h1 class="pane-title">Today</h1>
|
|
124
124
|
<p id="today-date" class="today-date"></p>
|
|
125
|
+
<p id="today-weather" class="today-weather hidden"></p>
|
|
125
126
|
</div>
|
|
126
127
|
<button type="button" class="ask-bar" data-open-ask>
|
|
127
128
|
<svg class="icon"><use href="#i-ask"/></svg>
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
.msg-split[data-show] > .msg-left, .msg-split[data-show] > .msg-right { display: block; }
|
|
11
11
|
/* The list column is narrower than the pane: its header's buttons go under the title instead of over it. */
|
|
12
12
|
.msg-left .cx-hero .cx-actions { grid-column: 1 / -1; justify-content: flex-start; }
|
|
13
|
-
.msg-right { position: sticky; top: calc(56px + var(--safe-top, 0px)); height: calc(100vh - 72px - var(--safe-top, 0px)); min-height: 420px; }
|
|
13
|
+
.msg-right { position: sticky; top: calc(56px + var(--safe-top, 0px)); height: calc(100vh - 72px - var(--safe-top, 0px)); height: calc(100dvh - 72px - var(--safe-top, 0px)); min-height: 420px; }
|
|
14
14
|
.msg-right .cc { height: 100%; display: flex; flex-direction: column; background: var(--panel); border: 1px solid var(--panel-border); border-radius: 16px; overflow: hidden; }
|
|
15
15
|
.msg-right .cc-head { padding: 14px 16px 12px; }
|
|
16
16
|
.msg-right .cc-feed { flex: 1; overflow-y: auto; padding: 12px 16px 8px; overscroll-behavior: contain; }
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
/* Below 860 px the chat fills the screen, so the composer sits at the bottom even when the chat is short. */
|
|
21
21
|
@media (max-width: 859px) {
|
|
22
|
-
.msg-split[data-show='chat'] .cc { min-height: calc(100vh - 64px - var(--safe-top, 0px)); display: flex; flex-direction: column; }
|
|
22
|
+
.msg-split[data-show='chat'] .cc { min-height: calc(100vh - 64px - var(--safe-top, 0px)); min-height: calc(100dvh - 64px - var(--safe-top, 0px)); display: flex; flex-direction: column; }
|
|
23
23
|
.msg-split[data-show='chat'] .cc-feed { flex: 1; }
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -190,7 +190,7 @@
|
|
|
190
190
|
.cc-prep-add:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
191
191
|
.cc-prep-acts .cx-btn { height: 40px; }
|
|
192
192
|
@media (max-width: 480px) {
|
|
193
|
-
.cc-prep-acts .cx-btn {
|
|
193
|
+
.cc-prep-acts .cx-btn { min-width: 44px; }
|
|
194
194
|
.cc-prep-head-acts button { min-height: 44px; }
|
|
195
195
|
}
|
|
196
196
|
.cc-prep-note { margin: 0; font-size: 13px; }
|
|
@@ -210,9 +210,15 @@
|
|
|
210
210
|
their own above the words, Take photo and the clip beside Fork and Send, and the limits in plain words under the chips. */
|
|
211
211
|
.att-composer { flex-wrap: wrap; }
|
|
212
212
|
.att-composer .att-files { flex: 1 1 100%; padding: 2px 0 4px; }
|
|
213
|
+
/* 6.99.30 (canary): the folded Sessions reader squeezed the box to about 101 px beside Fork, the clip and Send. Under
|
|
214
|
+
430 px the words get their own full row and the buttons sit on the row below, at its right. */
|
|
215
|
+
@media (max-width: 429px) {
|
|
216
|
+
.att-composer > textarea { flex: 1 1 100%; }
|
|
217
|
+
.att-composer > .composer-actions { margin-left: auto; }
|
|
218
|
+
}
|
|
213
219
|
.att-composer .att-files.hidden { display: none; }
|
|
214
220
|
.att-clip, .ta-clip { display: inline-flex; gap: 6px; }
|
|
215
221
|
.att-clip:empty { display: none; }
|
|
216
222
|
.att-clip .cc-attach { height: 38px; width: 42px; border-radius: var(--radius-sm); }
|
|
217
223
|
.att-composer.dragging { outline: 2px dashed var(--accent); outline-offset: 4px; }
|
|
218
|
-
.cc-chips .att-limit { flex-basis: 100%; margin: 0 2px; font-size: 12.5px; line-height: 1.35; color: var(--sub); }
|
|
224
|
+
.cc-chips .att-limit { flex-basis: 100%; min-width: 0; max-width: 100%; margin: 0 2px; font-size: 12.5px; line-height: 1.35; color: var(--sub); white-space: normal; overflow-wrap: anywhere; }
|
|
@@ -1826,7 +1826,7 @@
|
|
|
1826
1826
|
}
|
|
1827
1827
|
function isComposerInput(el) { return !!(el && el.classList && el.classList.contains('cc-input')); }
|
|
1828
1828
|
function onKey(e) {
|
|
1829
|
-
if (!isComposerInput(e.target) || e.key !== 'Enter' || e.isComposing || e.shiftKey) return;
|
|
1829
|
+
if (!isComposerInput(e.target) || e.key !== 'Enter' || e.isComposing || e.keyCode === 229 || e.shiftKey) return;
|
|
1830
1830
|
e.preventDefault();
|
|
1831
1831
|
var k = kOf(e.target);
|
|
1832
1832
|
if (e.altKey) { if (k === CHAT && dropOn()) doDrop(); return; }
|
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
.mt-wd { text-align: center; font: 600 10px/1 var(--mono); letter-spacing: 0.04em; color: var(--tertiary); padding: 2px 0 4px; }
|
|
22
22
|
.mt-day, .mt-blank { min-height: 42px; }
|
|
23
23
|
.mt-day { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border-radius: 8px; color: var(--ink); font: 400 14px/1 var(--body); }
|
|
24
|
-
.mt-day:disabled { opacity:
|
|
24
|
+
.mt-day:disabled { opacity: 1; }
|
|
25
|
+
/* 6.99.30 (design QA): a day with nothing to open read at 2.35:1 at 38% ink; the secondary colour keeps it quiet and
|
|
26
|
+
readable. */
|
|
27
|
+
.mt-day:disabled .mt-dnum { color: var(--sub); }
|
|
25
28
|
.mt-day:not(:disabled):hover { background: var(--qopen); }
|
|
26
29
|
.mt-day[aria-pressed='true'] { background: color-mix(in srgb, var(--tint) 18%, transparent); font-weight: 600; }
|
|
27
30
|
.mt-day:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
@@ -353,5 +356,5 @@
|
|
|
353
356
|
@media (min-width: 860px) {
|
|
354
357
|
.mt-turns { height: clamp(320px, calc(100dvh - 400px), 1100px); }
|
|
355
358
|
.mt-lgrid { grid-template-columns: minmax(0, 1fr) minmax(280px, min(380px, 38%)); align-items: start; column-gap: 24px; }
|
|
356
|
-
.mt-lgrid > .mt-side-panel { position: sticky; top: calc(62px + var(--safe-top, 0px)); max-height: calc(100vh - 80px - var(--safe-top, 0px)); overflow: auto; }
|
|
359
|
+
.mt-lgrid > .mt-side-panel { position: sticky; top: calc(62px + var(--safe-top, 0px)); max-height: calc(100vh - 80px - var(--safe-top, 0px)); max-height: calc(100dvh - 80px - var(--safe-top, 0px)); overflow: auto; }
|
|
357
360
|
}
|
|
@@ -102,7 +102,7 @@
|
|
|
102
102
|
// "2026-11-03 · 9:05 PM · P · 60 minutes · Mac System Audio"
|
|
103
103
|
function subtitle(m) {
|
|
104
104
|
var parts = [];
|
|
105
|
-
if (m.date) parts.push(m.date);
|
|
105
|
+
if (m.date) parts.push(W.plainDay ? W.plainDay(m.date) : m.date); // "Today" or "Nov 7", never 2026-11-07 (6.99.30)
|
|
106
106
|
if (m.time) parts.push(fmtTime(m.time));
|
|
107
107
|
var d = domainLabel(m); if (d) parts.push(d);
|
|
108
108
|
if (m.duration) parts.push(m.duration); else if (num(m.durationMinutes) > 0) parts.push(num(m.durationMinutes) + ' min');
|