@gotcos/glasses-server 6.99.33 → 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.
- package/.env.example +18 -0
- package/CHANGELOG.md +417 -0
- package/README.md +3 -1
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +29 -7
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-store.ts +24 -11
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +6 -0
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-source.ts +491 -0
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-store.ts +10 -4
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +80 -0
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +119 -28
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +13 -0
- package/server/routes/agent-session-stream.ts +16 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/health.ts +35 -0
- package/server/routes/live-events.ts +329 -0
- package/server/routes/permission-broker.ts +69 -5
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/thread-turn-queue.ts +207 -3
- package/server/routes/today-brief.ts +44 -9
- package/server/routes/welcome-context.ts +23 -3
- package/server/routes/work-board.ts +9 -2
- package/server/web-control/app.css +40 -1
- package/server/web-control/app.js +1309 -56
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +7 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +42 -3
- package/server/web-control/panes/cos-chat.js +267 -15
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +28 -8
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/today-actions.css +11 -0
- package/server/web-control/panes/today-actions.js +45 -12
- package/server/web-control/panes/work.css +10 -0
- package/server/web-control/panes/work.js +10 -2
- package/server/web-control/shell.js +17 -14
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
var W = window.COSWeb;
|
|
5
5
|
if (!W) return;
|
|
6
6
|
var scopes = new Map(), viewer = null, requests = [], activeRequests = 0;
|
|
7
|
-
var ID = /^ai_[a-f0-9]{64}$/;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
var ID = /^ai_[a-f0-9]{64}$/, TOOL_ID = /^ti_[a-f0-9]{64}$/;
|
|
8
|
+
var TOOLS = ['Read', 'Screenshot', 'Image', 'Attached'];
|
|
9
|
+
// outside_workspace and missing are permanent. unavailable means the workspace lookup failed this time; the content route
|
|
10
|
+
// re-resolves it. 6.99.35: outside the workspace and missing (a file a session sent that is gone) read the same, with no path.
|
|
11
|
+
var REASONS = { outside_workspace: 'Not available on this Mac.', missing: 'Not available on this Mac.',
|
|
12
|
+
secret_file: 'Not shown: this file may hold secrets.', unavailable: 'This image is temporarily unavailable. Retry in a moment.', too_large: 'Too large to show here.' };
|
|
11
13
|
var style = document.createElement('style');
|
|
12
|
-
style.textContent = '.rm-item{display:flex;flex-direction:column;gap:6px;max-width:180px}.rm-label,.rm-status{font:12px/1.4 var(--body);color:var(--sub);overflow-wrap:anywhere}.rm-label{color:var(--text)}.rm-item .cx-thumb{cursor:pointer}.rm-view{position:fixed;inset:0;z-index:10000;background:var(--bg,#171717);color:var(--text,#fff);display:flex;flex-direction:column;padding: max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));gap:16px}.rm-view-head{display:flex;align-items:center;justify-content:space-between;gap:16px}.rm-view-label{margin:0;overflow-wrap:anywhere;font:16px/1.4 var(--body)}.rm-view-body{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px}.rm-view img{max-width:100%;max-height:100%;object-fit:contain}.rm-viewing{overflow:hidden!important}';
|
|
14
|
+
style.textContent = '.rm-item{display:flex;flex-direction:column;gap:6px;max-width:180px}.rm-label,.rm-status{font:12px/1.4 var(--body);color:var(--sub);overflow-wrap:anywhere}.rm-label{color:var(--text)}.rm-item .cx-thumb{cursor:pointer}.rm-view{position:fixed;inset:0;z-index:10000;background:var(--bg,#171717);color:var(--text,#fff);display:flex;flex-direction:column;padding: max(16px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(16px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));gap:16px}.rm-view-head{display:flex;align-items:center;justify-content:space-between;gap:16px}.rm-view-label{margin:0;overflow-wrap:anywhere;font:16px/1.4 var(--body)}.rm-view-body{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:16px}.rm-view img{max-width:100%;max-height:100%;object-fit:contain}.rm-viewing{overflow:hidden!important}.rm-files{display:flex;flex-direction:column;gap:8px;margin-top:10px}.rm-file{display:grid;grid-template-columns:44px minmax(0,1fr) auto;gap:4px 12px;align-items:center;padding:10px 12px;border:1px solid var(--panel-border);border-radius:12px;background:var(--panel);max-width:560px}.rm-file-icon{grid-row:1/span 2;display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:10px;background:var(--bg);border:1px solid var(--panel-border);font:600 11px/1 var(--body);letter-spacing:.04em;color:var(--sub)}.rm-file-name{font:500 14px/1.35 var(--body);color:var(--text);overflow-wrap:anywhere;min-width:0}.rm-file-meta{font:12px/1.4 var(--body);color:var(--sub);grid-column:2;min-width:0}.rm-file-acts{grid-column:3;grid-row:1/span 2;display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.rm-file-acts .cx-btn{min-height:44px}.rm-file .rm-status{grid-column:1/-1}.rm-file.off{opacity:.78}.rm-view-acts{display:flex;gap:8px;align-items:center}.rm-text{flex:1;min-height:0;width:100%;overflow:auto;margin:0;padding:12px;border:1px solid var(--panel-border);border-radius:10px;background:var(--panel);color:var(--ink);font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.rm-frame{flex:1;min-height:0;width:100%;border:1px solid var(--panel-border);border-radius:10px;background:#fff}.rm-tools{display:flex;flex-wrap:wrap;gap:10px;margin:10px 0 2px;align-items:flex-start}.rm-tools.before{margin:2px 0 10px}.rm-tool{display:flex;flex-direction:column;gap:4px;width:132px;min-width:0}.rm-tool-thumb{display:flex;align-items:center;justify-content:center;width:132px;height:88px;padding:0;overflow:hidden;border:1px solid var(--panel-border);border-radius:10px;background:var(--panel);color:var(--sub);font:12px/1.3 var(--body);cursor:pointer;text-align:center}.rm-tool-thumb:disabled{cursor:default}.rm-tool-thumb:focus-visible{outline:2px solid var(--accent,currentColor);outline-offset:2px}.rm-tool-thumb img{width:100%;height:100%;object-fit:cover;object-position:top center;display:block}.rm-tool-cap{display:flex;justify-content:space-between;gap:6px;font:12px/1.3 var(--body);min-width:0}.rm-tool-name{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rm-tool-pos{color:var(--sub);white-space:nowrap}.rm-tool .rm-status{font-size:11.5px}.rm-tool-more{display:flex;align-items:center;justify-content:center;width:88px;height:88px;border:1px dashed var(--panel-border);border-radius:10px;color:var(--sub);font:13px/1.3 var(--body)}@media (max-width:599px){.rm-tool{width:104px}.rm-tool-thumb{width:104px;height:72px}.rm-tool-more{width:72px;height:72px}}@media (max-width:599px){.rm-file{grid-template-columns:40px minmax(0,1fr)}.rm-file-icon{width:40px;height:40px}.rm-file-acts{grid-column:1/-1;grid-row:auto;justify-content:flex-start}}';
|
|
13
15
|
document.head.appendChild(style);
|
|
14
16
|
|
|
15
17
|
function element(tag, cls, text) {
|
|
@@ -21,15 +23,27 @@
|
|
|
21
23
|
var b = element('button', cls || 'cx-btn quiet', text); b.type = 'button'; b.addEventListener('click', action); return b;
|
|
22
24
|
}
|
|
23
25
|
function path(scope, id, variant) {
|
|
26
|
+
// A tool-result or prompt image has one size; the thumbnail is the same bytes drawn small, fetched only when seen.
|
|
27
|
+
if (TOOL_ID.test(id)) return '/api/agent-sessions/' + encodeURIComponent(scope.provider) + '/' + encodeURIComponent(scope.id) + '/tool-images/' + encodeURIComponent(id) + '/content';
|
|
24
28
|
return '/api/agent-sessions/' + encodeURIComponent(scope.provider) + '/' + encodeURIComponent(scope.id) + '/output-images/' + encodeURIComponent(id) + '/content?variant=' + variant;
|
|
25
29
|
}
|
|
26
30
|
function revoke(item) { if (item.url) { URL.revokeObjectURL(item.url); item.url = ''; } }
|
|
31
|
+
// 7.0.4 QA: a 503 previews_busy means the Mac is already reading its limit of previews. It is not unreachable.
|
|
32
|
+
var BUSY_WORDS = 'Your Mac is busy with other previews. Try again in a moment.';
|
|
33
|
+
function busy(err) { return !!(err && err.status === 503 && err.code === 'previews_busy'); }
|
|
34
|
+
// A busy thumbnail tries again by itself, after 1, 2, 4 and 8 seconds (with a little spread), then waits for Retry.
|
|
35
|
+
var BUSY_RETRIES = 4;
|
|
36
|
+
function busyDelay(tries) { return 1000 * Math.pow(2, tries) + Math.floor(Math.random() * 250); }
|
|
27
37
|
function failWords(err) {
|
|
38
|
+
if (busy(err)) return BUSY_WORDS;
|
|
28
39
|
if (err && err.status === 401) return 'Signed out. Connect this browser again to view the image.';
|
|
40
|
+
if (err && err.status === 403 && err.code === 'secret_file_denied') return REASONS.secret_file;
|
|
41
|
+
if (err && err.status === 403) return 'Previews are off. Turn them on in Settings, Privacy.';
|
|
29
42
|
if (err && err.status === 404) return 'This image is no longer available. Reopen the session to refresh its images.';
|
|
43
|
+
if (err && err.status === 413) return 'Too large to show here.';
|
|
30
44
|
return 'The image could not load. Check that your Mac is awake, then retry.';
|
|
31
45
|
}
|
|
32
|
-
function retryable(err) { return !(err && (err.status === 401 || err.status === 404)); }
|
|
46
|
+
function retryable(err) { return !(err && (err.status === 401 || err.status === 403 || err.status === 404 || err.status === 413)); }
|
|
33
47
|
function imageBlob(blob) { return blob && /^image\/(png|jpeg|webp|gif|avif)$/.test(blob.type || '') && blob.size > 0; }
|
|
34
48
|
// The server normalizes at most two images at once. Share that budget across both readers and the viewer.
|
|
35
49
|
function pump() {
|
|
@@ -38,55 +52,128 @@
|
|
|
38
52
|
if (!job.valid()) { job.resolve(null); continue; }
|
|
39
53
|
activeRequests++;
|
|
40
54
|
(function (j) {
|
|
41
|
-
|
|
55
|
+
// The strip can disappear between admission and this microtask.
|
|
56
|
+
Promise.resolve().then(function () { return j.valid() ? j.run() : null; }).then(j.resolve, j.reject).then(function () { activeRequests--; pump(); });
|
|
42
57
|
})(job);
|
|
43
58
|
}
|
|
44
59
|
}
|
|
45
|
-
|
|
60
|
+
// type 'json' (preview engine phase 1): a text window or table page, parsed. Anything else is a blob.
|
|
61
|
+
function imageRequest(url, valid, foreground, type) {
|
|
46
62
|
return new Promise(function (resolve, reject) {
|
|
47
|
-
var job = { valid: valid, resolve: resolve, reject: reject, run: function () { return W.api(url, { responseType: 'blob', timeoutMs: 20000 }); } };
|
|
63
|
+
var job = { valid: valid, resolve: resolve, reject: reject, run: function () { return W.api(url, type === 'json' ? { timeoutMs: 20000 } : { responseType: 'blob', timeoutMs: 20000 }); } };
|
|
48
64
|
if (foreground) requests.unshift(job); else requests.push(job);
|
|
49
65
|
pump();
|
|
50
66
|
});
|
|
51
67
|
}
|
|
52
68
|
function fetchImage(scope, item, variant, target, ready, failed) {
|
|
53
69
|
var generation = ++target.generation;
|
|
54
|
-
imageRequest(path(scope, item.id, variant), function () { return scope.alive && generation === target.generation; }, variant === 'phone').then(function (blob) {
|
|
55
|
-
if (!scope.alive || generation !== target.generation) return;
|
|
70
|
+
imageRequest(path(scope, item.id, variant), function () { return scope.alive && target.active !== false && generation === target.generation; }, variant === 'phone').then(function (blob) {
|
|
71
|
+
if (!scope.alive || target.active === false || generation !== target.generation) return;
|
|
56
72
|
if (!imageBlob(blob)) throw new Error('Not a supported image');
|
|
57
73
|
revoke(target); target.url = URL.createObjectURL(blob); ready(target.url);
|
|
58
74
|
}).catch(function (err) {
|
|
59
|
-
if (scope.alive && generation === target.generation) failed(err);
|
|
75
|
+
if (scope.alive && target.active !== false && generation === target.generation) failed(err);
|
|
60
76
|
});
|
|
61
77
|
}
|
|
62
78
|
function loadThumb(scope, item) {
|
|
63
|
-
if (item.busy) return;
|
|
79
|
+
if (!scope.alive || item.active === false || item.busy) return;
|
|
64
80
|
item.busy = true; item.open.textContent = 'Loading image…'; item.status.textContent = 'Loading image…'; item.retry.hidden = true; item.open.disabled = true;
|
|
65
81
|
fetchImage(scope, item, 'thumb', item, function (url) {
|
|
66
|
-
item.busy = false; item.open.textContent = '';
|
|
82
|
+
item.busy = false; item.busyTries = 0; item.open.textContent = '';
|
|
67
83
|
var img = element('img'); img.alt = item.label;
|
|
68
84
|
img.addEventListener('error', function () {
|
|
69
|
-
if (!scope.alive || item.url !== url) return;
|
|
85
|
+
if (!scope.alive || item.active === false || item.url !== url) return;
|
|
70
86
|
revoke(item); item.open.textContent = 'Image unavailable'; item.open.disabled = true;
|
|
71
87
|
item.status.textContent = 'The image could not be displayed.'; item.retry.hidden = false;
|
|
72
88
|
});
|
|
73
89
|
img.src = url; item.open.appendChild(img); item.open.disabled = false; item.status.textContent = '';
|
|
74
90
|
}, function (err) {
|
|
75
91
|
item.busy = false; item.open.textContent = 'Image unavailable'; item.status.textContent = failWords(err); item.retry.hidden = !retryable(err);
|
|
92
|
+
if (busy(err) && (item.busyTries || 0) < BUSY_RETRIES) {
|
|
93
|
+
var delay = busyDelay(item.busyTries || 0);
|
|
94
|
+
item.busyTries = (item.busyTries || 0) + 1;
|
|
95
|
+
item.open.textContent = 'Waiting…'; item.status.textContent = 'Your Mac is busy with other previews. Trying again…'; item.retry.hidden = true;
|
|
96
|
+
setTimeout(function () { if (scope.alive && item.active !== false && !item.url) loadThumb(scope, item); }, delay);
|
|
97
|
+
}
|
|
76
98
|
});
|
|
77
99
|
}
|
|
78
|
-
|
|
100
|
+
// Back closes the viewer and lands where you were (11/08). Opening one pushes a history entry on the same address
|
|
101
|
+
// (no hashchange, so no pane changes); Back pops it and closes the image. Closing it any other way takes that entry
|
|
102
|
+
// back off, quietly: its popstate is ours, and the wait lapses after POP_WAIT_MS so a later real Back is never eaten.
|
|
103
|
+
var ownPops = 0, POP_WAIT_MS = 700;
|
|
104
|
+
function pushEntry(v) {
|
|
105
|
+
try { history.pushState(Object.assign({}, history.state || {}, { cosImageViewer: 1 }), ''); v.pushed = true; } catch (_e) { v.pushed = false; }
|
|
106
|
+
}
|
|
107
|
+
function dropEntry() {
|
|
108
|
+
ownPops++;
|
|
109
|
+
setTimeout(function () { if (ownPops > 0) ownPops--; }, POP_WAIT_MS);
|
|
110
|
+
try { history.back(); } catch (_e) { ownPops--; }
|
|
111
|
+
}
|
|
112
|
+
// Another popstate listener (Today's sheet) may run before or after this one, so the event is marked as well.
|
|
113
|
+
function mark(e) { try { if (e) e.__cosImagePop = true; } catch (_e) { /* a frozen event: popIsMine still sees the state */ } }
|
|
114
|
+
window.addEventListener('popstate', function (e) {
|
|
115
|
+
if (ownPops > 0) { ownPops--; mark(e); return; }
|
|
116
|
+
if (viewer && viewer.pushed) { viewer.pushed = false; mark(e); closeViewer(); }
|
|
117
|
+
});
|
|
118
|
+
function popIsMine(e) { return !!(e && e.__cosImagePop) || ownPops > 0 || !!(viewer && viewer.pushed); }
|
|
119
|
+
// The scroll spots under the opener: the page and every scrolled box it sits in (Today's sheet body).
|
|
120
|
+
function scrollSpots(el) {
|
|
121
|
+
var out = { x: window.scrollX || 0, y: window.scrollY || 0, boxes: [] };
|
|
122
|
+
for (var p = el && el.parentElement; p; p = p.parentElement) if (p.scrollTop > 0) out.boxes.push([p, p.scrollTop]);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
function restoreSpots(spots) {
|
|
126
|
+
if (!spots) return;
|
|
127
|
+
spots.boxes.forEach(function (b) { if (b[0].scrollTop !== b[1]) b[0].scrollTop = b[1]; });
|
|
128
|
+
if ((window.scrollX || 0) !== spots.x || (window.scrollY || 0) !== spots.y) { try { window.scrollTo(spots.x, spots.y); } catch (_e) {} }
|
|
129
|
+
}
|
|
130
|
+
// keepEntry: another viewer replaces this one and takes over its history entry.
|
|
131
|
+
function closeViewer(restore, keepEntry) {
|
|
79
132
|
var v = viewer; if (!v) return;
|
|
80
|
-
viewer = null; v.generation++; revoke(v); v.el.remove(); document.documentElement.classList.remove('rm-viewing');
|
|
81
|
-
if (
|
|
133
|
+
viewer = null; v.generation++; revoke(v); if (v.unpin) v.unpin(); v.el.remove(); document.documentElement.classList.remove('rm-viewing');
|
|
134
|
+
if (v.pushed && !keepEntry) { v.pushed = false; dropEntry(); }
|
|
135
|
+
if (restore !== false && v.opener && v.opener.isConnected) { try { v.opener.focus({ preventScroll: true }); } catch (_e) { v.opener.focus(); } }
|
|
136
|
+
if (restore !== false) restoreSpots(v.spots);
|
|
137
|
+
}
|
|
138
|
+
function takeOver(v, had) { v.spots = had ? had.spots : scrollSpots(v.opener); if (had && had.pushed) v.pushed = true; else pushEntry(v); pinViewer(v); }
|
|
139
|
+
// 7.0.5 (Miles's Duo, 11/08 IMG_0541 and IMG_0543): the viewer is position:fixed, inset 0, which is the LAYOUT
|
|
140
|
+
// viewport. When the browser has panned the visible part of the page (a keyboard up or just closed, a bar coming and
|
|
141
|
+
// going), that box can sit above what is on screen, so the viewer opened with only its bottom edge showing. It now
|
|
142
|
+
// takes the visible box itself, read the one way Control Web reads it (COSWeb.viewportFit: top and height from
|
|
143
|
+
// visualViewport), as Today's session sheet does, and follows it while it is open (at most once a frame).
|
|
144
|
+
// A breadcrumb records the box on open and whenever it moves by more than 40 px, so a device report has numbers.
|
|
145
|
+
function pinViewer(v) {
|
|
146
|
+
var fit = W.viewportFit, vv = window.visualViewport, frame = 0, last = null;
|
|
147
|
+
function place() {
|
|
148
|
+
frame = 0;
|
|
149
|
+
if (viewer !== v || !v.el.style) return;
|
|
150
|
+
var b = typeof fit === 'function' ? fit() : null;
|
|
151
|
+
if (!b || !b.vv) { v.el.style.top = ''; v.el.style.height = ''; v.el.style.bottom = ''; return; }
|
|
152
|
+
v.el.style.top = b.top + 'px'; v.el.style.height = b.height + 'px'; v.el.style.bottom = 'auto';
|
|
153
|
+
if (W.layoutCrumb && (!last || Math.abs(last.top - b.top) > 40 || Math.abs(last.height - b.height) > 40)) {
|
|
154
|
+
try { W.layoutCrumb(last ? 'viewer-move' : 'viewer-open', { boxTop: b.top, boxH: b.height, inset: b.inset, sy: Math.round(window.scrollY || 0) }); } catch (_e) {}
|
|
155
|
+
}
|
|
156
|
+
last = { top: b.top, height: b.height };
|
|
157
|
+
}
|
|
158
|
+
function soon() { if (!frame) frame = window.requestAnimationFrame ? window.requestAnimationFrame(place) : setTimeout(place, 16); }
|
|
159
|
+
place();
|
|
160
|
+
if (vv && vv.addEventListener) { vv.addEventListener('resize', soon); vv.addEventListener('scroll', soon); }
|
|
161
|
+
if (window.addEventListener) window.addEventListener('resize', soon);
|
|
162
|
+
v.unpin = function () {
|
|
163
|
+
if (vv && vv.removeEventListener) { vv.removeEventListener('resize', soon); vv.removeEventListener('scroll', soon); }
|
|
164
|
+
if (window.removeEventListener) window.removeEventListener('resize', soon);
|
|
165
|
+
if (frame) { if (window.cancelAnimationFrame) window.cancelAnimationFrame(frame); else clearTimeout(frame); frame = 0; }
|
|
166
|
+
};
|
|
82
167
|
}
|
|
83
168
|
function openViewer(scope, item) {
|
|
84
|
-
|
|
169
|
+
if (!scope.alive || item.active === false) return;
|
|
170
|
+
var had = viewer; closeViewer(false, true);
|
|
85
171
|
var el = element('div', 'rm-view'), head = element('div', 'rm-view-head'), body = element('div', 'rm-view-body');
|
|
86
172
|
el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-label', 'Image preview: ' + item.label);
|
|
87
173
|
var close = button('Close image', function () { closeViewer(); });
|
|
88
174
|
head.appendChild(element('h2', 'rm-view-label', item.label)); head.appendChild(close); el.appendChild(head); el.appendChild(body);
|
|
89
175
|
var v = viewer = { el: el, body: body, scope: scope, opener: item.open, close: close, url: '', generation: 0 };
|
|
176
|
+
takeOver(v, had);
|
|
90
177
|
document.body.appendChild(el); document.documentElement.classList.add('rm-viewing'); close.focus();
|
|
91
178
|
function load() {
|
|
92
179
|
body.textContent = ''; var status = element('p', 'rm-status', 'Loading image…'); status.setAttribute('role', 'status'); body.appendChild(status);
|
|
@@ -115,12 +202,20 @@
|
|
|
115
202
|
}
|
|
116
203
|
}, true);
|
|
117
204
|
function clearRecord(scope, record) {
|
|
118
|
-
record.items.forEach(function (item) {
|
|
205
|
+
record.items.forEach(function (item) {
|
|
206
|
+
item.active = false; item.generation++;
|
|
207
|
+
if (item.open) {
|
|
208
|
+
if (scope.observer) scope.observer.unobserve(item.open);
|
|
209
|
+
if (item.open.__cosToolItem === item) delete item.open.__cosToolItem;
|
|
210
|
+
}
|
|
211
|
+
revoke(item);
|
|
212
|
+
});
|
|
119
213
|
if (viewer && viewer.scope === scope && record.items.some(function (item) { return item.open === viewer.opener; })) closeViewer(false);
|
|
120
214
|
}
|
|
121
215
|
function unmount(host) {
|
|
122
216
|
var scope = scopes.get(host); if (!scope) return;
|
|
123
217
|
scope.alive = false; scope.records.forEach(function (record) { clearRecord(scope, record); }); scopes.delete(host);
|
|
218
|
+
if (scope.observer) { scope.observer.disconnect(); scope.observer = null; }
|
|
124
219
|
}
|
|
125
220
|
function mount(host, provider, sessionId) {
|
|
126
221
|
if (!host || !provider || !sessionId) return;
|
|
@@ -128,7 +223,15 @@
|
|
|
128
223
|
if (scope && (scope.provider !== provider || scope.id !== sessionId)) { unmount(host); scope = null; }
|
|
129
224
|
if (!scope) { scope = { alive: true, provider: String(provider), id: String(sessionId), records: new Map() }; scopes.set(host, scope); }
|
|
130
225
|
var slots = Array.prototype.slice.call(host.querySelectorAll('[data-reader-images]'));
|
|
131
|
-
|
|
226
|
+
var fileSlots = Array.prototype.slice.call(host.querySelectorAll('[data-reader-files]'));
|
|
227
|
+
var toolSlots = Array.prototype.slice.call(host.querySelectorAll('[data-reader-tools]'));
|
|
228
|
+
var linkSlots = Array.prototype.slice.call(host.querySelectorAll('[data-reader-links]'));
|
|
229
|
+
scope.records.forEach(function (record, slot) {
|
|
230
|
+
if (slots.indexOf(slot) < 0 && fileSlots.indexOf(slot) < 0 && toolSlots.indexOf(slot) < 0 && linkSlots.indexOf(slot) < 0) { clearRecord(scope, record); scope.records.delete(slot); }
|
|
231
|
+
});
|
|
232
|
+
mountFiles(host, scope);
|
|
233
|
+
mountTools(host, scope, toolSlots);
|
|
234
|
+
mountLinks(host, scope);
|
|
132
235
|
slots.forEach(function (slot) {
|
|
133
236
|
if (scope.records.has(slot)) return;
|
|
134
237
|
var raw; try { raw = JSON.parse(slot.getAttribute('data-reader-images') || '[]'); } catch (_e) { raw = []; }
|
|
@@ -145,10 +248,293 @@
|
|
|
145
248
|
item.retry = button('Retry image', function () { loadThumb(scope, item); }, 'link-btn'); item.retry.hidden = true;
|
|
146
249
|
wrap.appendChild(item.open); wrap.appendChild(element('span', 'rm-label', item.label)); wrap.appendChild(item.status); wrap.appendChild(item.retry); slot.appendChild(wrap);
|
|
147
250
|
if (dto.state === 'available') loadThumb(scope, item);
|
|
148
|
-
else { item.status.textContent = REASONS[dto.reason] || REASONS.unavailable; item.retry.hidden = dto.reason === 'outside_workspace'; }
|
|
251
|
+
else { item.status.textContent = REASONS[dto.reason] || REASONS.unavailable; item.retry.hidden = dto.reason === 'outside_workspace' || dto.reason === 'missing' || dto.reason === 'secret_file'; }
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Images the session looked at (11/08): a Read of a PNG, a screenshot, a prompt's attached image ──
|
|
257
|
+
// A small thumbnail per image with its tool and place in the step ("Read", "1 of 2"), as the Claude app shows a step.
|
|
258
|
+
// Lazy: nothing is fetched until the thumbnail is near the screen, so a long session never pulls 50 images at once.
|
|
259
|
+
// Without IntersectionObserver the thumbnails load as output images do. Tapping one opens it full size.
|
|
260
|
+
function toolObserver(scope) {
|
|
261
|
+
if (scope.observer !== undefined) return scope.observer;
|
|
262
|
+
var IO = window.IntersectionObserver;
|
|
263
|
+
var observer = typeof IO === 'function' ? new IO(function (entries) {
|
|
264
|
+
// disconnect/unobserve cannot withdraw a callback already queued by the browser.
|
|
265
|
+
if (!scope.alive || scope.observer !== observer) return;
|
|
266
|
+
entries.forEach(function (e) {
|
|
267
|
+
if (!e.isIntersecting) return;
|
|
268
|
+
observer.unobserve(e.target);
|
|
269
|
+
// Preview engine phase 1: a link card's thumbnail loads the same lazy way.
|
|
270
|
+
var card = e.target.__cosLinkCard;
|
|
271
|
+
if (card) { delete e.target.__cosLinkCard; linkThumb(scope, card); }
|
|
272
|
+
var item = e.target.__cosToolItem;
|
|
273
|
+
if (!item || item.active === false) return;
|
|
274
|
+
delete e.target.__cosToolItem;
|
|
275
|
+
loadThumb(scope, item);
|
|
276
|
+
});
|
|
277
|
+
}, { rootMargin: '300px 0px' }) : null;
|
|
278
|
+
scope.observer = observer;
|
|
279
|
+
return observer;
|
|
280
|
+
}
|
|
281
|
+
function mountTools(host, scope, slots) {
|
|
282
|
+
slots.forEach(function (slot) {
|
|
283
|
+
if (scope.records.has(slot)) return;
|
|
284
|
+
var raw; try { raw = JSON.parse(slot.getAttribute('data-reader-tools') || '{}'); } catch (_e) { raw = {}; }
|
|
285
|
+
var list = raw && Array.isArray(raw.items) ? raw.items : [];
|
|
286
|
+
slot.textContent = ''; slot.className = 'rm-tools' + (raw && raw.before ? ' before' : ''); slot.setAttribute('aria-label', 'Images the session looked at');
|
|
287
|
+
var record = { items: [] }; scope.records.set(slot, record);
|
|
288
|
+
var io = toolObserver(scope);
|
|
289
|
+
list.forEach(function (dto) {
|
|
290
|
+
if (!dto || !TOOL_ID.test(dto.id || '') || ['available', 'unavailable'].indexOf(dto.state) < 0) return;
|
|
291
|
+
var tool = TOOLS.indexOf(dto.tool) >= 0 ? dto.tool : 'Image';
|
|
292
|
+
var index = Math.max(1, Math.floor(Number(dto.index)) || 1), total = Math.max(index, Math.floor(Number(dto.total)) || index);
|
|
293
|
+
var pos = index + ' of ' + total;
|
|
294
|
+
var item = { id: dto.id, label: tool + ', ' + pos, generation: 0, url: '', busy: false };
|
|
295
|
+
record.items.push(item);
|
|
296
|
+
var wrap = element('div', 'rm-tool');
|
|
297
|
+
item.open = button('Image', function () { openViewer(scope, item); }, 'rm-tool-thumb');
|
|
298
|
+
item.open.setAttribute('aria-label', 'Open image: ' + item.label); item.open.disabled = true;
|
|
299
|
+
item.status = element('span', 'rm-status'); item.status.setAttribute('role', 'status');
|
|
300
|
+
item.retry = button('Retry image', function () { loadThumb(scope, item); }, 'link-btn'); item.retry.hidden = true;
|
|
301
|
+
var cap = element('span', 'rm-tool-cap'); cap.appendChild(element('span', 'rm-tool-name', tool)); cap.appendChild(element('span', 'rm-tool-pos', pos));
|
|
302
|
+
wrap.appendChild(item.open); wrap.appendChild(cap); wrap.appendChild(item.status); wrap.appendChild(item.retry); slot.appendChild(wrap);
|
|
303
|
+
if (dto.state !== 'available') {
|
|
304
|
+
item.open.textContent = 'Unavailable';
|
|
305
|
+
item.status.textContent = REASONS[dto.reason] || REASONS.unavailable;
|
|
306
|
+
item.retry.hidden = dto.reason !== 'unavailable' && !!dto.reason;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (io) { item.open.__cosToolItem = item; io.observe(item.open); }
|
|
310
|
+
else loadThumb(scope, item);
|
|
149
311
|
});
|
|
312
|
+
var more = Math.floor(Number(raw && raw.more)) || 0;
|
|
313
|
+
if (more > 0) {
|
|
314
|
+
var m = element('span', 'rm-tool-more', '+' + more + ' more');
|
|
315
|
+
m.setAttribute('title', more === 1 ? 'One more image in this step is not shown here.' : more + ' more images in this step are not shown here.');
|
|
316
|
+
slot.appendChild(m);
|
|
317
|
+
}
|
|
150
318
|
});
|
|
151
319
|
}
|
|
152
|
-
|
|
320
|
+
|
|
321
|
+
// ── Files a session sent (6.99.35): a card per file, opened through the authenticated output-files route ──
|
|
322
|
+
// A PDF opens in a new tab as a PDF. Text, Markdown, CSV, JSON and SVG show as words in the viewer. HTML shows in a
|
|
323
|
+
// sandboxed frame with no scripts and its own opaque origin, or downloads; it is never opened as a page of this
|
|
324
|
+
// origin. A GIF shows as a picture. Anything else downloads. Every blob is retyped here, never trusted as served.
|
|
325
|
+
var FILE_ID = /^af_[a-f0-9]{64}$/, TEXT_SHOWN = 512 * 1024;
|
|
326
|
+
var OFF = 'Not available on this Mac';
|
|
327
|
+
// QA 7.0.0: a file card has no Retry (a mounted card is never re-read), so a passing failure says the step that works.
|
|
328
|
+
var FILE_REASONS = { secret_file: REASONS.secret_file, outside_workspace: OFF, missing: OFF, unavailable: 'Not available right now. Reopen the session to check again.' };
|
|
329
|
+
var BADGE = { pdf: 'PDF', html: 'HTML', image: 'GIF' };
|
|
330
|
+
function fileUrl(scope, id) {
|
|
331
|
+
return '/api/agent-sessions/' + encodeURIComponent(scope.provider) + '/' + encodeURIComponent(scope.id) + '/output-files/' + encodeURIComponent(id) + '/content';
|
|
332
|
+
}
|
|
333
|
+
function bytesWords(n) {
|
|
334
|
+
if (typeof n !== 'number' || !(n >= 0)) return '';
|
|
335
|
+
if (n < 1024) return n + ' B';
|
|
336
|
+
if (n < 1024 * 1024) return Math.max(1, Math.round(n / 1024)) + ' KB';
|
|
337
|
+
return (n / 1024 / 1024).toFixed(n < 10 * 1024 * 1024 ? 1 : 0) + ' MB';
|
|
338
|
+
}
|
|
339
|
+
function badge(f) {
|
|
340
|
+
if (BADGE[f.kind]) return BADGE[f.kind];
|
|
341
|
+
var ext = /\.([a-z0-9]{1,4})$/i.exec(f.name); return ext ? ext[1].toUpperCase() : 'FILE';
|
|
342
|
+
}
|
|
343
|
+
function fileFailWords(err) {
|
|
344
|
+
if (busy(err)) return 'Your Mac is busy with other previews. Try again in a moment.';
|
|
345
|
+
if (err && err.status === 403 && err.code === 'secret_file_denied') return REASONS.secret_file;
|
|
346
|
+
if (err && err.status === 401) return 'Signed out. Connect this browser again to open the file.';
|
|
347
|
+
if (err && err.status === 403) return 'Previews are off. Turn them on in Settings, Privacy.';
|
|
348
|
+
if (err && err.status === 404) return 'This file is no longer in the recent turns. Reopen the session to refresh it.';
|
|
349
|
+
if (err && err.status === 413) return 'Too large to open here. Open it on your Mac.';
|
|
350
|
+
return 'The file could not load. Check that your Mac is awake, then retry.';
|
|
351
|
+
}
|
|
352
|
+
function fetchFile(scope, f) {
|
|
353
|
+
return imageRequest(fileUrl(scope, f.id), function () { return scope.alive; }, true).then(function (blob) {
|
|
354
|
+
if (!blob || typeof blob.size !== 'number') throw new Error('No file');
|
|
355
|
+
return blob;
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
// A download is always typed application/octet-stream, so no browser renders it as a page of this origin.
|
|
359
|
+
function saveBlob(blob, name) {
|
|
360
|
+
var url = URL.createObjectURL(new Blob([blob], { type: 'application/octet-stream' }));
|
|
361
|
+
var a = element('a'); a.href = url; a.download = name; a.rel = 'noopener'; a.hidden = true;
|
|
362
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
363
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 60000);
|
|
364
|
+
}
|
|
365
|
+
function openPdf(blob, pending) {
|
|
366
|
+
var url = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }));
|
|
367
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 5 * 60000);
|
|
368
|
+
if (pending && !pending.closed) { try { pending.location.href = url; return true; } catch (_e) { /* fall through */ } }
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
function fileViewer(scope, f, opener) {
|
|
372
|
+
// Preview engine phase 1: with the shared viewer loaded, a sent file opens in it (Markdown rendered, code with line
|
|
373
|
+
// numbers, CSV as a table, HTML full length with a per-open Run scripts). Without it, the 6.99.35 viewer below.
|
|
374
|
+
if (W.preview && (f.kind === 'text' || f.kind === 'html')) return openPreview(scope, { id: f.id, name: f.name, label: f.name, kind: W.preview.kindOf(f.name), type: f.type, size: f.size, state: f.state }, opener);
|
|
375
|
+
var had = viewer; closeViewer(false, true);
|
|
376
|
+
var el = element('div', 'rm-view'), head = element('div', 'rm-view-head'), body = element('div', 'rm-view-body'), acts = element('div', 'rm-view-acts');
|
|
377
|
+
el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-label', f.type + ': ' + f.name);
|
|
378
|
+
var close = button('Close', function () { closeViewer(); });
|
|
379
|
+
acts.appendChild(button('Download', function () { fetchFile(scope, f).then(function (b) { saveBlob(b, f.name); }, failure); }));
|
|
380
|
+
acts.appendChild(close);
|
|
381
|
+
head.appendChild(element('h2', 'rm-view-label', f.name)); head.appendChild(acts); el.appendChild(head); el.appendChild(body);
|
|
382
|
+
var v = viewer = { el: el, body: body, scope: scope, opener: opener, close: close, url: '', generation: 0 };
|
|
383
|
+
takeOver(v, had);
|
|
384
|
+
document.body.appendChild(el); document.documentElement.classList.add('rm-viewing'); close.focus();
|
|
385
|
+
function failure(err) {
|
|
386
|
+
if (viewer !== v) return;
|
|
387
|
+
body.textContent = ''; var st = element('p', 'rm-status', fileFailWords(err)); st.setAttribute('role', 'status'); body.appendChild(st);
|
|
388
|
+
}
|
|
389
|
+
var status = element('p', 'rm-status', 'Loading ' + f.name + '…'); status.setAttribute('role', 'status'); body.appendChild(status);
|
|
390
|
+
fetchFile(scope, f).then(function (blob) {
|
|
391
|
+
if (viewer !== v) return;
|
|
392
|
+
if (f.kind === 'image') {
|
|
393
|
+
revoke(v); v.url = URL.createObjectURL(new Blob([blob], { type: 'image/gif' }));
|
|
394
|
+
body.textContent = ''; var img = element('img'); img.alt = f.name; img.src = v.url; body.appendChild(img); return;
|
|
395
|
+
}
|
|
396
|
+
return blob.slice(0, TEXT_SHOWN).text().then(function (text) {
|
|
397
|
+
if (viewer !== v) return;
|
|
398
|
+
body.textContent = '';
|
|
399
|
+
if (f.kind === 'html') {
|
|
400
|
+
// sandbox with no allow-* tokens: no scripts, no forms, no popups, an opaque origin. srcdoc, so no URL of this origin.
|
|
401
|
+
var frame = element('iframe', 'rm-frame'); frame.setAttribute('sandbox', ''); frame.setAttribute('referrerpolicy', 'no-referrer');
|
|
402
|
+
frame.title = 'Preview of ' + f.name + ' (scripts off)'; frame.srcdoc = text; body.appendChild(frame);
|
|
403
|
+
body.appendChild(element('p', 'rm-status', 'Preview with scripts off. Download it to open it fully.'));
|
|
404
|
+
} else {
|
|
405
|
+
body.appendChild(element('pre', 'rm-text', text));
|
|
406
|
+
if (blob.size > TEXT_SHOWN) body.appendChild(element('p', 'rm-status', 'Showing the first 512 KB. Download it for the rest.'));
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
}).catch(failure);
|
|
410
|
+
}
|
|
411
|
+
function fileCard(scope, record, f) {
|
|
412
|
+
var card = element('div', 'rm-file' + (f.state === 'available' ? '' : ' off'));
|
|
413
|
+
var icon = element('span', 'rm-file-icon', badge(f)); icon.setAttribute('aria-hidden', 'true');
|
|
414
|
+
var meta = element('span', 'rm-file-meta', [f.type, bytesWords(f.size)].filter(Boolean).join(' · '));
|
|
415
|
+
var acts = element('div', 'rm-file-acts');
|
|
416
|
+
var status = element('span', 'rm-status'); status.setAttribute('role', 'status');
|
|
417
|
+
card.appendChild(icon); card.appendChild(element('span', 'rm-file-name', f.name)); card.appendChild(meta); card.appendChild(acts); card.appendChild(status);
|
|
418
|
+
var item = { open: null, generation: 0, url: '' }; record.items.push(item);
|
|
419
|
+
if (f.state !== 'available') { meta.textContent = f.type + ' · ' + (FILE_REASONS[f.reason] || OFF); return card; }
|
|
420
|
+
function busy(b, on, words) { b.disabled = on; status.textContent = on ? words : ''; }
|
|
421
|
+
if (f.kind === 'pdf') {
|
|
422
|
+
var openPdfBtn = item.open = button('Open', function () {
|
|
423
|
+
// Preview engine phase 1: on a desktop a PDF opens inline in the viewer; a phone keeps the new tab.
|
|
424
|
+
if (W.preview && !W.preview.isPhone()) { openPreview(scope, { id: f.id, name: f.name, label: f.name, kind: 'pdf', type: f.type, size: f.size, state: f.state }, openPdfBtn); return; }
|
|
425
|
+
// The tab opens on the tap (a later window.open is blocked), then shows the PDF when it arrives.
|
|
426
|
+
var pending = null; try { pending = window.open('', '_blank'); } catch (_e) { pending = null; }
|
|
427
|
+
busy(openPdfBtn, true, 'Opening ' + f.name + '…');
|
|
428
|
+
fetchFile(scope, f).then(function (blob) {
|
|
429
|
+
busy(openPdfBtn, false, '');
|
|
430
|
+
if (!openPdf(blob, pending)) {
|
|
431
|
+
var url = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }));
|
|
432
|
+
var link = element('a', 'link-btn', 'Show the PDF'); link.href = url; link.target = '_blank'; link.rel = 'noopener';
|
|
433
|
+
status.textContent = ''; status.appendChild(link);
|
|
434
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 5 * 60000);
|
|
435
|
+
}
|
|
436
|
+
}, function (err) { if (pending) try { pending.close(); } catch (_e) {} busy(openPdfBtn, false, ''); status.textContent = fileFailWords(err); });
|
|
437
|
+
}, 'cx-btn');
|
|
438
|
+
openPdfBtn.setAttribute('aria-label', 'Open PDF: ' + f.name); acts.appendChild(openPdfBtn);
|
|
439
|
+
} else if (f.kind === 'text' || f.kind === 'html' || f.kind === 'image') {
|
|
440
|
+
var view = item.open = button(f.kind === 'html' ? 'Preview' : 'Open', function () { fileViewer(scope, f, view); }, 'cx-btn');
|
|
441
|
+
view.setAttribute('aria-label', (f.kind === 'html' ? 'Preview ' : 'Open ') + f.name); acts.appendChild(view);
|
|
442
|
+
}
|
|
443
|
+
if (f.kind !== 'text' && f.kind !== 'image') {
|
|
444
|
+
var save = button('Download', function () {
|
|
445
|
+
busy(save, true, 'Downloading ' + f.name + '…');
|
|
446
|
+
fetchFile(scope, f).then(function (blob) { busy(save, false, ''); saveBlob(blob, f.name); }, function (err) { busy(save, false, ''); status.textContent = fileFailWords(err); });
|
|
447
|
+
}, f.kind === 'file' ? 'cx-btn' : 'cx-btn quiet');
|
|
448
|
+
save.setAttribute('aria-label', 'Download ' + f.name); acts.appendChild(save);
|
|
449
|
+
if (!item.open) item.open = save;
|
|
450
|
+
}
|
|
451
|
+
return card;
|
|
452
|
+
}
|
|
453
|
+
function mountFiles(host, scope) {
|
|
454
|
+
var slots = Array.prototype.slice.call(host.querySelectorAll('[data-reader-files]'));
|
|
455
|
+
slots.forEach(function (slot) {
|
|
456
|
+
if (scope.records.has(slot)) return;
|
|
457
|
+
var raw; try { raw = JSON.parse(slot.getAttribute('data-reader-files') || '[]'); } catch (_e) { raw = []; }
|
|
458
|
+
slot.textContent = ''; slot.className = 'rm-files'; slot.setAttribute('aria-label', 'Files the session sent');
|
|
459
|
+
var record = { items: [] }; scope.records.set(slot, record);
|
|
460
|
+
(Array.isArray(raw) ? raw : []).forEach(function (dto) {
|
|
461
|
+
if (!dto || !FILE_ID.test(dto.id || '') || ['available', 'unavailable'].indexOf(dto.state) < 0) return;
|
|
462
|
+
var f = { id: dto.id, name: String(dto.name || 'File').slice(0, 120), kind: ['pdf', 'text', 'html', 'image', 'file'].indexOf(dto.kind) >= 0 ? dto.kind : 'file',
|
|
463
|
+
type: String(dto.type || 'File').slice(0, 40), size: typeof dto.size === 'number' ? dto.size : null, state: dto.state, reason: dto.reason };
|
|
464
|
+
slot.appendChild(fileCard(scope, record, f));
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
return slots;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ── Preview engine phase 1: link cards and the shared viewer (panes/preview.js draws; this shell owns history) ──
|
|
471
|
+
function saveRaw(scope, f, failure) {
|
|
472
|
+
imageRequest(W.preview.url(scope, f.id, 'raw'), function () { return scope.alive; }, true).then(function (b) { saveBlob(b, f.name); }, failure);
|
|
473
|
+
}
|
|
474
|
+
function openPreview(scope, dto, opener) {
|
|
475
|
+
var f = W.preview && W.preview.clean(dto);
|
|
476
|
+
if (!f) return;
|
|
477
|
+
// A phone opens a PDF in its own tab, on the tap (a later window.open is blocked).
|
|
478
|
+
if (f.kind === 'pdf' && W.preview.isPhone()) { try { window.open(W.preview.url(scope, f.id, 'inline'), '_blank', 'noopener'); } catch (_e) {} return; }
|
|
479
|
+
var had = viewer; closeViewer(false, true);
|
|
480
|
+
var el = element('div', 'rm-view'), head = element('div', 'rm-view-head'), body = element('div', 'rm-view-body'), acts = element('div', 'rm-view-acts');
|
|
481
|
+
el.setAttribute('role', 'dialog'); el.setAttribute('aria-modal', 'true'); el.setAttribute('aria-label', f.type + ': ' + f.name);
|
|
482
|
+
var close = button('Close', function () { closeViewer(); });
|
|
483
|
+
var v = viewer = { el: el, body: body, scope: scope, opener: opener, close: close, url: '', generation: 0 };
|
|
484
|
+
if (W.preview.parts(f).indexOf('raw') >= 0) acts.appendChild(button('Download', function () { saveRaw(scope, f, function (err) { if (viewer === v) { var st = element('p', 'rm-status', W.preview.failWords(err)); st.setAttribute('role', 'status'); body.appendChild(st); } }); }));
|
|
485
|
+
acts.appendChild(close);
|
|
486
|
+
head.appendChild(element('h2', 'rm-view-label', f.name + (f.line ? ':' + f.line : ''))); head.appendChild(acts); el.appendChild(head); el.appendChild(body);
|
|
487
|
+
takeOver(v, had);
|
|
488
|
+
document.body.appendChild(el); document.documentElement.classList.add('rm-viewing'); close.focus();
|
|
489
|
+
var generation = v.generation;
|
|
490
|
+
W.preview.render({
|
|
491
|
+
body: body, acts: acts,
|
|
492
|
+
alive: function () { return viewer === v && v.generation === generation && scope.alive; },
|
|
493
|
+
request: function (u, foreground, type) { return imageRequest(u, function () { return viewer === v && scope.alive; }, foreground !== false, type); },
|
|
494
|
+
setUrl: function (u) { revoke(v); v.url = u; }, url: function () { return v.url; }
|
|
495
|
+
}, scope, f);
|
|
496
|
+
}
|
|
497
|
+
// A card where each local link sits: [data-reader-link="N"] inside a [data-reader-links] list from the server.
|
|
498
|
+
function linkThumb(scope, card, record) {
|
|
499
|
+
var t = card.thumb; if (!t) return;
|
|
500
|
+
var generation = ++card.generation;
|
|
501
|
+
imageRequest(W.preview.url(scope, card.f.id, 'thumb'), function () { return scope.alive && generation === card.generation; }, false).then(function (blob) {
|
|
502
|
+
if (!scope.alive || generation !== card.generation) return;
|
|
503
|
+
if (!blob || !blob.size) throw new Error('No thumbnail');
|
|
504
|
+
revoke(card); card.url = URL.createObjectURL(new Blob([blob], { type: 'image/png' }));
|
|
505
|
+
t.textContent = ''; var img = element('img'); img.alt = ''; img.src = card.url; t.appendChild(img);
|
|
506
|
+
}).catch(function (err) {
|
|
507
|
+
if (!scope.alive || generation !== card.generation) return;
|
|
508
|
+
// 7.0.4 QA's busy rule: a busy Mac is asked again after about 1, 2, 4 and 8 seconds, then the card stays plain.
|
|
509
|
+
if (busy(err) && (card.busyTries || 0) < BUSY_RETRIES) {
|
|
510
|
+
var delay = busyDelay(card.busyTries || 0); card.busyTries = (card.busyTries || 0) + 1;
|
|
511
|
+
setTimeout(function () { if (scope.alive && generation === card.generation && !card.url) linkThumb(scope, card, record); }, delay);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// No Quick Look picture on this Mac: an honest card, never a broken image.
|
|
515
|
+
t.textContent = 'PDF'; t.title = 'No preview image';
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
function mountLinks(host, scope) {
|
|
519
|
+
if (!W.preview) return;
|
|
520
|
+
Array.prototype.slice.call(host.querySelectorAll('[data-reader-links]')).forEach(function (list) {
|
|
521
|
+
if (scope.records.has(list)) return;
|
|
522
|
+
var raw; try { raw = JSON.parse(list.getAttribute('data-reader-links') || '[]'); } catch (_e) { raw = []; }
|
|
523
|
+
var dtos = Array.isArray(raw) ? raw.slice(0, 20) : [];
|
|
524
|
+
var record = { items: [] }; scope.records.set(list, record);
|
|
525
|
+
var io = toolObserver(scope);
|
|
526
|
+
Array.prototype.slice.call(list.querySelectorAll('[data-reader-link]')).forEach(function (slot) {
|
|
527
|
+
var n = Math.floor(Number(slot.getAttribute('data-reader-link')));
|
|
528
|
+
var dto = n >= 0 && n < dtos.length ? dtos[n] : null;
|
|
529
|
+
var card = dto && W.preview.chip(scope, dto, slot.textContent, function (f, el) { openPreview(scope, f, el); });
|
|
530
|
+
if (!card) return;
|
|
531
|
+
record.items.push(card); card.open = card.el;
|
|
532
|
+
slot.textContent = ''; slot.appendChild(card.el);
|
|
533
|
+
if (card.thumb) { if (io) { card.el.__cosToolItem = null; card.el.__cosLinkCard = card; io.observe(card.el); } else linkThumb(scope, card, record); }
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
W.readerMedia = { mount: mount, unmount: unmount, isOpen: function () { return !!viewer; }, close: closeViewer, popIsMine: popIsMine, openPreview: openPreview };
|
|
153
539
|
W.registerPane('readerMedia', W.readerMedia);
|
|
154
540
|
})();
|
|
@@ -25,6 +25,34 @@
|
|
|
25
25
|
.sx-views { justify-content: space-between; margin: 10px 0 8px; }
|
|
26
26
|
.sx-clock { margin-left: auto; }
|
|
27
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; } }
|
|
28
56
|
@media (max-width: 599px) {
|
|
29
57
|
.sx-views { gap: 2px 18px; }
|
|
30
58
|
.sx-clock { margin-left: 0; }
|
|
@@ -164,9 +192,10 @@
|
|
|
164
192
|
.sx-md a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }
|
|
165
193
|
.sx-md del { color: var(--sub); }
|
|
166
194
|
.sx-table { overflow-x: auto; max-width: 100%; margin: 0 0 0.8em; }
|
|
167
|
-
|
|
168
|
-
.sx-table
|
|
169
|
-
.sx-table th {
|
|
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); }
|
|
170
199
|
|
|
171
200
|
.sx-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 16px 0 4px; }
|
|
172
201
|
.sx-foot-note { font-size: 11.5px; color: var(--tertiary); }
|