@songsid/agend 2.3.3 → 2.3.4-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +3 -3
  2. package/README.zh-TW.md +3 -3
  3. package/dist/agent-cli-instructions.md +1 -1
  4. package/dist/auth-api.d.ts +16 -3
  5. package/dist/auth-api.js +22 -6
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +15 -1
  8. package/dist/authority/web-authority.js +27 -10
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/backend/claude-code.js +11 -1
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +12 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/types.d.ts +7 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.js +13 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/chat-log-ids.d.ts +48 -0
  19. package/dist/chat-log-ids.js +182 -0
  20. package/dist/chat-log-ids.js.map +1 -0
  21. package/dist/classic-channel-manager.d.ts +8 -1
  22. package/dist/classic-channel-manager.js +24 -2
  23. package/dist/classic-channel-manager.js.map +1 -1
  24. package/dist/config-validator.js +18 -1
  25. package/dist/config-validator.js.map +1 -1
  26. package/dist/daemon.js +8 -1
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/env-file.d.ts +2 -0
  29. package/dist/env-file.js +13 -8
  30. package/dist/env-file.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +42 -1
  32. package/dist/fleet-manager.js +206 -23
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -1
  35. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
  36. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  37. package/dist/instance-config-impact.js +1 -0
  38. package/dist/instance-config-impact.js.map +1 -1
  39. package/dist/instance-lifecycle.d.ts +2 -0
  40. package/dist/instance-lifecycle.js +23 -7
  41. package/dist/instance-lifecycle.js.map +1 -1
  42. package/dist/instructions.d.ts +1 -1
  43. package/dist/instructions.js +1 -1
  44. package/dist/instructions.js.map +1 -1
  45. package/dist/interaction-observation.d.ts +1 -0
  46. package/dist/interaction-observation.js +1 -1
  47. package/dist/interaction-observation.js.map +1 -1
  48. package/dist/locale.js +48 -0
  49. package/dist/locale.js.map +1 -1
  50. package/dist/next-steps.d.ts +12 -2
  51. package/dist/next-steps.js +19 -3
  52. package/dist/next-steps.js.map +1 -1
  53. package/dist/office-share-service.d.ts +57 -0
  54. package/dist/office-share-service.js +76 -11
  55. package/dist/office-share-service.js.map +1 -1
  56. package/dist/office-share.d.ts +8 -0
  57. package/dist/office-share.js +34 -0
  58. package/dist/office-share.js.map +1 -1
  59. package/dist/outbound-schemas.js +1 -1
  60. package/dist/outbound-schemas.js.map +1 -1
  61. package/dist/page-share.d.ts +122 -0
  62. package/dist/page-share.js +199 -0
  63. package/dist/page-share.js.map +1 -0
  64. package/dist/public-preview.d.ts +4 -0
  65. package/dist/public-preview.js +5 -4
  66. package/dist/public-preview.js.map +1 -1
  67. package/dist/public-web-gateway.js +5 -0
  68. package/dist/public-web-gateway.js.map +1 -1
  69. package/dist/quickstart-api.d.ts +5 -0
  70. package/dist/quickstart-api.js +1 -1
  71. package/dist/quickstart-api.js.map +1 -1
  72. package/dist/quickstart.d.ts +40 -0
  73. package/dist/quickstart.js +174 -28
  74. package/dist/quickstart.js.map +1 -1
  75. package/dist/remote-card-answer.js +2 -0
  76. package/dist/remote-card-answer.js.map +1 -1
  77. package/dist/remote-card-prompt.js +4 -2
  78. package/dist/remote-card-prompt.js.map +1 -1
  79. package/dist/selection-card.d.ts +21 -0
  80. package/dist/selection-card.js +129 -0
  81. package/dist/selection-card.js.map +1 -0
  82. package/dist/settings-api.js +4 -0
  83. package/dist/settings-api.js.map +1 -1
  84. package/dist/settings-change.js +5 -0
  85. package/dist/settings-change.js.map +1 -1
  86. package/dist/settings-effect.js +2 -0
  87. package/dist/settings-effect.js.map +1 -1
  88. package/dist/setup-host.js +2 -2
  89. package/dist/setup-host.js.map +1 -1
  90. package/dist/setup-wizard.js +2 -2
  91. package/dist/setup-wizard.js.map +1 -1
  92. package/dist/types.d.ts +10 -0
  93. package/dist/ui/chat-store.js +8 -1
  94. package/dist/ui/chat-strings.js +38 -2
  95. package/dist/ui/chat-thread.js +52 -10
  96. package/dist/ui/page-share-dialog.js +191 -0
  97. package/dist/ui/panel-chat.js +14 -0
  98. package/dist/ui/panel-notices.js +98 -0
  99. package/dist/ui/panel-settings.js +9 -2
  100. package/dist/ui/preview-pages.js +41 -0
  101. package/dist/ui/preview-panel-actions.js +161 -0
  102. package/dist/ui/preview-panel.js +209 -28
  103. package/dist/ui/preview-versions.js +155 -0
  104. package/dist/ui/preview.js +88 -3
  105. package/dist/ui/settings-registry.js +1 -0
  106. package/dist/ui/settings-strings.js +4 -0
  107. package/dist/ui/shared/app-i18n.js +2 -2
  108. package/dist/ui/shared/app-route.js +4 -0
  109. package/dist/ui/shared/app-shell.js +5 -3
  110. package/dist/ui/shared/app-store.js +71 -1
  111. package/dist/ui/shared/app-stream.js +1 -1
  112. package/dist/ui/shared/app.css +57 -3
  113. package/dist/ui/shared/app.js +22 -1
  114. package/dist/ui/shared/signin.js +12 -1
  115. package/dist/ui/shared/ui-icons.js +3 -0
  116. package/dist/ui/shared/web-authorize.js +3 -1
  117. package/dist/web-api.d.ts +26 -0
  118. package/dist/web-api.js +180 -1
  119. package/dist/web-api.js.map +1 -1
  120. package/dist/web-auth.js +7 -1
  121. package/dist/web-auth.js.map +1 -1
  122. package/dist/web-login.d.ts +2 -0
  123. package/dist/web-login.js +3 -1
  124. package/dist/web-login.js.map +1 -1
  125. package/dist/web-notices.d.ts +51 -0
  126. package/dist/web-notices.js +118 -0
  127. package/dist/web-notices.js.map +1 -0
  128. package/dist/web-only-fleet.d.ts +22 -0
  129. package/dist/web-only-fleet.js +13 -0
  130. package/dist/web-only-fleet.js.map +1 -0
  131. package/dist/web-preview.d.ts +32 -3
  132. package/dist/web-preview.js +52 -19
  133. package/dist/web-preview.js.map +1 -1
  134. package/dist/web-session.d.ts +7 -0
  135. package/dist/web-session.js +2 -0
  136. package/dist/web-session.js.map +1 -1
  137. package/dist/web-settings-patch.d.ts +3 -1
  138. package/dist/web-settings-patch.js +4 -1
  139. package/dist/web-settings-patch.js.map +1 -1
  140. package/dist/web-shell-routes.d.ts +2 -0
  141. package/dist/web-shell-routes.js +3 -0
  142. package/dist/web-shell-routes.js.map +1 -1
  143. package/package.json +1 -1
