mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
Merge relprep/rtl fix(response-viewer): lay out Persian, Arabic and Hebrew right to left (#715)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
cddfc07550
13 files changed
+320
-14
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
Persian, Arabic and Hebrew now read right to left in the Response Viewer and in the File Viewer's markdown view (#715). Each paragraph, heading, list, quote and table takes its direction from its own first letter, so a right-to-left answer is right-aligned with its punctuation, list bullets and quote bar on the right, while an English paragraph beside it stays left to right. Code blocks, inline code and the file paths the viewer links on its own stay left to right and no longer decide a sentence's direction, so a Persian sentence that starts with `npm install` still reads right to left. The phone's Compose dialog lays a right-to-left prompt out the same way. The terminal pane is unchanged: xterm.js has no right-to-left support yet (xtermjs/xterm.js#701).
|
||||
@@ -405,7 +405,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
**One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`.
|
||||
|
||||
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite.
|
||||
**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. The anchors the walk builds carry `dir="ltr"`, so a path at the head of a right-to-left sentence does not decide that sentence's direction; a rewritten link keeps no `dir`, since its label is prose (#715). ⚠️ `_applyBlockDirection()` runs after the linkifier in both consumers, never before it: a block already carrying `dir="auto"` re-resolves its direction on every text node replaced under it. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite, `test/response-viewer-rtl.test.ts` the direction and the order.
|
||||
|
||||
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
|
||||
|
||||
|
||||
@@ -66,8 +66,9 @@ A row of keys above the virtual keyboard, and what it contains depends on the se
|
||||
**Agent sessions** get quick actions: `/init`, `/clear`, `/compact`, a Compose key, `Esc`,
|
||||
a path picker, and 🧠 when Read My Mind is on. Compose opens a multiline editor with
|
||||
autocorrect: Enter adds a new line, and only Send delivers the text, as one paste followed
|
||||
by Enter, so your line breaks reach the agent intact. Anything already typed on the terminal
|
||||
prompt moves into the editor when it opens. Drafts are kept per session and in memory only,
|
||||
by Enter, so your line breaks reach the agent intact. A prompt written in Persian, Arabic or
|
||||
Hebrew lays out right to left. Anything already typed on the terminal prompt moves into the
|
||||
editor when it opens. Drafts are kept per session and in memory only,
|
||||
so switching tabs keeps them and a page reload forgets them; a dot on the key shows a draft
|
||||
is parked. The editor's Attach button attaches photos, videos or documents and puts their paths
|
||||
into the draft.
|
||||
@@ -187,6 +188,7 @@ Setup in [Notifications And Approvals](Notifications-And-Approvals).
|
||||
|
||||
The terminal viewport is small. **Last Response** (opt-in header button) renders the agent's
|
||||
last answer as scrollable text instead, with a **More** button for additional context.
|
||||
Persian, Arabic and Hebrew answers read right to left there.
|
||||
|
||||
The [File Viewer](Working-With-Files) works on phones too, including edit mode, which is
|
||||
enough to fix a typo an agent introduced while you are away from your desk.
|
||||
|
||||
@@ -257,6 +257,20 @@ If you are sending input over the API instead, your payload must end with `\r` o
|
||||
is ever sent. The request still succeeds and the text sits unsubmitted in the composer. See
|
||||
[Driving Codeman From An Agent](Driving-Codeman-From-An-Agent).
|
||||
|
||||
### Persian, Arabic or Hebrew looks wrong in the terminal
|
||||
|
||||
The terminal is xterm.js, which does not support right-to-left text yet
|
||||
([xtermjs/xterm.js#701](https://github.com/xtermjs/xterm.js/issues/701)). It puts each
|
||||
character in its own cell from left to right, so a sentence comes out reversed, and the
|
||||
default WebGL renderer also draws the letters unjoined. Codeman cannot fix that without
|
||||
replacing the terminal.
|
||||
|
||||
Read the answer in **Last Response** instead, where it lays out right to left, or open a
|
||||
Markdown file in the [File Viewer](Working-With-Files). On desktop, turning off **WebGL
|
||||
Renderer** in **App Settings → Terminal & Input** and reloading joins the letters, so each
|
||||
word reads correctly, but the words of a sentence still run left to right. Phones already
|
||||
use that renderer.
|
||||
|
||||
## Mobile
|
||||
|
||||
### The keyboard covers the terminal, or scroll position jumps
|
||||
|
||||
@@ -77,7 +77,9 @@ Turning on either starts the watcher.
|
||||
|
||||
**Last Response** (header button, opt-in) renders the agent's last answer as scrollable text
|
||||
rather than terminal output. It exists mostly for phones, where reading a long answer in a
|
||||
terminal viewport is painful. **More** loads additional context.
|
||||
terminal viewport is painful. **More** loads additional context. Persian, Arabic and Hebrew
|
||||
answers read right to left here, which the terminal cannot do, while code and the file paths
|
||||
it links stay left to right.
|
||||
|
||||
## After the fact
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ It renders what it can:
|
||||
| Kind | Behaviour |
|
||||
| ------------------------ | ------------------------------------------------------------------------- |
|
||||
| Text and code | Plain preview with Lines (line numbers) and Wrap toggles in the header. Long files are truncated in plain preview. |
|
||||
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. The MD pill in the header flips to source. |
|
||||
| Markdown | Rendered by default: headings, tables, code blocks with copy buttons, images and links relative to the file (root-relative ones resolve from the workspace root, as on GitHub). Links to another heading of the same file (`[Install](#installation)`) scroll to it, with GitHub's heading names (lower-case, punctuation dropped, repeats numbered `-1`, `-2`), and never leave the page. Opened from an attachment card, where the file's folder is unknown, relative images show their alt text and relative links show as plain text. Persian, Arabic and Hebrew read right to left, each block following its own first letter, while code stays left to right. The MD pill in the header flips to source. |
|
||||
| Images | Inline. |
|
||||
| Audio and video | Inline with a working scrub bar, because range requests are supported. |
|
||||
| Spreadsheets (`.xlsx`) | Read-only grid, parsed in your browser (never on the server), up to 10 MB. `.xls` and `.ods` are download only. |
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@
|
||||
|
||||
**Ctrl+V paste trap** (`image-input.js`): `Ctrl+V` routes through `_handleImagePaste()`, which focuses a hidden `contenteditable` trap and reads the clipboard from the paste event landing there; images upload and their paths are typed in, plain text is a Codeman-built bracketed paste handed to that pane's terminal via `terminal.input(framed)` (strip nested 200/201 markers, newlines to `\r`, wrap CSI 200~/201~) because `terminal.paste()` drops those markers after a replay clears xterm's DECSET 2004 mirror (#716). `input()` fires onData as user input, so the main pane's bracketed-paste branch and a tile's `_onTerminalData` deliver the frame, including the local-echo flush (#219). The terminal is only refocused. ⚠️ **The trap must consume exactly ONE paste event** (Firefox delivers two per keypress: the `execCommand('paste')` event and the keydown's default action); the one-shot flag lives on the trap, never on a browser check. ⚠️ Do not remove the `execCommand('paste')` call: on some mobile engines it is the only route into the trap, and the trap is the only place image blobs are read. Tests: `test/image-paste-trap.test.ts`. → [architecture-invariants#terminal-paste-ctrlv](../../docs/architecture-invariants.md#terminal-paste-ctrlv)
|
||||
|
||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. Tests: `test/file-preview-markdown.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
**File Viewer text view: rendered markdown + Lines/Wrap toggles** (`_renderFilePreviewText()` in panels-ui.js): a `.md`/`.markdown` opens RENDERED by default with an `MD` pill back to source; the plain-text view has `Lines` (CSS-counter gutter) and `Wrap` toggles. ⚠️ ONE markdown pipeline: the viewer calls `_renderMarkdown(text, { breaks: false })` (marked + the DOMPurify allowlist, the Response Viewer's; chat keeps the default `breaks: true`, a file must not turn every hard wrap into a `<br>`) and binds the Response Viewer's click delegate (`_bindResponseViewerInteractions`) on the preview body for code-copy buttons and path links; never a second parser or handler. ⚠️ The document is built inside a `<template>` (a detached div with `innerHTML` set starts fetching every `<img src>` before the rewrite), then `_rebaseFilePreviewMarkdownRefs()` points relative images at the workspace-confined `file-raw` under the document's directory and root-relative ones under the workspace root (never a widened route; a failed load degrades to alt text), after `decodeURIComponent`ing the ref and dropping `?query`/`#fragment` (marked percent-encodes destinations, and the route encodes again), and turns workspace links into `a.rv-path` carrying `data-session-id` for the delegate (the linkifier's absolute paths get it too), stripping the `target` marked gave them. ⚠️ A preview opened by attachment id under a bare file name (attachment cards, history drawer: the registry keeps no relative path) has no directory, so its workspace refs degrade (images to alt text, links to their text), never resolve against the workspace root. ⚠️ The container carries `data-i18n-skip` or the translator rewrites the document's prose. ⚠️ Toggles are per-device localStorage keys (`codeman:filePreview*`), never `SettingsUpdateSchema`; Lines/Wrap are class flips on the ONE `<pre>`, with rules scoped `.file-preview-body > pre.file-preview-text` so they never leak into the document's code blocks. Markdown fetches `lines=10000` (the route ceiling), other text keeps 500. ⚠️ `md`/`markdown` are IN `FILE_PREVIEW_EXTENSIONS` (owner decision 2026-10-13): a markdown path clicked or tapped in the TERMINAL (main pane, grid tiles and split Pane B share `registerFilePathLinkProvider`) opens this rendered view like every other surface, never the streaming log viewer; other in-workspace text keeps the tail viewer (live follow). The terminal pattern links `.markdown` and `.htm` too. ⚠️ **Right-to-left text** (#715) has three parts on that one pipeline: `_renderMarkdown` pins `pre, code` to `dir="ltr"`, `_linkifyFilePaths` pins the anchors it builds the same way (`dir="auto"` skips a descendant that carries a `dir`, so `npm install` or a path at the head of a Persian sentence does not decide its direction; a markdown link's label is prose and stays unpinned), and `_applyBlockDirection()` gives each OUTERMOST prose block `dir="auto"`, keeping a `dir` the agent wrote and what it covers. ⚠️ That pass runs LAST, on the DOM, in both consumers (`_buildResponseViewerMessage`, `_renderFilePreviewText`), never in `_renderMarkdown` (its string must hold no `dir="auto"`) and never inside the linkifier (which returns early on a stale constants.js): an element already carrying `dir="auto"` re-resolves its direction on every insertion under it, so a code-only or path-only document went quadratic and a 200 KB list froze the tab for 40 s. ⚠️ The `.rv-text` block rules use logical properties (`padding-inline-start`, `border-inline-start`, `border-inline-end`, `text-align: start`, logical corner radii), never physical left and right, or an RTL block keeps its bullets and quote bar on the wrong side. The terminal pane is out of reach: xterm.js has no RTL support (xtermjs/xterm.js#701). Tests: `test/file-preview-markdown.test.ts`, `test/response-viewer-rtl.test.ts`. → [architecture-invariants#file-viewer-text-view-rendered-markdown-and-text-toggles](../../docs/architecture-invariants.md#file-viewer-text-view-rendered-markdown-and-text-toggles)
|
||||
|
||||
**Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `<col>` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `<numFmt>` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `<r/>` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/<name>.<ext>`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `<r/>` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet<N>.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only.
|
||||
|
||||
@@ -72,7 +72,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl)
|
||||
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. The textarea carries `dir="auto"`, so a prompt written in Persian, Arabic or Hebrew lays out right to left from its first letter (#715). Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer)
|
||||
|
||||
**Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines)
|
||||
|
||||
|
||||
@@ -2847,6 +2847,14 @@ class CodemanApp {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
// Code and preformatted text are always left to right. `dir="ltr"`
|
||||
// also isolates them, and `dir="auto"` skips any descendant that
|
||||
// carries a `dir`, so `npm install` at the head of a Persian sentence
|
||||
// no longer turns the whole sentence left to right (issue #715). The
|
||||
// `dir="auto"` itself is `_applyBlockDirection`'s, never this string's.
|
||||
tmpl.content.querySelectorAll('pre, code').forEach((el) => {
|
||||
if (!el.hasAttribute('dir')) el.setAttribute('dir', 'ltr');
|
||||
});
|
||||
return tmpl.innerHTML;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
@@ -3004,10 +3012,38 @@ class CodemanApp {
|
||||
renderedText.className = 'rv-text';
|
||||
renderedText.innerHTML = this._renderMarkdown(text);
|
||||
this._linkifyFilePaths(renderedText);
|
||||
this._applyBlockDirection(renderedText);
|
||||
div.appendChild(renderedText);
|
||||
return div;
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-to-left text (issue #715). With no `dir`, every block was laid out
|
||||
* left to right, so a Persian, Arabic or Hebrew answer was left-aligned, its
|
||||
* punctuation landed on the wrong end and its lists and quotes hung off the
|
||||
* left edge. Each OUTERMOST prose block now takes its direction from its own
|
||||
* first letter (`dir="auto"`, what GitHub does for rendered markdown), so an
|
||||
* RTL paragraph beside an English one reads correctly in both; nested blocks
|
||||
* inherit, which keeps a list's markers and indent on one side. A `dir` the
|
||||
* agent wrote (the sanitizer allows it) is kept, and so is what it covers.
|
||||
*
|
||||
* ⚠️ Call this LAST, on the DOM, once nothing else inserts under `root`: an
|
||||
* element that already carries `dir="auto"` re-resolves its direction on every
|
||||
* insertion beneath it, and a block whose letters all sit in `dir` children
|
||||
* (a code-only or path-only list) walks its whole subtree each time. Set
|
||||
* during `_renderMarkdown` or before the linkifier, that made rendering
|
||||
* quadratic, for English documents too: a 200 KB list of inline code froze
|
||||
* the tab for 40 s. Not part of `_linkifyFilePaths`, which returns early when
|
||||
* a stale constants.js has no `findFilePathLinks`.
|
||||
*/
|
||||
_applyBlockDirection(root) {
|
||||
if (!root) return;
|
||||
for (const el of root.querySelectorAll('p, h1, h2, h3, h4, h5, h6, ul, ol, dl, blockquote, .rv-table-wrap')) {
|
||||
if (el.parentElement?.closest('[dir]') || el.hasAttribute('dir')) continue;
|
||||
el.setAttribute('dir', 'auto');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make file paths in a rendered message clickable: absolute, `~/` and
|
||||
* relative ones (`findFilePathLinks`, constants.js).
|
||||
@@ -3108,6 +3144,9 @@ class CodemanApp {
|
||||
link.dataset.path = path;
|
||||
link.dataset.linkified = '1';
|
||||
if (folder) link.dataset.folder = '1';
|
||||
// A path reads left to right, and in a right-to-left sentence it must
|
||||
// not vote on the sentence's direction (`_applyBlockDirection`, issue #715).
|
||||
link.dir = 'ltr';
|
||||
link.title = path;
|
||||
link.textContent = path;
|
||||
frag.appendChild(link);
|
||||
|
||||
@@ -1371,7 +1371,7 @@ const KeyboardAccessoryBar = {
|
||||
<strong>Compose prompt</strong>
|
||||
<span>Enter adds a new line</span>
|
||||
</div>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<textarea class="paste-textarea prompt-composer-textarea" dir="auto" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<div class="paste-actions prompt-composer-actions">
|
||||
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||
<button type="button" class="paste-image">📎 Attach</button>
|
||||
@@ -1384,6 +1384,8 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
this._composerOverlay = overlay;
|
||||
const textarea = overlay.querySelector('.prompt-composer-textarea');
|
||||
// The textarea's dir="auto" lays a prompt written in Persian, Arabic or
|
||||
// Hebrew out right to left, from its first letter (#715).
|
||||
// i18n.js skips <textarea> subtrees (what is typed there is user content),
|
||||
// so the placeholder and label are translated here, when the dialog is built.
|
||||
const i18n = typeof window !== 'undefined' ? window.CodemanI18n : undefined;
|
||||
|
||||
@@ -5138,6 +5138,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const doc = tmpl.content.firstElementChild;
|
||||
this._rebaseFilePreviewMarkdownRefs(doc, info);
|
||||
this._linkifyFilePaths(doc);
|
||||
this._applyBlockDirection(doc);
|
||||
// The linkifier's absolute paths name no session; give them the
|
||||
// preview's, like the rebased links, or they open in the active tab's.
|
||||
for (const a of doc.querySelectorAll('a.rv-path:not([data-session-id])')) a.dataset.sessionId = info.sessionId;
|
||||
|
||||
@@ -15251,7 +15251,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.rv-text ul, .rv-text ol,
|
||||
.response-viewer-body > ul, .response-viewer-body > ol {
|
||||
margin: 0.6em 0;
|
||||
padding-left: 1.5em;
|
||||
padding-inline-start: 1.5em;
|
||||
}
|
||||
|
||||
.rv-text li,
|
||||
@@ -15263,12 +15263,16 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
|
||||
.rv-text blockquote,
|
||||
.response-viewer-body > blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
border-inline-start: 3px solid var(--accent);
|
||||
background: rgba(var(--accent-rgb), 0.06);
|
||||
margin: 0.8em 0;
|
||||
padding: 0.5em 14px;
|
||||
color: var(--text-dim);
|
||||
border-radius: 0 6px 6px 0;
|
||||
/* Logical corners: the bar sits on the start side, right-to-left too (#715). */
|
||||
border-start-start-radius: 0;
|
||||
border-end-start-radius: 0;
|
||||
border-start-end-radius: 6px;
|
||||
border-end-end-radius: 6px;
|
||||
}
|
||||
|
||||
.rv-text strong,
|
||||
@@ -15338,9 +15342,9 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.response-viewer-body > table td,
|
||||
.rv-table-wrap th, .rv-table-wrap td {
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
border-inline-end: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
@@ -15349,7 +15353,7 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
.response-viewer-body > table th:last-child,
|
||||
.response-viewer-body > table td:last-child,
|
||||
.rv-table-wrap th:last-child, .rv-table-wrap td:last-child {
|
||||
border-right: none;
|
||||
border-inline-end: none;
|
||||
}
|
||||
|
||||
.rv-text tr:last-child td,
|
||||
|
||||
@@ -151,6 +151,7 @@ function loadApp(prefs: Record<string, string> = {}) {
|
||||
app.filePreviewContent = '';
|
||||
app._renderMarkdown = vi.fn(() => MARKDOWN_HTML);
|
||||
app._linkifyFilePaths = vi.fn();
|
||||
app._applyBlockDirection = vi.fn();
|
||||
app._bindResponseViewerInteractions = vi.fn();
|
||||
|
||||
const byId = (id: string) => document.getElementById(id) as HTMLButtonElement;
|
||||
@@ -177,6 +178,11 @@ describe('file viewer rendered markdown', () => {
|
||||
expect(app._renderMarkdown).toHaveBeenCalledWith(MD_CONTENT, { breaks: false });
|
||||
// Identity, not deep equality: DOM nodes are compared by reference here.
|
||||
expect(app._linkifyFilePaths.mock.calls[0][0]).toBe(doc);
|
||||
// After the linkifier: a dir="auto" block re-resolves on every insertion under it (#715).
|
||||
expect(app._applyBlockDirection.mock.calls[0][0]).toBe(doc);
|
||||
expect(app._applyBlockDirection.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
app._linkifyFilePaths.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(app._bindResponseViewerInteractions.mock.calls[0][0]).toBe(body);
|
||||
// The source stays what Copy copies.
|
||||
expect(app.filePreviewContent).toBe(MD_CONTENT);
|
||||
@@ -463,6 +469,14 @@ describe('file viewer markdown line breaks', () => {
|
||||
expect(chatPara.querySelectorAll('br')).toHaveLength(1);
|
||||
expect(chatPara.textContent).toBe(WRAPPED.replace('\n', ''));
|
||||
});
|
||||
|
||||
it('lets a document read right to left through the shipped viewer (#715)', async () => {
|
||||
const app = loadShippingApp('سلام، این یک تست است.\n');
|
||||
|
||||
await app.openFilePreview('docs/README.md', 's1');
|
||||
|
||||
expect(document.querySelector('#filePreviewBody .file-preview-md p')?.getAttribute('dir')).toBe('auto');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FILE_PREVIEW_EXTENSIONS', () => {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @fileoverview Right-to-left text in rendered markdown (`_applyBlockDirection`, issue #715).
|
||||
*
|
||||
* The Response Viewer and the File Viewer's markdown view rendered every block left to
|
||||
* right, so a Persian, Arabic or Hebrew answer was left-aligned, its punctuation landed on
|
||||
* the wrong end and its list markers sat on the left. `_applyBlockDirection` now gives each
|
||||
* OUTERMOST prose block `dir="auto"` (the browser picks the direction from the block's own
|
||||
* first letter), and `_renderMarkdown` pins code to `dir="ltr"`, which also keeps a code
|
||||
* span from deciding a sentence's direction, since `dir="auto"` skips descendants that
|
||||
* carry a `dir`.
|
||||
*
|
||||
* The pass runs LAST, on the DOM, after the linkifier: an element that already carries
|
||||
* `dir="auto"` re-resolves its direction on every insertion under it, which made a
|
||||
* code-only or path-only document quadratic. Two tests below pin that order.
|
||||
*
|
||||
* jsdom does not resolve `dir="auto"`, so this pins the attributes the browser acts on and
|
||||
* the order, never the cost; what Chromium makes of them was measured when the change was
|
||||
* made.
|
||||
*
|
||||
* Drives the SHIPPING artifacts (vendored `marked`, vendored DOMPurify + `sanitize-html.js`,
|
||||
* `constants.js` and `app.js`) in a `vm` with a jsdom document, the technique from
|
||||
* response-viewer-external-links.test.ts. No port / server needed.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
||||
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
const { document, NodeFilter } = dom.window;
|
||||
|
||||
function loadShippingSanitizer(): (html: string) => string {
|
||||
const dpModule: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('vendor/dompurify.min.js'))(dpModule, dpModule.exports);
|
||||
const DOMPurify = (dpModule.exports as (win: unknown) => unknown)(jsdomWindow);
|
||||
|
||||
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
|
||||
exports: {},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('sanitize-html.js'))(sanModule, sanModule.exports);
|
||||
const create = sanModule.exports.createMarkdownSanitizer;
|
||||
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
|
||||
return create(DOMPurify);
|
||||
}
|
||||
|
||||
function loadShippingMarked(): { parse: (src: string, opts?: unknown) => string } {
|
||||
const module: { exports: unknown } = { exports: {} };
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||
new Function('module', 'exports', publicFile('vendor/marked.min.js'))(module, module.exports);
|
||||
return module.exports as { parse: (src: string, opts?: unknown) => string };
|
||||
}
|
||||
|
||||
type RenderApp = {
|
||||
_renderMarkdown(text: string, opts?: { breaks?: boolean }): string;
|
||||
_linkifyFilePaths(root: unknown): void;
|
||||
_applyBlockDirection(root: unknown): void;
|
||||
_buildResponseViewerMessage(text: string, role: string, agentLabel: string): HTMLElement;
|
||||
};
|
||||
|
||||
function loadCodemanAppClass(): { prototype: RenderApp } {
|
||||
const context = vm.createContext({
|
||||
console,
|
||||
performance,
|
||||
setInterval: vi.fn(),
|
||||
clearInterval: vi.fn(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
requestAnimationFrame: vi.fn(),
|
||||
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||
fetch: vi.fn(),
|
||||
document,
|
||||
NodeFilter,
|
||||
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
// _sanitizeHtml fails closed without the sanitizer, which would make every
|
||||
// assertion below vacuous.
|
||||
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), sanitizeMarkdownHtml: loadShippingSanitizer() },
|
||||
marked: loadShippingMarked(),
|
||||
MobileDetection: {},
|
||||
});
|
||||
vm.runInContext(
|
||||
`${publicFile('constants.js')}\n${publicFile('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||
context
|
||||
);
|
||||
return (context as { __CodemanApp: { prototype: RenderApp } }).__CodemanApp;
|
||||
}
|
||||
|
||||
const CodemanApp = loadCodemanAppClass();
|
||||
const app = Object.create(CodemanApp.prototype) as RenderApp;
|
||||
|
||||
/** Render markdown the way both viewers do (pipeline, linkifier, direction pass) and return the element. */
|
||||
function render(markdown: string, opts?: { breaks?: boolean }): HTMLElement {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'rv-text';
|
||||
root.innerHTML = app._renderMarkdown(markdown, opts);
|
||||
app._linkifyFilePaths(root);
|
||||
app._applyBlockDirection(root);
|
||||
return root as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
const dirOf = (el: Element | null) => el?.getAttribute('dir') ?? null;
|
||||
|
||||
describe('response viewer right-to-left text (#715)', () => {
|
||||
it('lets every top-level prose block pick its own direction', () => {
|
||||
const root = render(
|
||||
[
|
||||
'# عنوان',
|
||||
'',
|
||||
'سلام، این یک تست است.',
|
||||
'',
|
||||
'An English paragraph.',
|
||||
'',
|
||||
'- مورد اول',
|
||||
'- مورد دوم',
|
||||
'',
|
||||
'1. one',
|
||||
'',
|
||||
'> نقل قول',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
expect(dirOf(root.querySelector('h1'))).toBe('auto');
|
||||
const paragraphs = Array.from(root.querySelectorAll(':scope > p'));
|
||||
expect(paragraphs).toHaveLength(2);
|
||||
for (const p of paragraphs) expect(dirOf(p)).toBe('auto');
|
||||
expect(dirOf(root.querySelector('ul'))).toBe('auto');
|
||||
expect(dirOf(root.querySelector('ol'))).toBe('auto');
|
||||
expect(dirOf(root.querySelector('blockquote'))).toBe('auto');
|
||||
});
|
||||
|
||||
it('leaves nested blocks to inherit, so a list keeps its markers and indent on one side', () => {
|
||||
// A `dir="auto"` per item would resolve each item on its own while the list's
|
||||
// padding stays on the list's side, which hung RTL markers outside the box.
|
||||
const root = render(['- مورد اول', ' - تو در تو', '', '> پاراگراف اول', '>', '> پاراگراف دوم'].join('\n'));
|
||||
|
||||
for (const li of Array.from(root.querySelectorAll('li'))) expect(li.hasAttribute('dir')).toBe(false);
|
||||
expect(root.querySelector('ul ul')?.hasAttribute('dir')).toBe(false);
|
||||
for (const p of Array.from(root.querySelectorAll('blockquote p'))) expect(p.hasAttribute('dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('gives a table its direction on the scroll wrapper, so an RTL table scrolls from its first column', () => {
|
||||
const root = render('| ستون | مقدار |\n| --- | --- |\n| الف | ۱ |');
|
||||
|
||||
const wrap = root.querySelector('.rv-table-wrap');
|
||||
expect(wrap, 'tables are still wrapped').not.toBeNull();
|
||||
expect(dirOf(wrap)).toBe('auto');
|
||||
expect(root.querySelector('table')?.hasAttribute('dir')).toBe(false);
|
||||
for (const cell of Array.from(root.querySelectorAll('th, td'))) expect(cell.hasAttribute('dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('pins code blocks and inline code left to right', () => {
|
||||
const root = render('`npm install` را اجرا کن.\n\n```\nconst a = 1;\n```');
|
||||
|
||||
expect(dirOf(root.querySelector('p > code'))).toBe('ltr');
|
||||
expect(dirOf(root.querySelector('pre'))).toBe('ltr');
|
||||
// The code-block toolbar shares the template walk; it must survive the new pass.
|
||||
expect(root.querySelector('.rv-code-wrap .rv-copy-btn')).not.toBeNull();
|
||||
expect(root.querySelector('.rv-code-wrap')?.hasAttribute('dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a dir the agent wrote, and what it covers', () => {
|
||||
// The sanitizer allows `dir`; an explicit choice must not be replaced by a guess.
|
||||
const root = render('<div dir="rtl"><p>inherits</p></div>\n\n<p dir="ltr">explicit</p>');
|
||||
|
||||
expect(dirOf(root.querySelector('div[dir]'))).toBe('rtl');
|
||||
expect(root.querySelector('div[dir] > p')?.hasAttribute('dir')).toBe(false);
|
||||
expect(dirOf(Array.from(root.querySelectorAll('p')).find((p) => p.textContent === 'explicit') ?? null)).toBe('ltr');
|
||||
});
|
||||
|
||||
it('does the same for the File Viewer markdown view (breaks: false)', () => {
|
||||
const root = render('پاراگراف اول\nادامه همان پاراگراف', { breaks: false });
|
||||
|
||||
expect(dirOf(root.querySelector('p'))).toBe('auto');
|
||||
});
|
||||
|
||||
it('pins a linkified file path left to right, so it does not decide the sentence direction', () => {
|
||||
const root = render('فایل /home/user/project/config.json را باز کن.');
|
||||
|
||||
const link = root.querySelector('a.rv-path');
|
||||
expect(link?.textContent).toBe('/home/user/project/config.json');
|
||||
expect(dirOf(link)).toBe('ltr');
|
||||
});
|
||||
|
||||
it('does not pin a markdown link label, which is prose in the sentence language', () => {
|
||||
const root = render('[گزارش حادثه](/tmp/report.json)');
|
||||
|
||||
const link = root.querySelector('a.rv-path-link');
|
||||
expect(link, 'a path-target link takes the rv-path shape').not.toBeNull();
|
||||
expect(link?.hasAttribute('dir')).toBe(false);
|
||||
});
|
||||
|
||||
it('never puts dir="auto" in the rendered string, where it would make the parse and the linkifier quadratic', () => {
|
||||
// An element carrying dir="auto" re-resolves its direction on every insertion
|
||||
// under it. A document whose letters all sit in `dir` children walks the whole
|
||||
// block each time, which froze the tab for seconds on an all-code list.
|
||||
const html = app._renderMarkdown('- `f1`\n- `f2`\n\nسلام\n\n| ستون |\n| --- |\n| الف |');
|
||||
|
||||
expect(html).not.toContain('dir="auto"');
|
||||
expect(html).toContain('<code dir="ltr">f1</code>');
|
||||
});
|
||||
|
||||
it('gives a Response Viewer message its direction only after the linkifier finished', () => {
|
||||
const probe = Object.create(app) as RenderApp;
|
||||
let autoDuringLinkify = -1;
|
||||
probe._linkifyFilePaths = (root) => {
|
||||
autoDuringLinkify = (root as Element).querySelectorAll('[dir="auto"]').length;
|
||||
app._linkifyFilePaths(root);
|
||||
};
|
||||
|
||||
const message = probe._buildResponseViewerMessage('/home/user/project/config.json را ببین.', 'assistant', 'Claude');
|
||||
|
||||
expect(autoDuringLinkify).toBe(0);
|
||||
const para = message.querySelector('.rv-text p');
|
||||
expect(dirOf(para)).toBe('auto');
|
||||
expect(dirOf(para?.querySelector('a.rv-path') ?? null)).toBe('ltr');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user