arcane-os 0.81.0 → 0.83.0

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.
@@ -0,0 +1,206 @@
1
+ # Native application control
2
+
3
+ `connectAppControl` from `arcane-os/core/app-control` connects to one explicitly
4
+ selected running application window. The Windows WebView2 host provides document
5
+ inspection, a PNG of its rendered viewport, and targeted DOM actions through its
6
+ own local named pipe. The application keeps its existing Core child, profile,
7
+ origin and window lifecycle. These operations do not activate the desktop window
8
+ or send global keyboard or pointer input.
9
+
10
+ The Node client uses the SDK's existing `arcane/1` framing and local pipe/socket
11
+ transport on Windows, Linux and macOS. The native window adapter described here
12
+ is Windows WebView2; macOS, Linux and Android hosts do not yet expose these
13
+ operations. An installed generic browser tool does not automatically discover
14
+ this endpoint. A caller must use this public client or CLI explicitly.
15
+
16
+ ## Select the running instance
17
+
18
+ Add an explicit endpoint to the application's existing native launch context:
19
+
20
+ ```json
21
+ {
22
+ "native": {
23
+ "launchContext": {
24
+ "appControl": {
25
+ "endpoint": "\\\\.\\pipe\\arcane-pm-control"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ The same `appControl` record can be supplied in the launch JSON already selected
33
+ by `--arcane-launch-config`. That record is merged by the existing launcher; it
34
+ does not select a different profile or Core topology. Omission starts the usual
35
+ application without a control listener. Each simultaneous application instance
36
+ needs its own endpoint. An occupied endpoint reports the actual startup failure;
37
+ the host does not silently attach to another window or choose another name.
38
+
39
+ Use a matching published Windows host asset when assembling the application.
40
+ The host producer generates `arcane-app-control.js` from the canonical standalone
41
+ DOM operation function and includes it beside `Arcane.exe`. Direct C# composition
42
+ uses `ArcaneHostOptions.ApplicationId`, `AppControlEndpoint`, and
43
+ `AppControlSource` with the same generated source. Ordinary composition leaves
44
+ the two control options unset.
45
+
46
+ ## Public client
47
+
48
+ ```js
49
+ import {connectAppControl} from 'arcane-os/core/app-control';
50
+
51
+ const app = await connectAppControl({
52
+ endpoint: String.raw`\\.\pipe\arcane-pm-control`
53
+ });
54
+ try {
55
+ const page = await app.inspect();
56
+ await app.act({
57
+ documentGeneration: page.documentGeneration,
58
+ selector: 'a[data-route="connections"]',
59
+ action: 'click'
60
+ });
61
+ const contents = await app.inspect({selector: '#pm-content'});
62
+ console.log(contents);
63
+ } finally {
64
+ await app.close();
65
+ }
66
+ ```
67
+
68
+ This example selects an existing navigation link. Its returned action result
69
+ describes the immediate DOM operation. Inspect the resulting page to establish
70
+ the application outcome; a click alone does not establish an asynchronous save,
71
+ connection or other business result.
72
+
73
+ | Member | Contract |
74
+ | --- | --- |
75
+ | `connectAppControl({endpoint, signal?, onError?})` | Connect once to the explicit running host. It never launches an app, creates Core, changes a profile or reconnects automatically. |
76
+ | `status(options?)` | Returns app identity, platform/host, current URL, document generation, readiness, window title/state/client dimensions and available operations. Readiness describes the document, not models or application services. |
77
+ | `inspect(parameters={}, options?)` | Optional `shadowPath` selects an open shadow root. Optional CSS `selector` selects zero or more elements within the selected root; omission selects the whole root. Optional `documentGeneration` selects the expected document. Returns complete HTML/text, live control state and DOM-derived role/name hints. |
78
+ | `capture(parameters={}, options?)` | Returns `{mimeType:'image/png', data, documentGeneration, url}`. `data` contains the complete base64 PNG. Optional `documentGeneration` selects the expected document. |
79
+ | `act(parameters, options?)` | Requires `documentGeneration`, one unambiguous CSS `selector` within the root selected by optional `shadowPath`, and the action fields below. |
80
+ | `close()` | Closes this caller's connection and cancels its pending requests. It never closes the app or Core. Returns the connection's completion promise. |
81
+ | `closed` | Connection lifetime promise; actual transport failures remain observable. |
82
+ | `endpoint` | The caller-selected local endpoint. |
83
+
84
+ Each operation's separate `options` uses the existing Core client request
85
+ contract, including `signal` and `timeoutMs`. Connection cancellation closes only
86
+ that connection. Cancellation before UI dispatch prevents execution. Once a
87
+ script or capture has been dispatched, cancellation cannot reverse its effects;
88
+ the host still observes its completion. A disconnected caller's completed result
89
+ is retained in the host diagnostics instead of being represented as delivered.
90
+
91
+ ## Document operations
92
+
93
+ Inspection defaults to the top-level light DOM. Its result contains `scope`, `title`,
94
+ `readyState`, `doctype`, `roots`, `controls`, `frames`, `shadowHosts`,
95
+ `activeElement`, `scrollingElement`, `selection`, `documentGeneration`, and
96
+ `url`. Each root retains its complete `html` and `text`; controls retain their
97
+ attributes and current form, selection, disabled, scrolling and related state.
98
+ Generated CSS selectors are useful for the inspected document; a later DOM edit
99
+ can change their meaning. Application-authored stable selectors are preferable
100
+ when available. Component replacement or mutation can make a selector stale
101
+ without changing the native document generation.
102
+
103
+ ### Open shadow roots
104
+
105
+ `inspect` and `act` accept `shadowPath`, an ordered array of CSS host selectors.
106
+ The first selector resolves inside the top-level document; each later selector
107
+ resolves inside the preceding host's open shadow root. Every host selector must
108
+ match exactly one element. The operation's `selector` then resolves only inside
109
+ the final root. An omitted or empty `shadowPath` retains the ordinary light-DOM
110
+ scope. Selecting a root never recursively enters its nested shadow roots.
111
+
112
+ ```js
113
+ const shadowPath = ['#appearance-panel', 'theme-switcher'];
114
+ const panel = await app.inspect({shadowPath});
115
+ await app.act({
116
+ documentGeneration: panel.documentGeneration,
117
+ shadowPath,
118
+ selector: 'button[data-theme="dark"]',
119
+ action: 'click'
120
+ });
121
+ ```
122
+
123
+ Shadow inspection returns `scope:'open-shadow-root'` and the selected
124
+ `shadowPath`. Its element selectors are local to that root. With no `selector`,
125
+ `roots` contains one complete root record with `selector:null`, its full
126
+ `innerHTML` as `html`, its full `textContent` as `text`, and `state:null`; a
127
+ ShadowRoot itself is not an element or an action target. With a selector, roots
128
+ retain their element HTML and state. Controls, frames and open-shadow hosts are
129
+ described within this selected tree. Each `shadowHosts` descriptor retains its
130
+ local `selector` and `mode`, and adds the full `shadowPath` needed to inspect
131
+ that host's shadow root, including nested hosts. Pass that path to the next
132
+ inspection; pair selectors from its result with that same path for actions.
133
+
134
+ `activeElement` comes from the selected root. `scrollingElement` is `null` for a
135
+ shadow-root scope because the root has no document scrolling element; scroll
136
+ actions still target elements. `selection.text`, `title`, `readyState`, `doctype`
137
+ and native URL/generation remain document-level information. Role/name label
138
+ references resolve within the element's own tree. Inspection follows the DOM
139
+ tree, not the composed rendering tree: slot-assigned light-DOM nodes remain in
140
+ their own containing DOM tree and are not duplicated inside the selected shadow
141
+ root.
142
+
143
+ A missing or ambiguous host produces document error `ARCANE_APP_CONTROL_TARGET_COUNT`
144
+ with the complete path, zero-based `index`, `selector` and match count. Invalid
145
+ path or CSS input reports its actual error before any action. A host without an
146
+ accessible open root produces document error `ARCANE_APP_CONTROL_UNSUPPORTED`
147
+ with the path and failing step. The Windows client rejects these document failures
148
+ with top-level `error.code === 'ARCANE_APP_CONTROL_FAILED'`; the original document
149
+ error, including its `code` and `details`, is available at
150
+ `error.data.find(entry => entry.key === 'documentError').value`.
151
+ Closed roots cannot be discovered, entered, or distinguished from
152
+ absent roots through this API. No closed-root interception is installed.
153
+ Shadow actions add the selected `shadowPath` to their result and retain the
154
+ actual target state after dispatch, including disconnection by the action.
155
+
156
+ Role/name hints are derived from DOM attributes and labels, not a native
157
+ accessibility-tree computation. Frame documents and unselected shadow-root
158
+ contents are outside the selected scope. Frame elements and open-shadow hosts
159
+ are described; closed shadow roots cannot be discovered. Capture is the current rendered
160
+ viewport, not an offscreen full-document rendering. Covered and minimized-window
161
+ capture behavior requires execution evidence in the selected environment.
162
+
163
+ | Action | Parameters and immediate behavior |
164
+ | --- | --- |
165
+ | `click` | Calls the uniquely selected element's DOM `click()` method. |
166
+ | `fill` | Exact string `value` replaces an input, textarea or contenteditable value; input/change events are dispatched. Read-only, disabled and non-text input controls report their actual unsupported state. |
167
+ | `select` | Exact `value` string or `values` string array selects native `<select>` options and dispatches input/change. Multiple values require a multiple-select control. |
168
+ | `scroll` | Absolute numeric `left`, `top`, or both in CSS pixels; the omitted axis retains its position. Returns the resulting scroll state, including browser clamping. |
169
+
170
+ DOM actions use programmatic, untrusted events. They do not supply physical user
171
+ activation for browser features that require it. Results describe the actual
172
+ immediate state, including browser value sanitization; input is not silently
173
+ rewritten by the SDK.
174
+
175
+ The host publishes a new generation when an actual replacement document becomes
176
+ ready. A creation-time document marker is also checked inside the executing
177
+ script so a queued action cannot run against a successor page. During navigation,
178
+ operations report that the document is not ready. A change during completion
179
+ reports the actual result with that failure; actions are never retried. These
180
+ markers are transient document lifecycle state and are not written to saved data.
181
+
182
+ ## CLI
183
+
184
+ ```powershell
185
+ arcane app-control status --endpoint '\\.\pipe\arcane-pm-control'
186
+ arcane app-control inspect --endpoint '\\.\pipe\arcane-pm-control' --selector '#pm-content'
187
+ arcane app-control inspect --endpoint '\\.\pipe\arcane-pm-control' --request '.arcane\inspection.json'
188
+ arcane app-control capture --endpoint '\\.\pipe\arcane-pm-control' --output '.arcane\view.png'
189
+ arcane app-control act --endpoint '\\.\pipe\arcane-pm-control' --request '.arcane\action.json'
190
+ ```
191
+
192
+ The action file is the complete action parameter object, including the actual
193
+ `documentGeneration` returned by the preceding status or inspection. Inspection
194
+ also accepts `--request` for a complete parameter object, such as
195
+ `{"shadowPath":["#appearance-panel","theme-switcher"]}` or the same object with
196
+ `selector` and `documentGeneration`. Inspection's `--request` and `--selector`
197
+ options are mutually exclusive. The CLI reads the selected file completely.
198
+ Paths are relative to the current directory. Capture
199
+ writes the PNG to the explicitly selected file and prints its metadata. The other
200
+ operations print complete JSON. `app-control --help` describes this command's
201
+ options; other Arcane commands retain their existing global output-format option.
202
+
203
+ Pipe startup, framing, document, image and output-file errors remain observable.
204
+ Host shutdown stops new control work and observes accepted operations alongside
205
+ the existing Core drain. It does not wait for desktop input or leave an
206
+ independent controller process running.
@@ -372,6 +372,26 @@ reach developer diagnostics and a concise recovery status stays outside the
372
372
  transcript. Destruction cancels pending preparation, while an already requested
373
373
  print retains its snapshot through `afterprint`.
374
374
 
375
+ Markdown images with local `arcane-media:` references saved through
376
+ [`MarkdownMedia`](runtime-modules.md#markdownmediajs) display automatically in
377
+ new messages, live response chunks, final responses, and restored history.
378
+ Chat resolves references through the existing application-scoped DBOPFS owner
379
+ while preserving the complete original Markdown, model payloads, saved history,
380
+ image descriptions, and ordinary image URLs. Text appears immediately; local
381
+ image reads and decoding proceed independently without delaying streaming or
382
+ session readiness. Failed images retain complete developer diagnostics and show
383
+ a concise message beside that card; successful sibling images remain visible.
384
+
385
+ Each rendered card owns its display URLs. Re-rendering, history replacement,
386
+ transient-message removal, and destruction cancel obsolete display work and
387
+ release those URLs without deleting stored images. Back/Forward Cache retains
388
+ the existing component lifecycle. Explicit `print()` awaits the current images;
389
+ native Print reports images that are still loading or failed instead of claiming
390
+ they are ready. A requested print snapshot retains its image URLs across later
391
+ message changes or component destruction until `afterprint` releases them.
392
+ Applications continue to own image-record names, associations, and cleanup;
393
+ Chat introduces no renderer callback or additional storage API.
394
+
375
395
  Before any session binding begins, callback-mode `sessionStatus` follows the
376
396
  selected sticky LLM role and the existing application-owned `modelName` label
377
397
  (or the selected model ID). Assigning `modelName` refreshes that status even
@@ -58,6 +58,7 @@ runtime layouts.
58
58
  | `arcane-os/core/development` | App-service composition and document-owned Core transport during source development, independently of local AI. |
59
59
  | `arcane-os/core/stdio` | Framed Node stdio transport with graceful runtime drain. |
60
60
  | `arcane-os/core/host` | Node-only reusable Core host lifecycle, app-selected native launch defaults and shared native location resolution. |
61
+ | `arcane-os/core/app-control` | Explicit connection to an existing native window for background document inspection, capture and DOM actions. |
61
62
  | `arcane-os/codex/app-server` | Explicit native Codex App Server session, complete live turns, server requests and recovery. |
62
63
  | `arcane-os/mcp/stdio` | Node MCP STDIO server for app-owned tools and static resources. |
63
64
  | `arcane-os/core/repositories` | Native persistent ArcaneData paths, connected working repositories and scoped non-secret Git identity configuration. |
@@ -428,6 +429,7 @@ The remaining data-only subpaths are eight JSON Schemas and package metadata.
428
429
  | `startCoreHost()` | function | `arcane-os/core/host` | Native Core host composition | Node with readable input and writable output streams |
429
430
  | `startSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC on Windows, Linux and macOS |