@@ -0,0 +1,161 @@
1
+ // #2911: the preview panel's feedback actions — what a person tells the agent about the page it made (M-2217). The
2
+ // panel (preview-panel.js, M-2216's chrome) renders <PanelActions> in its bar, before Download; everything the actions
3
+ // do lives here, so neither side edits the other's file.
4
+ //
5
+ // Nothing here reads or relays the page. A preview's content is the agent's untrusted HTML, so only what the chat
6
+ // already holds goes into the message: a #2272 reply to the message the page came in, and the page's <title> or file
7
+ // name as one line of plain text in the composer — where the person edits it and sends it themselves. What goes is an
8
+ // ordinary message (Discord first): no new channel, nothing sent by itself.
9
+
10
+ import { html, useEffect, useState } from "/assets/app-html.js";
11
+ import { t } from "/assets/app-i18n.js";
12
+ import { Icon } from "/assets/ui-icons.js";
13
+ import { closePanel, frameKey, htmlTitle } from "./preview-panel.js";
14
+ import { PageShareButton } from "./page-share-dialog.js";
15
+
16
+ const P = () => globalThis.AgendPreview;
17
+
18
+ /**
19
+ * The chat registers how to reach its composer (panel-chat.js): { findMsg(instance, msgKey), startReply(instance, msg),
20
+ * clearReply(instance), addToDraft(instance, line), focus() }. null: no chat is mounted, and the actions are not shown.
21
+ */
22
+ let chat = null;
23
+ export function setPanelFeedback(deps) { chat = deps || null; }
24
+
25
+ /** The most of a page's title or file name the line carries. */
26
+ export const NAME_MAX = 80;
27
+
28
+ /** One line of plain text: format characters removed, whitespace and control characters collapsed, cut at NAME_MAX characters. */
29
+ export function plainName(value) {
30
+ // \p{Cf} first (bidi controls, zero-widths): what the person reads is what is sent (#2914 review).
31
+ const s = String(value == null ? "" : value).replace(/\p{Cf}+/gu, "").replace(/[\s\u0000-\u001f\u007f-\u009f\u2028\u2029]+/gu, " ").trim();
32
+ const chars = [...s];
33
+ return chars.length > NAME_MAX ? `${chars.slice(0, NAME_MAX - 1).join("")}…` : s;
34
+ }
35
+
36
+ /** The composer's line naming the page: its <title>, else the attached file's name, else "this page". */
37
+ export function aboutLine(open) {
38
+ if (!open) return "";
39
+ const name = plainName(htmlTitle(open.code || "")) || plainName(open.att && open.att.name);
40
+ return name ? t("chat.pvFbAboutNamed", name) : t("chat.pvFbAboutThis");
41
+ }
42
+
43
+ /**
44
+ * Reply about the open page: a reply to the message it came in (while the thread still has it), and the line naming
45
+ * the page added to the composer (never twice). Returns false when there is nothing to do it with.
46
+ */
47
+ export function replyAboutPage(open, deps = chat) {
48
+ if (!open || !deps) return false;
49
+ const msg = open.msgKey ? deps.findMsg(open.instance, open.msgKey) : null;
50
+ // The page's message gone from the thread: no reply — and not one to another message started earlier, which the
51
+ // line naming this page would be sent as (#2913 review).
52
+ if (msg) deps.startReply(open.instance, msg);
53
+ else deps.clearReply(open.instance);
54
+ deps.addToDraft(open.instance, aboutLine(open));
55
+ deps.focus();
56
+ return true;
57
+ }
58
+
59
+ /**
60
+ * #2911: the lines the errors chip adds — a header saying the text below is the page's, then each kept error quoted
61
+ * ("> "), already one line of plain text (preview.js), and how many more there were. `log`: P().errors(key).
62
+ */
63
+ export function errorLines(log) {
64
+ if (!log || !log.count) return [];
65
+ const kind = { error: "", rejection: t("chat.pvFbErrRejection"), console: "console.error: ", blocked: t("chat.pvCdnBlocked") };
66
+ const lines = [t(log.count === 1 ? "chat.pvFbErrHeadOne" : "chat.pvFbErrHead", String(log.count))];
67
+ for (const x of log.items) lines.push(`> ${kind[x.kind] ?? ""}${plainName(x.message)}${x.line ? ` (${t("chat.pvFbErrAt", String(x.line), String(x.col))})` : ""}`);
68
+ const more = log.count - log.items.length;
69
+ if (more > 0) lines.push(t("chat.pvFbErrMore", String(more)));
70
+ return lines;
71
+ }
72
+
73
+ /** The errors chip's click: reply about the page, then the error lines (each once) — the person edits and sends. */
74
+ export function sendErrors(open, log, deps = chat) {
75
+ if (!replyAboutPage(open, deps)) return false;
76
+ for (const line of errorLines(log)) deps.addToDraft(open.instance, line);
77
+ deps.focus();
78
+ return true;
79
+ }
80
+
81
+ /**
82
+ * #2911: the line a picked element adds — where in the page (the selector the page's frame built, as code) and its
83
+ * words, marked as the page's text. Backticks are taken out of the selector so it stays one code span.
84
+ */
85
+ export function pickLine(result) {
86
+ if (!result) return "";
87
+ const where = plainName(String(result.selector || "").replace(/`/g, "")) || "?";
88
+ const words = plainName(result.text);
89
+ return words ? t("chat.pvFbPickWords", where, words) : t("chat.pvFbPickAt", where);
90
+ }
91
+
92
+ /** A picked element: reply about the page, then its line — the person writes what to change after it. */
93
+ export function sendPick(open, result, deps = chat) {
94
+ if (!result || !replyAboutPage(open, deps)) return false;
95
+ deps.addToDraft(open.instance, pickLine(result));
96
+ deps.focus();
97
+ return true;
98
+ }
99
+
100
+ /** Whether the open page's frame is in pick mode, and its answer handed to sendPick. */
101
+ function usePick(open) {
102
+ const key = open ? frameKey(open.key) : null;
103
+ const [on, setOn] = useState(() => !!(key && P() && P().picking && P().picking(key)));
104
+ useEffect(() => {
105
+ setOn(!!(key && P() && P().picking && P().picking(key)));
106
+ if (!key || !P() || typeof P().onPick !== "function") return undefined;
107
+ return P().onPick((k, result, now) => {
108
+ if (k !== key) return;
109
+ setOn(!!now);
110
+ if (result) { if (narrow()) closePanel(); sendPick(open, result); }
111
+ });
112
+ }, [key]);
113
+ return on;
114
+ }
115
+
116
+ /** The errors the open page's frame reported, re-read when they change. */
117
+ function useErrors(open) {
118
+ const key = open ? frameKey(open.key) : null;
119
+ const read = () => (key && P() && typeof P().errors === "function" ? P().errors(key) : null);
120
+ const [log, setLog] = useState(read);
121
+ useEffect(() => {
122
+ setLog(read());
123
+ if (!key || !P() || typeof P().onErrors !== "function") return undefined;
124
+ return P().onErrors((k) => { if (k === key) setLog(read()); });
125
+ }, [key]);
126
+ return log;
127
+ }
128
+
129
+ /**
130
+ * #2911 item 8: what Share page names — the block of the message the open page came in ({ boot, id } and its fence
131
+ * number or attachment id; the server reads the page itself), its title, and whose reply. null while the thread no
132
+ * longer has that message.
133
+ */
134
+ export function shareBlock(open, deps = chat) {
135
+ if (!open || !deps) return null;
136
+ const msg = open.msgKey ? deps.findMsg(open.instance, open.msgKey) : null;
137
+ if (!msg || typeof msg.boot !== "string" || typeof msg.id !== "number") return null;
138
+ const from = open.att && typeof open.att.id === "string" ? { boot: msg.boot, id: msg.id, att: open.att.id }
139
+ : Number.isInteger(open.n) ? { boot: msg.boot, id: msg.id, fence: open.n } : null;
140
+ if (!from) return null;
141
+ const title = plainName(htmlTitle(open.code || "")) || plainName(open.att && open.att.name);
142
+ return { instance: open.instance, from, title, sender: msg.sender || open.sender || "", ts: msg.ts || open.ts || "" };
143
+ }
144
+
145
+ const narrow = () => typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(max-width: 899px)").matches;
146
+
147
+ /** The bar's actions for the open page. props: open, name, run, view (as the panel has them). */
148
+ export function PanelActions({ open, run }) {
149
+ const log = useErrors(open);
150
+ const picking = usePick(open);
151
+ if (!open || !chat) return null;
152
+ // On a phone the panel is a sheet over the composer: it closes, so the composer is there to type in.
153
+ const reply = () => { if (narrow()) closePanel(); replyAboutPage(open); };
154
+ const send = () => { const now = P().errors(frameKey(open.key)); if (narrow()) closePanel(); sendErrors(open, now); };
155
+ const n = log ? log.count : 0;
156
+ const key = open && frameKey(open.key);
157
+ const canPick = run && run.state === "running" && typeof P().pick === "function";
158
+ const point = () => { P().pick(key, !picking); };
159
+ return html`<${PageShareButton} block=${shareBlock(open)} Icon=${Icon} />${canPick ? html`<button type="button" class=${`btn btn-sm pv-fb-pick${picking ? " on" : ""}`} aria-pressed=${picking ? "true" : "false"}
160
+ title=${t(picking ? "chat.pvFbPickCancelTitle" : "chat.pvFbPickTitle")} onClick=${point}><${Icon} name="edit" size=${14} /><span class="lbl">${t(picking ? "chat.pvFbPickCancel" : "chat.pvFbPick")}</span></button>` : null}${n ? html`<button type="button" class="btn btn-sm pv-fb-errors" title=${t("chat.pvFbErrTitle")} onClick=${send}><${Icon} name="alert" size=${14} /><span class="pv-fb-n">${n >= P().ERRORS.maxCount ? `${n}+` : String(n)}</span><span class="lbl">${t(n === 1 ? "chat.pvFbErrOne" : "chat.pvFbErrMany")}</span></button>` : null}<button type="button" class="btn btn-sm pv-fb-reply" title=${t("chat.pvFbReplyTitle")} onClick=${reply}><${Icon} name="chat" size=${14} /><span class="lbl">${t("chat.pvFbReply")}</span></button>`;
161
+ }
@@ -17,6 +17,11 @@ import "./chat-render.js";
17
17
  import "./preview.js";
18
18
  import { loadHtmlAttachment } from "./html-attachment.js";
19
19
  import { chatTime, onDayChange } from "./chat-time.js";
20
+ import { PanelActions } from "./preview-panel-actions.js";
21
+ import { htmlTitle, versionsOf, lineDiff, diffHunks, DIFF_LIMITS } from "./preview-versions.js";
22
+ import { pagesOf, pageIdOf } from "./preview-pages.js";
23
+
24
+ export { htmlTitle };
20
25
 
21
26
  const R = () => globalThis.AgendChatRender;
22
27
  const P = () => globalThis.AgendPreview;
@@ -60,12 +65,6 @@ export function closePanel() {
60
65
  /** The card key the panel shows, or null. */
61
66
  export const shownKey = () => (state.open ? state.open.key : null);
62
67
 
63
- /** The text of a block's <title>, or "" — what says two blocks are versions of one thing. */
64
- export function htmlTitle(code) {
65
- const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(String(code || ""));
66
- return m ? m[1].replace(/\s+/g, " ").trim() : "";
67
- }
68
-
69
68
  /**
70
69
  * A newer version of the shown block: the last complete ```html block in an agent reply after the one it came from.
71
70
  * When the shown block has a <title>, only a block with the same title counts (another page is not a new version);
@@ -118,6 +117,30 @@ export function clampWidth(w, total) {
118
117
  return Math.round(Math.min(max, Math.max(MIN_PANEL, w)));
119
118
  }
120
119
 
120
+ // ── #2911 (7): a CDN library the preview blocked (web.preview_cdn off) — the panel says where to allow it ──
121
+
122
+ /** web-preview.ts PREVIEW_CDN_SOURCES (a test keeps the two equal): what web.preview_cdn allows. */
123
+ export const CDN_PREFIXES = ["https://cdnjs.cloudflare.com/ajax/libs/", "https://cdn.jsdelivr.net/npm/"];
124
+ /** The CDN host of the first blocked library in a frame's error log that the setting would allow, or null. */
125
+ export function blockedCdnHost(log) {
126
+ for (const x of (log && log.items) || []) {
127
+ if (x.kind !== "blocked") continue;
128
+ const p = CDN_PREFIXES.find(pre => String(x.message).startsWith(pre));
129
+ if (p) return new URL(p).host;
130
+ }
131
+ return null;
132
+ }
133
+
134
+ // ── #2911 (2): the page's width in the panel (a phone, a tablet, or the panel's own), this device's choice ──
135
+
136
+ const DEVICE_KEY = "agend_preview_panel_device";
137
+ /** The widths a preview can be shown at: CSS px, or null for the panel's full width. */
138
+ export const DEVICE_WIDTHS = { full: null, tablet: 768, phone: 390 };
139
+ export function storedDevice() { try { const v = localStorage.getItem(DEVICE_KEY); return Object.hasOwn(DEVICE_WIDTHS, v) ? v : "full"; } catch { return "full"; } }
140
+ function storeDevice(v) { try { localStorage.setItem(DEVICE_KEY, v); } catch { /* this page only */ } }
141
+ /** The element the page has fullscreen (standard, else webkit's), or null. */
142
+ const fullscreenEl = () => { const d = globalThis.document; return (d && (d.fullscreenElement || d.webkitFullscreenElement)) || null; };
143
+
121
144
  /** #1770: as the thread shows it — the fleet's zone, with the day when it is not today. */
122
145
  const timeOf = (ts) => (ts ? chatTime(ts, { yesterday: (hm) => t("chat.yesterdayAt", hm) }) : "");
123
146
 
@@ -133,10 +156,53 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
133
156
  const [run, setRun] = useState({ state: "idle", reason: "" });
134
157
  const [newer, setNewer] = useState(null);
135
158
  const [newerNote, setNewerNote] = useState("");
159
+ const [vers, setVers] = useState({ list: [], index: -1 }); // #2911 (1): the shown block's versions in the thread
160
+ const [pages, setPages] = useState([]); // #2911 (6): every page in the thread, newest first
161
+ const [cdnHost, setCdnHost] = useState(null); // #2911 (7): a CDN library this page's run was refused
162
+ const [listing, setListing] = useState(false); // the page list is on screen (instead of the page)
163
+ const [changes, setChanges] = useState(null); // the Changes view: { state, from, hunks, shown, total }
164
+ const keepView = useRef(false); // a version step keeps Code / Changes on screen
165
+ const loaded = useRef(new Map()); // an attached version's HTML, read once (key → code)
136
166
  const reading = useRef(null); // the newer version whose file is being read (one at a time)
137
167
  const pending = useRef(0); // #1704: the card's click, waiting for this device's Allow (the st.run it came with)
138
168
  const ownAllow = useRef(0); // while this panel's own Allow commits its choice: the click it answers (not cancelled by it)
139
169
  const holder = useRef(null), divider = useRef(null), panel = useRef(null);
170
+ const [device, setDeviceState] = useState(storedDevice); // #2911 (2): the page's width
171
+ const setDevice = (v) => { setDeviceState(v); storeDevice(v); };
172
+ // Full screen: the Fullscreen API on the panel (the dashboard's document — the frame's own policy still denies it),
173
+ // or, where the page may not, the panel over the whole window ("immersive"). Esc leaves it before it closes the panel.
174
+ const [fs, setFs] = useState("");
175
+ const fsTarget = useRef(null); // the element asked to go full screen (the panel may be gone when it is left)
176
+ // #2918 review: each request has a generation; leaving (Esc, the button, a close, the chat unmounting) moves it, so a
177
+ // request still pending answers nobody — a late refusal does not make a later open immersive, and a late success
178
+ // only takes back the full screen it made.
179
+ const fsGen = useRef(0);
180
+ const exitOwn = (el) => {
181
+ const d = globalThis.document;
182
+ if (!d || !el || fullscreenEl() !== el) return;
183
+ const ex = d.exitFullscreen || d.webkitExitFullscreen;
184
+ try { const r = typeof ex === "function" ? ex.call(d) : null; if (r && typeof r.catch === "function") r.catch(() => {}); } catch { /* already out */ }
185
+ };
186
+ const enterFs = () => {
187
+ const el = panel.current, d = globalThis.document; if (!el || fs) return;
188
+ const gen = ++fsGen.current;
189
+ const req = el.requestFullscreen || el.webkitRequestFullscreen;
190
+ const enabled = d ? (d.fullscreenEnabled ?? d.webkitFullscreenEnabled) : false;
191
+ if (typeof req !== "function" || enabled === false) { setFs("immersive"); return; }
192
+ let r;
193
+ fsTarget.current = el;
194
+ try { r = req.call(el); } catch { fsTarget.current = null; setFs("immersive"); return; }
195
+ if (r && typeof r.then === "function") {
196
+ r.then(() => { if (fsGen.current === gen) { if (fullscreenEl() === el) setFs("native"); } else exitOwn(el); },
197
+ () => { if (fsGen.current === gen) { fsTarget.current = null; setFs("immersive"); } });
198
+ } else setFs("native");
199
+ };
200
+ function leaveFs() {
201
+ fsGen.current++;
202
+ const el = fsTarget.current; fsTarget.current = null;
203
+ exitOwn(el);
204
+ setFs("");
205
+ }
140
206
  const open = st.open?.instance === name ? st.open : null;
141
207
 
142
208
  useEffect(() => panelStore.subscribe(setSt), []);
@@ -147,6 +213,15 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
147
213
  useEffect(() => (P() ? P().onChange(() => { if (pending.current && ownAllow.current !== pending.current) pending.current = 0; choice(n => n + 1); }) : undefined), []);
148
214
  // Leaving the chat (another instance, another panel) closes the panel: its frame stops with the thread's.
149
215
  useEffect(() => () => closePanel(), []);
216
+ // Leaving the chat: a pending request answers nobody, and our own full screen is taken back.
217
+ useEffect(() => () => { fsGen.current++; exitOwn(fsTarget.current); fsTarget.current = null; }, []);
218
+ // Full screen ends with the browser's own Esc or its exit control too: follow it.
219
+ useEffect(() => {
220
+ const d = globalThis.document; if (!d || !d.addEventListener) return undefined;
221
+ const sync = () => setFs(f => (f === "native" && fullscreenEl() !== fsTarget.current ? "" : f));
222
+ d.addEventListener("fullscreenchange", sync); d.addEventListener("webkitfullscreenchange", sync);
223
+ return () => { d.removeEventListener("fullscreenchange", sync); d.removeEventListener("webkitfullscreenchange", sync); };
224
+ }, []);
150
225
  // #1770: the source and newer-version labels say the day as the thread does — drawn again when the fleet's day
151
226
  // changes, while a block is open. A render: the frame and the run are left as they are.
152
227
  const [, dayTick] = useState(0);
@@ -166,7 +241,9 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
166
241
  // change of this device's choice cancels it.
167
242
  useEffect(() => {
168
243
  setRun({ state: "idle", reason: "" });
169
- setView("preview");
244
+ if (!keepView.current) setView("preview");
245
+ setListing(false); // #2920 review: a new block on screen leaves the page list
246
+ keepView.current = false;
170
247
  if (open && st.run) {
171
248
  if (P().availability().ok) start();
172
249
  else pending.current = st.run;
@@ -174,11 +251,21 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
174
251
  // Focus goes to the panel's close control that is on screen (Back on a phone, × beside the thread).
175
252
  if (panel.current) { const f = [...panel.current.querySelectorAll(".pv-panel-close")].find(b => b.offsetParent !== null); if (f) f.focus(); }
176
253
  }, [open && open.key, st.run]);
254
+ // The panel closing, or showing another instance's block: out of full screen.
255
+ useEffect(() => { if (!open) leaveFs(); }, [!!open]);
256
+ // #2911 (7): the running page's blocked CDN library, read from its error log (preview.js), kept as it changes.
257
+ useEffect(() => {
258
+ const key = open ? frameKey(open.key) : null;
259
+ const read = () => setCdnHost(key && P() && typeof P().errors === "function" ? blockedCdnHost(P().errors(key)) : null);
260
+ read();
261
+ if (!key || !P() || typeof P().onErrors !== "function") return undefined;
262
+ return P().onErrors((k) => { if (k === key) read(); });
263
+ }, [open && open.key]);
177
264
  // A later reply with a newer version of this block: offered, not swapped in.
178
265
  useEffect(() => {
179
266
  setNewerNote(""); reading.current = null;
180
267
  if (!open) { setNewer(null); return undefined; }
181
- const check = () => setNewer(newerVersion(open, msgs() || [], keyOf));
268
+ const check = () => { const all = msgs() || []; setNewer(newerVersion(open, all, keyOf)); setVers(versionsOf(open, all, keyOf)); setPages(pagesOf(all, keyOf, name)); };
182
269
  check();
183
270
  return subscribe((instance, kind) => { if (instance === name && kind === "msgs") check(); });
184
271
  }, [open && open.key, name]);
@@ -219,24 +306,70 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
219
306
  el.addEventListener("pointerup", up);
220
307
  el.addEventListener("pointercancel", up);
221
308
  };
222
- // Show the newer version: an attached file's HTML is read first. A read the panel moved on from is dropped.
223
- const showNewer = async () => {
224
- if (!newer || reading.current) return;
225
- if (newer.code != null) { openPanel(newer); return; }
226
- const was = open && open.key, mine = reading.current = newer;
309
+ // Show another version (the newer one offered, or a step of ‹ ›): an attached file's HTML is read first. A read the
310
+ // panel moved on from is dropped. It is shown as Show it shows one — waiting for Preview, never started by the step.
311
+ const attReason = (r) => t(r.reason === "over" ? "chat.pvAttOver" : r.reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed");
312
+ // #2920 review: a newer choice supersedes a read still waiting (it opens, or claims, and so moves the generation the
313
+ // waiting read checks) — it is never ignored because another read is pending.
314
+ const show = async (spec, { keep = false } = {}) => {
315
+ if (!spec) return;
316
+ const cached = spec.code == null ? loaded.current.get(spec.key) : undefined;
317
+ if (spec.code != null || cached != null) { keepView.current = keep; openPanel(cached != null ? { ...spec, code: cached } : spec); return; }
318
+ // #2915 review: the panel's generation, not just the key — a close and reopen of the same block while this read
319
+ // waits moves it, and the read is then dropped (claimPanel also drops any earlier click still reading).
320
+ const gen = claimPanel(), mine = reading.current = spec;
227
321
  setNewerNote(t("chat.pvLoading"));
228
- // A click on the newer version reads it whatever its size (up to the 50 MB ceiling); over 8 MB the panel then asks.
229
- const r = await loadHtmlAttachment(newer.att, undefined, { anyway: true });
230
- if (reading.current !== mine) return;
231
- reading.current = null;
232
- const now = panelStore.get().open;
233
- if (!now || now.key !== was) return;
234
- if (!r.ok) { setNewerNote(t(r.reason === "over" ? "chat.pvAttOver" : r.reason === "gone" ? "chat.pvAttGone" : "chat.pvAttFailed")); return; }
235
- openPanel({ ...newer, code: r.code });
322
+ // A click on a version reads it whatever its size (up to the 50 MB ceiling); over 8 MB the panel then asks.
323
+ const r = await loadHtmlAttachment(spec.att, undefined, { anyway: true });
324
+ if (reading.current === mine) reading.current = null;
325
+ if (panelVersion() !== gen) return;
326
+ if (!r.ok) { setNewerNote(attReason(r)); return; }
327
+ setNewerNote("");
328
+ loaded.current.set(spec.key, r.code);
329
+ keepView.current = keep;
330
+ openPanel({ ...spec, code: r.code });
236
331
  };
332
+ const showNewer = () => show(newer);
333
+ /** A view button: that view on screen — the page list, if open, gives way to it (#2920 review). */
334
+ const pickView = (v) => { setListing(false); setView(v); };
335
+ const step = (by) => { const v = vers.list[vers.index + by]; if (vers.index >= 0 && v) show(v, { keep: true }); };
336
+ // Changes: the previous version against this one, a line diff (bounded: DIFF_LIMITS). An attached previous version
337
+ // is read for it, once.
338
+ const prev = vers.index > 0 ? vers.list[vers.index - 1] : null;
339
+ // A step to the first version from Changes: nothing to compare it with, so the page is shown (Changes is kept as the
340
+ // choice, and comes back with the next step that has a previous version).
341
+ const shown = view === "changes" && !prev ? "preview" : view;
342
+ useEffect(() => {
343
+ if (shown !== "changes" || !open) { setChanges(null); return undefined; }
344
+ let live = true;
345
+ const from = vers.index; // "Changes from v{from}" (1-based: the previous one)
346
+ const diff = (before) => {
347
+ const ops = lineDiff(before, open.code);
348
+ if (!ops) return { state: "big", from };
349
+ const hunks = diffHunks(ops);
350
+ let total = 0; for (const h of hunks) total += h.lines.length + 1;
351
+ return { state: "ready", from, hunks, total };
352
+ };
353
+ const known = prev.code != null ? prev.code : loaded.current.get(prev.key);
354
+ if (known != null) setChanges(diff(known));
355
+ else {
356
+ setChanges({ state: "loading", from });
357
+ loadHtmlAttachment(prev.att, undefined, { anyway: true }).then((r) => {
358
+ if (!live) return;
359
+ if (!r.ok) { setChanges({ state: "failed", from, reason: attReason(r) }); return; }
360
+ loaded.current.set(prev.key, r.code);
361
+ setChanges(diff(r.code));
362
+ });
363
+ }
364
+ return () => { live = false; };
365
+ }, [shown, open && open.key, prev && prev.key]);
237
366
  const fileName = open && open.att ? open.att.name : undefined;
238
367
  // Esc in the panel closes it (and is not the chat's "stop the reply").
239
- const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
368
+ const onPanelKey = (e) => {
369
+ if (e.key !== "Escape" || e.isComposing) return;
370
+ e.preventDefault();
371
+ if (fs) leaveFs(); else closePanel();
372
+ };
240
373
 
241
374
  if (!open) return null;
242
375
  const going = run.state === "starting" || run.state === "running";
@@ -259,33 +392,81 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
259
392
  const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
260
393
  return html`<div data-trust="preview" class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
261
394
  aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
262
- <aside data-trust="preview" class="pv-panel" ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
395
+ <aside data-trust="preview" class=${`pv-panel${fs ? ` pv-fs pv-fs-${fs}` : ""}`} ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
263
396
  <div class="pv-panel-head">
264
397
  <button type="button" class="icon-btn only-narrow pv-panel-close" title=${t("chat.pvBack")} aria-label=${t("chat.pvBack")} onClick=${closePanel}><${Icon} name="back" size=${18} /></button>
265
398
  <div class="pv-panel-title"><strong>${t("chat.pvPanel")}</strong>
266
399
  <button type="button" class="pv-source" title=${source} onClick=${() => { if (window.matchMedia && window.matchMedia("(max-width: 899px)").matches) closePanel(); reveal(open.msgKey); }}>${source}</button></div>
400
+ ${pages.length > 1 ? html`<button type="button" class=${`btn btn-sm pv-pages-toggle${listing ? " on" : ""}`} aria-pressed=${listing ? "true" : "false"} title=${t("chat.pvPagesTitle")}
401
+ onClick=${() => setListing(v => !v)}><${Icon} name="file" size=${14} />${t("chat.pvPagesCount", pages.length)}</button>` : null}
267
402
  <button type="button" class="icon-btn only-wide pv-panel-close" title=${t("chat.pvClose")} aria-label=${t("chat.pvClose")} onClick=${closePanel}><${Icon} name="close" size=${18} /></button>
268
403
  </div>
269
404
  <div class="pv-panel-bar">
405
+ ${vers.list.length > 1 && vers.index >= 0 ? html`<div class="pv-ver" role="group" aria-label=${t("chat.pvVersions")}>
406
+ <button type="button" class="icon-btn pv-ver-prev" title=${t("chat.pvVerPrev")} aria-label=${t("chat.pvVerPrev")} disabled=${vers.index === 0} onClick=${() => step(-1)}><${Icon} name="prev" size=${16} /></button>
407
+ <span class="pv-ver-at" aria-live="polite" title=${t("chat.pvVerLong", vers.index + 1, vers.list.length)} aria-label=${t("chat.pvVerLong", vers.index + 1, vers.list.length)}>${t("chat.pvVer", vers.index + 1, vers.list.length)}</span>
408
+ <button type="button" class="icon-btn pv-ver-next" title=${t("chat.pvVerNext")} aria-label=${t("chat.pvVerNext")} disabled=${vers.index === vers.list.length - 1} onClick=${() => step(1)}><${Icon} name="next" size=${16} /></button>
409
+ </div>` : null}
270
410
  <div class="seg-inline" role="group" aria-label=${t("chat.pvPanel")}>
271
- <button type="button" class=${`btn btn-sm${view === "preview" ? " on" : ""}`} aria-pressed=${view === "preview" ? "true" : "false"} onClick=${() => setView("preview")}><${Icon} name="eye" size=${14} />${t("chat.pvPage")}</button>
272
- <button type="button" class=${`btn btn-sm${view === "code" ? " on" : ""}`} aria-pressed=${view === "code" ? "true" : "false"} onClick=${() => setView("code")}><${Icon} name="code" size=${14} />${t("chat.pvCode")}</button>
411
+ <button type="button" class=${`btn btn-sm${shown === "preview" ? " on" : ""}`} aria-pressed=${shown === "preview" ? "true" : "false"} title=${t("chat.pvPage")} onClick=${() => pickView("preview")}><${Icon} name="eye" size=${14} /><span class="lbl">${t("chat.pvPage")}</span></button>
412
+ <button type="button" class=${`btn btn-sm${shown === "code" ? " on" : ""}`} aria-pressed=${shown === "code" ? "true" : "false"} title=${t("chat.pvCode")} onClick=${() => pickView("code")}><${Icon} name="code" size=${14} /><span class="lbl">${t("chat.pvCode")}</span></button>
413
+ ${prev ? html`<button type="button" class=${`btn btn-sm pv-changes${shown === "changes" ? " on" : ""}`} aria-pressed=${shown === "changes" ? "true" : "false"} title=${t("chat.pvChanges")} onClick=${() => pickView("changes")}><${Icon} name="diff" size=${14} /><span class="lbl">${t("chat.pvChanges")}</span></button>` : null}
273
414
  </div>
274
415
  <span class="grow"></span>
416
+ ${shown === "preview" ? html`<select class="pv-width" aria-label=${t("chat.pvWidth")} title=${t("chat.pvWidth")} value=${device} onChange=${(e) => setDevice(e.currentTarget.value)}>
417
+ <option value="full">${t("chat.pvWidthFull")}</option><option value="tablet">${t("chat.pvWidthTablet")}</option><option value="phone">${t("chat.pvWidthPhone")}</option></select>` : null}
275
418
  ${going ? html`<button type="button" class="btn btn-sm pv-panel-reload" title=${t("chat.pvReload")} onClick=${() => { stop(); start(true); }}><${Icon} name="restart" size=${14} /><span class="lbl">${t("chat.pvReload")}</span></button>
276
419
  <button type="button" class="btn btn-sm pv-panel-stop" title=${t("chat.pvStop")} onClick=${stop}><${Icon} name="stop" size=${14} /><span class="lbl">${t("chat.pvStop")}</span></button>`
277
420
  : run.state === "large" ? html`<button type="button" class="btn btn-sm btn-primary pv-panel-anyway" title=${t("chat.pvAnyway")} disabled=${!a.ok} onClick=${() => start(true)}><${Icon} name="play" size=${14} /><span class="lbl">${t("chat.pvAnyway")}</span></button>`
278
421
  : html`<button type="button" class="btn btn-sm btn-primary pv-panel-run" title=${t("chat.pvPreview")} disabled=${!a.ok} onClick=${() => start()}><${Icon} name="play" size=${14} /><span class="lbl">${t("chat.pvPreview")}</span></button>`}
422
+ <button type="button" class=${`btn btn-sm only-wide pv-panel-fs${fs ? " on" : ""}`} aria-pressed=${fs ? "true" : "false"} title=${t(fs ? "chat.pvFsExit" : "chat.pvFs")} aria-label=${t(fs ? "chat.pvFsExit" : "chat.pvFs")}
423
+ onClick=${() => (fs ? leaveFs() : enterFs())}><${Icon} name=${fs ? "fullscreenexit" : "fullscreen"} size=${14} /></button>
424
+ <${PanelActions} open=${open} name=${name} run=${run} view=${view} />
279
425
  <button type="button" class="btn btn-sm pv-panel-dl" title=${t("chat.pvDownload")} onClick=${() => download(open.code, fileName)}><${Icon} name="download" size=${14} /><span class="lbl">${t("chat.pvDownload")}</span></button>
280
426
  </div>
281
427
  ${newer && newer.key !== open.key ? html`<div class="pv-newer" role="status"><span>${t("chat.pvNewer", timeOf(newer.ts))}</span>
282
428
  <button type="button" class="btn btn-sm pv-newer-show" onClick=${showNewer}>${t("chat.pvNewerShow")}</button>${newerNote ? html`<span class="pv-newer-note">${newerNote}</span>` : null}</div>` : null}
429
+ ${newerNote && !(newer && newer.key !== open.key) ? html`<div class="pv-note pv-ver-note" role="status">${newerNote}</div>` : null}
283
430
  ${going && !P().bannerAcked() ? html`<div class="pv-banner"><span class="pv-banner-text">${P().banner()}</span>
284
431
  <button type="button" class="btn btn-sm btn-ghost pv-ack" title=${P().bannerAckTitle()} onClick=${() => P().ackBanner()}>${P().bannerAckLabel()}</button></div>` : null}
285
- ${note && view === "preview" ? html`<div class="pv-note">${note}${askOptIn ? html` <button type="button" class="btn btn-sm pv-panel-allow" onClick=${allowHere}>${t("chat.pvAllowButton")}</button>` : null}</div>` : null}
286
- <div class=${`pv-panel-body${view === "code" ? " show-code" : ""}`}>
432
+ ${cdnHost && shown === "preview" ? html`<div class="pv-note pv-cdn-note" role="status">${t("chat.pvCdnNote", cdnHost)}</div>` : null}
433
+ ${note && shown === "preview" ? html`<div class="pv-note">${note}${askOptIn ? html` <button type="button" class="btn btn-sm pv-panel-allow" onClick=${allowHere}>${t("chat.pvAllowButton")}</button>` : null}</div>` : null}
434
+ <div class=${`pv-panel-body${listing || shown !== "preview" ? " show-code" : ` w-${device}`}`}>
287
435
  <div class="pv-holder" ref=${holder}></div>
288
- ${view === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
436
+ ${listing ? html`<${PageList} pages=${pages} current=${pageIdOf(open)} pick=${(p) => { setListing(false); if (p.id !== pageIdOf(open)) show(p.latest); }} />` : null}
437
+ ${!listing && shown === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
438
+ ${!listing && shown === "changes" ? html`<${Changes} changes=${changes} />` : null}
289
439
  </div>
290
440
  </aside>`;
291
441
  }
442
+
443
+ /** The Changes view: hunks of the line diff as a unified diff shows them, drawn as text (never as HTML). */
444
+ function Changes({ changes }) {
445
+ if (!changes || changes.state === "loading") return html`<div class="pv-note pv-diff-note">${t("chat.pvLoading")}</div>`;
446
+ if (changes.state === "failed") return html`<div class="pv-note pv-diff-note">${changes.reason}</div>`;
447
+ if (changes.state === "big") return html`<div class="pv-note pv-diff-note">${t("chat.pvChangesBig")}</div>`;
448
+ if (!changes.hunks.length) return html`<div class="pv-note pv-diff-note">${t("chat.pvChangesNone", changes.from)}</div>`;
449
+ const rows = [];
450
+ let left = DIFF_LIMITS.maxLines;
451
+ for (const h of changes.hunks) {
452
+ if (left <= 0) break;
453
+ rows.push(html`<span class="d-hunk">${`@@ -${h.oldStart},${h.oldLines} +${h.newStart},${h.newLines} @@\n`}</span>`);
454
+ left--;
455
+ for (const l of h.lines) {
456
+ if (left-- <= 0) break;
457
+ rows.push(html`<span class=${l.t === "+" ? "d-add" : l.t === "-" ? "d-del" : "d-ctx"}>${`${l.t} ${l.text}\n`}</span>`);
458
+ }
459
+ }
460
+ return html`<div class="pv-diff-wrap"><div class="pv-diff-head">${t("chat.pvChangesFrom", changes.from)}${changes.total > DIFF_LIMITS.maxLines ? html` · <span class="pv-diff-cut">${t("chat.pvChangesCut", DIFF_LIMITS.maxLines)}</span>` : null}</div>
461
+ <pre class="pv-code pv-diff"><code>${rows}</code></pre></div>`;
462
+ }
463
+
464
+ /** #2911 (6): the thread's pages, newest first — one row each, its newest version opened by a click (waiting for Preview). */
465
+ function PageList({ pages, current, pick }) {
466
+ return html`<ul class="pv-pages" aria-label=${t("chat.pvPagesTitle")}>${pages.map(p => html`<li key=${p.id}>
467
+ <button type="button" class=${`pv-page${p.id === current ? " on" : ""}`} aria-current=${p.id === current ? "true" : undefined} onClick=${() => pick(p)}>
468
+ <${Icon} name=${p.kind === "file" ? "attach" : "code"} size=${14} />
469
+ <span class="pv-page-name">${p.label || t("chat.pvPagesUntitled")}</span>
470
+ <span class="pv-page-meta">${p.versions > 1 ? t("chat.pvPagesVersions", p.versions) : ""}${p.versions > 1 ? " · " : ""}${timeOf(p.latest.ts)}</span>
471
+ </button></li>`)}</ul>`;
472
+ }