430
431
  | `connectSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node local IPC and optional explicit headless startup |
432
+ | `connectAppControl()` | function | `arcane-os/core/app-control` | Native application control | Node local IPC; current window adapter is Windows WebView2 |
431
433
  | `runSharedCoreHost()` | function | `arcane-os/core/host` | Shared Core host | Node headless entry and optional parent startup IPC |
432
434
  | `startSharedCoreBridge()` | function | `arcane-os/core/host` | Shared Core host | Node readable/writable streams and local IPC |
433
435
  | `readCoreLaunchContext()` | function | `arcane-os/core/host` | Native Core host composition | Node; combines packaged defaults, host state and explicitly selected launch JSON |
@@ -8886,6 +8888,34 @@ try {
8886
8888
  }
8887
8889
  ```
8888
8890
 
8891
+ ## connectAppControl()
8892
+
8893
+ ```text
8894
+ connectAppControl({endpoint,signal,onError}={})
8895
+ ```
8896
+
8897
+ Connects once to the explicit running native window and resolves
8898
+ `{endpoint,closed,status,inspect,capture,act,close}`. The Windows host selects its
8899
+ own listener through `native.launchContext.appControl.endpoint` and preserves the
8900
+ existing Core child, origin, profile and application lifetime. The client uses
8901
+ portable Node local IPC; other native host adapters do not yet implement these
8902
+ document operations. No application is launched, no desktop window is activated,
8903
+ and no automatic reconnect or action retry occurs.
8904
+
8905
+ `status(options)` reports current app/window/document readiness.
8906
+ `inspect(parameters={},options)` returns complete selected DOM HTML/text
8907
+ and live control state. Inspection and actions accept optional `shadowPath`, an
8908
+ ordered array of unique CSS host selectors through open shadow roots; their
8909
+ `selector` resolves inside the final root. Omission retains top-level light DOM.
8910
+ Closed roots remain unavailable. `capture(parameters={},options)` returns a full base64 PNG
8911
+ of the rendered viewport. `act(parameters,options)` requires a returned
8912
+ `documentGeneration`, one CSS selector, and `click`, `fill`, `select` or `scroll`
8913
+ fields. Options preserve the Core client signal/timeout contract. `close()`
8914
+ cancels this connection's requests and returns `closed`, without closing the app.
8915
+ Document changes and actual errors remain observable; a dispatched action is
8916
+ not reversed by later cancellation. Full parameter/result, CLI and platform
8917
+ contracts are in [native application control](native-app-control.md).
8918
+
8889
8919
  ## runSharedCoreHost()
8890
8920
 
8891
8921
  Accepts the same options as `startSharedCoreHost(options)` and adds the headless
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcane-os",
3
- "version": "0.81.0",
3
+ "version": "0.83.0",
4
4
  "description": "Arcane OS JavaScript SDK, project-local CLI, browser runtime, and repository-portable application packager.",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -62,6 +62,7 @@
62
62
  "./core/development": "./src/core/development.mjs",
63
63
  "./core/stdio": "./src/core/stdio.mjs",
64
64
  "./core/host": "./src/core/host.mjs",
65
+ "./core/app-control": "./src/core/app-control.mjs",
65
66
  "./codex/app-server": "./src/codex/app-server.mjs",
66
67
  "./mcp/stdio": "./src/mcp/stdio-server.mjs",
67
68
  "./core/repositories": "./src/core/repositories.mjs",
@@ -886,6 +886,7 @@
886
886
  const aiActivationButton=shadowRoot.querySelector('#ai_activation_button');
887
887
  const [
888
888
  {default:MD},
889
+ {hydrateMarkdownMedia},
889
890
  {default:FileEntity},
890
891
  {ConversationSubmissionBarrier,requireConversationTimeboxDelivery},
891
892
  {requestAIRuntimeIntent,subscribeAIRuntimeState},
@@ -893,6 +894,7 @@
893
894
  {getBrowserDeviceClass}
894
895
  ]=await Promise.all([
895
896
  import('../modules/MD.js'),
897
+ import('../modules/MarkdownMedia.js'),
896
898
  import('../entities/File.js'),
897
899
  import('../modules/ConversationTimebox.js'),
898
900
  import('../modules/AIRuntimeState.js'),
@@ -946,15 +948,19 @@
946
948
  let destroyed=false;
947
949
  let recognitionDraft=null;
948
950
  let writingRecognitionDraft=false;
951
+ const transcriptMedia=new Map();
952
+ let printMediaRetains=null;
949
953
  const printViewPromise=import('../modules/PrintView.js').then(
950
954
  function registerChatPrint({createPrintView}){
951
955
  if(destroyed) return null;
952
956
  return createPrintView({
953
957
  host,
954
- content:function chatPrintContent(){return chatOutput;},
958
+ content:renderedChatPrintContent,
955
959
  title:function chatPrintTitle(){return host.printTitle||'Conversation';},
956
960
  active:function chatPrintActive(){return !destroyed&&chatOutput.children.length>0;},
957
961
  signal:aiRuntimeStateAbortController.signal,
962
+ retain:retainChatPrintMedia,
963
+ prepare:prepareChatPrintMedia,
958
964
  onError:reportChatPrintFailure
959
965
  });
960
966
  }
@@ -1190,6 +1196,7 @@
1190
1196
  for(const item of record.removedNodes) {
1191
1197
  if(item.localName === 'li' && item.parentElement !== chatOutput) {
1192
1198
  transcriptResizeObserver?.unobserve(item);
1199
+ releaseTranscriptMessageMedia(item);
1193
1200
  }
1194
1201
  }
1195
1202
  for(const item of record.addedNodes) {
@@ -1427,14 +1434,72 @@
1427
1434
  return entry;
1428
1435
  }
1429
1436
 
1437
+ function renderTranscriptMarkdown(target,content){
1438
+ if(destroyed) return null;
1439
+ const template=target.ownerDocument.createElement('template');
1440
+ template.innerHTML=new MD(content).rendered;
1441
+ const pending=[...template.content.querySelectorAll('img')].some(
1442
+ function localChatImage(image){
1443
+ return image.getAttribute('src')?.startsWith('arcane-media:');
1444
+ }
1445
+ );
1446
+ // Remove local-reference src values while detached; text renders now,
1447
+ // and the shared media owner observes independent storage and decoding.
1448
+ const handle=hydrateMarkdownMedia(template.content,{
1449
+ signal:aiRuntimeStateAbortController.signal
1450
+ });
1451
+ releaseTranscriptMarkdown(target);
1452
+ const media={handle,pending,error:null,status:null,ready:null};
1453
+ transcriptMedia.set(target,media);
1454
+ if(printMediaRetains){
1455
+ printMediaRetains.add(handle.retain());
1456
+ }
1457
+ target.raw=content;
1458
+ target.replaceChildren(template.content);
1459
+ media.ready=handle.ready.then(function transcriptImagesReady(){
1460
+ media.pending=false;
1461
+ },function transcriptImagesFailed(error){
1462
+ media.pending=false;
1463
+ media.error=error;
1464
+ if(destroyed||transcriptMedia.get(target)!==media) return;
1465
+ arcaneLogging.error('Unable to display saved Chat images.',error);
1466
+ const status=target.ownerDocument.createElement('p');
1467
+ status.className='message_media_status';
1468
+ status.setAttribute('role','status');
1469
+ status.textContent='Some saved images could not be displayed.';
1470
+ media.status=status;
1471
+ target.after(status);
1472
+ scrollTranscriptToBottom();
1473
+ });
1474
+ return media;
1475
+ }
1476
+
1477
+ function releaseTranscriptMarkdown(target){
1478
+ const media=transcriptMedia.get(target);
1479
+ if(!media) return;
1480
+ transcriptMedia.delete(target);
1481
+ media.status?.remove();
1482
+ media.handle.destroy();
1483
+ }
1484
+
1485
+ function releaseTranscriptMessageMedia(item){
1486
+ const markdown=item?.querySelector('.markdown');
1487
+ if(markdown) releaseTranscriptMarkdown(markdown);
1488
+ }
1489
+
1490
+ function removeTranscriptMessage(item){
1491
+ if(!item) return;
1492
+ releaseTranscriptMessageMedia(item);
1493
+ item.remove();
1494
+ }
1495
+
1430
1496
  function setTranscriptMessageContent(item,content,timestamp){
1431
1497
  if(!is.string(content)){
1432
1498
  throw new TypeError('Chat session message content must be a string.');
1433
1499
  }
1434
1500
  item.querySelector('.thinking')?.remove();
1435
1501
  const markdown=item.querySelector('.markdown');
1436
- markdown.raw=content;
1437
- markdown.innerHTML=new MD(content).rendered;
1502
+ renderTranscriptMarkdown(markdown,content);
1438
1503
  if(timestamp!==undefined){
1439
1504
  item.querySelector('.message_timestamp')?.replaceWith(
1440
1505
  transcriptTime(timestamp)
@@ -1495,12 +1560,11 @@
1495
1560
  header.append(heading);
1496
1561
  const markdown=chatOutput.ownerDocument.createElement('div');
1497
1562
  markdown.className='markdown';
1498
- markdown.raw=content;
1499
- markdown.innerHTML=new MD(content).rendered;
1500
1563
  item.append(header,markdown,transcriptTime(timestamp));
1501
1564
  for(const call of normalizeVisibleToolCalls(toolCalls)){
1502
1565
  appendVisibleToolCall(item,call);
1503
1566
  }
1567
+ renderTranscriptMarkdown(markdown,content);
1504
1568
  return item;
1505
1569
  }
1506
1570
 
@@ -1642,6 +1706,9 @@
1642
1706
  setPendingStructuralToolCalls(
1643
1707
  actionable?[...restoredPendingToolCalls.values()]:[]
1644
1708
  );
1709
+ for(const item of chatOutput.children){
1710
+ releaseTranscriptMessageMedia(item);
1711
+ }
1645
1712
  chatOutput.replaceChildren(fragment);
1646
1713
  transcriptFollowing = true;
1647
1714
  transcriptScrollTop = chatOutput.scrollTop;
@@ -3034,9 +3101,7 @@
3034
3101
  thinkingHTML.remove();
3035
3102
  }
3036
3103
 
3037
- target.raw ? target.raw+=text : target.raw=text;
3038
-
3039
- target.innerHTML=new MD(target.raw).rendered;
3104
+ renderTranscriptMarkdown(target,target.raw?target.raw+text:text);
3040
3105
 
3041
3106
  scrollTranscriptToBottom();
3042
3107
  }
@@ -3393,8 +3458,7 @@
3393
3458
  message.querySelector('.thinking')?.remove();
3394
3459
  const markdown=message.querySelector('.markdown');
3395
3460
  const text=visibleErrorMessage(error,'The AI request failed.');
3396
- markdown.raw=text;
3397
- markdown.innerHTML=new MD(text).rendered;
3461
+ renderTranscriptMarkdown(markdown,text);
3398
3462
  scrollTranscriptToBottom();
3399
3463
  return true;
3400
3464
  }
@@ -3421,8 +3485,8 @@
3421
3485
  const response=[...chatOutput.children].find(
3422
3486
  candidate=>candidate.id===`message-${messageId}`
3423
3487
  )||null;
3424
- response?.remove();
3425
- for(const request of requestMessages) request.remove();
3488
+ removeTranscriptMessage(response);
3489
+ for(const request of requestMessages) removeTranscriptMessage(request);
3426
3490
  const existing=textArea.value;
3427
3491
  if(existing!==text){
3428
3492
  textArea.value=existing
@@ -3500,7 +3564,7 @@
3500
3564
  function removeTransientSessionCards(){
3501
3565
  if(retainTurn||!transientCards.length) return;
3502
3566
  // The submitter supplies only nodes created for this submission.
3503
- for(const card of transientCards) card.remove();
3567
+ for(const card of transientCards) removeTranscriptMessage(card);
3504
3568
  transientCards.length=0;
3505
3569
  scrollTranscriptToBottom();
3506
3570
  }
@@ -3675,9 +3739,9 @@
3675
3739
  const response=[...chatOutput.children].find(
3676
3740
  candidate=>candidate.id===`message-${messageId}`
3677
3741
  )||null;
3678
- response?.remove();
3742
+ removeTranscriptMessage(response);
3679
3743
  for(const requestMessage of requestMessages){
3680
- requestMessage.remove();
3744
+ removeTranscriptMessage(requestMessage);
3681
3745
  }
3682
3746
  scrollTranscriptToBottom();
3683
3747
  }else if(retainTurn){
@@ -3692,7 +3756,7 @@
3692
3756
  arcaneLogging.error('Arcane chat request failed.',error);
3693
3757
  if(sessionRequestMessages.every(message=>message.role==='tool')){
3694
3758
  for(const requestMessage of requestMessages){
3695
- requestMessage.remove();
3759
+ removeTranscriptMessage(requestMessage);
3696
3760
  }
3697
3761
  }
3698
3762
  if(retainTurn) renderSessionMessageFailure(messageId,error);
@@ -4136,11 +4200,64 @@
4136
4200
  return message;
4137
4201
  }
4138
4202
 
4203
+ function renderedChatPrintContent(){
4204
+ for(const media of transcriptMedia.values()){
4205
+ if(media.pending){
4206
+ const error=new Error('Saved images are still loading. Print again when they finish.');
4207
+ error.code='ARCANE_MARKDOWN_MEDIA_PENDING';
4208
+ throw error;
4209
+ }
4210
+ if(media.error) throw media.error;
4211
+ }
4212
+ return chatOutput;
4213
+ }
4214
+
4215
+ function retainChatPrintMedia(){
4216
+ renderedChatPrintContent();
4217
+ const releases=new Set();
4218
+ for(const media of transcriptMedia.values()){
4219
+ releases.add(media.handle.retain());
4220
+ }
4221
+ printMediaRetains=releases;
4222
+ return function releaseChatPrintMedia(){
4223
+ if(printMediaRetains===releases){
4224
+ printMediaRetains=null;
4225
+ }
4226
+ for(const release of releases) release();
4227
+ releases.clear();
4228
+ };
4229
+ }
4230
+
4231
+ async function prepareChatPrintMedia(signal){
4232
+ let cancel;
4233
+ const cancelled=new Promise(function waitForChatPrintCancellation(resolve){cancel=resolve;});
4234
+ signal?.addEventListener('abort',cancel,{once:true});
4235
+ try{
4236
+ while(!destroyed&&!signal?.aborted){
4237
+ const selected=[...transcriptMedia];
4238
+ await Promise.race([
4239
+ Promise.all(selected.map(function readyChatMedia([,media]){return media.ready;})),
4240
+ cancelled
4241
+ ]);
4242
+ if(destroyed||signal?.aborted) return false;
4243
+ if(selected.length!==transcriptMedia.size||selected.some(
4244
+ function replacedChatMedia([target,media]){return transcriptMedia.get(target)!==media;}
4245
+ )) continue;
4246
+ renderedChatPrintContent();
4247
+ return true;
4248
+ }
4249
+ return false;
4250
+ }finally{
4251
+ signal?.removeEventListener('abort',cancel);
4252
+ }
4253
+ }
4254
+
4139
4255
  async function print(){
4140
4256
  if(destroyed) return false;
4141
4257
  try{
4142
4258
  const view=await printViewPromise;
4143
- return destroyed||!view?false:await view.print();
4259
+ if(destroyed||!view||!await prepareChatPrintMedia()) return false;
4260
+ return await view.print();
4144
4261
  }catch(error){
4145
4262
  reportChatPrintFailure(error);
4146
4263
  throw error;
@@ -4150,7 +4267,9 @@
4150
4267
  function reportChatPrintFailure(error){
4151
4268
  arcaneLogging.error('Unable to print the conversation.',error);
4152
4269
  if(!destroyed){
4153
- setSessionStatus('error','Unable to open print preview. Please try again.');
4270
+ setSessionStatus('error',error?.code==='ARCANE_MARKDOWN_MEDIA_PENDING'
4271
+ ?'Saved images are still loading. Print again when they finish.'
4272
+ :'Unable to open print preview. Please try again.');
4154
4273
  }
4155
4274
  }
4156
4275
 
@@ -4235,6 +4354,9 @@
4235
4354
  ownership.abort(destroyReason);
4236
4355
  }
4237
4356
  aiRuntimeStateAbortController.abort(destroyReason);
4357
+ for(const target of transcriptMedia.keys()){
4358
+ releaseTranscriptMarkdown(target);
4359
+ }
4238
4360
  aiActivationController.destroy();
4239
4361
  window.removeEventListener('pagehide',handlePageHide);
4240
4362
  window.removeEventListener('pageshow',handlePageShow);