Codex+Opus gate (both reproduced): the new mixed-content ordered render path
resolved tool snippets only from the S.messages join (_collectToolResultSnippetsByTid)
and never consulted persistedSnippetByTid (S.session.tool_calls) — so on a
cold/paginated load where the tool_result join misses (#4927 scenario) the inline
card rendered an EMPTY body, AND its tid was added to transparentOrderedToolIds,
suppressing the post-loop derived card that WOULD have recovered via #4927. Build
a transparentPersistedSnippetByTid map from S.session.tool_calls and thread it as a
5th arg to _transparentOrderedToolCall, falling back in both the live and non-live
branches. Also gate the transparent-only scans (S.toolCalls + _collectToolResultSnippetsByTid)
behind isTransparentStream() so non-transparent renders skip the O(messages×parts)
work (Opus perf finding). Redaction (#4926/#4928) was already safe (shared buildToolCard).
+ cold-load persisted-fallback regression test.
Codex gate: the third derived-push site (Anthropic tool_use content-array branch,
ui.js) still read resultsByTid[tid]||'' without the persisted fallback, so
Anthropic-format sessions could still cold-reload with an empty tool body. Add
the persistedSnippetByTid fallback there too; now all derived result-snippet
reads (OpenAI top-level, Anthropic tool_use, partial) chain to the durable
persisted snippet. Added a guard test asserting EVERY derived resultSnippet read
includes the fallback.
In the transparent stream, terminal output and patch diffs intermittently
vanished after a cold/paginated reload. A settled tool row renders its body from
`snippet`, but the raw-assistant-envelope 'derived' rebuild only joined the
result by tool id via resultsByTid (role:tool messages); when that join missed
(id mismatch, recovery-rebuilt turn) the snippet was empty, and #4622's live
enrichment only helps when S.toolCalls still matches (empty on cold load).
Build a persistedSnippetByTid lookup from the durable session.tool_calls compact
summary (which carries a bounded result snippet per tid) and use it as a
fallback in all three derived-push sites, so the persisted record is the
reliable source and the live join becomes a fast-path, not the only path that
shows the body. Part of #4925.
Codex gate: raising content args from 120->4000 chars newly surfaces secrets on
non-first lines / past char 120 in render paths that print tc.args directly and
bypass the #4926 label redaction. Apply _redactToolTargetLabel to arg values at
every such site: the transparent Full-tab args render (_transparentToolDetailHtml),
the non-transparent tool-card args block (buildToolCard visibleArgs), the
clipboard copy path (_copyEventToClipboard), and the _toolArgsSnapshot itself.
+ node regression proving a secret past char 120 in a long command is masked in
the Full tab while the non-secret command structure is retained.
Tool-call args were truncated to 120 chars at three sites (_truncate_tool_args,
the live _tool_args_snapshot in api/streaming.py, and _toolArgsSnapshot in
static/ui.js). For incidental args that's fine, but content/diff-bearing keys
(command/cmd/script/code/patch/diff/old_string/new_string/content/path/
file_path) feed the rendered tool card AND the diff reconstruction in
recovery-rebuilt sessions, so the cap silently corrupted long commands/paths and
produced broken/empty diffs. Exempt those keys with a much larger cap
(_TOOL_ARG_CONTENT_CAP = _TOOL_RESULT_SNIPPET_MAX = 4000) at every site, keyed by
key name; incidental args keep the 120 cap; storage stays bounded. Part of #4925.
Codex re-gate: the env/AUTH '=' redaction regex matched 'token=' INSIDE a URL
(?token=...&mode=debug&signature=...) before the dedicated URL-query redactor,
and its \S+ value swallowed the rest of the URL incl later params + closing
quote. Anchor the env-assignment match to a shell-token boundary (^|[\s;|(]) so
it can't match keys preceded by ? or & — URL query params are left to the
dedicated query-param redactor, which masks the secret-looking ones while
preserving the command tail. + regression tests (URL-tail-preserved,
benign-PATH-not-over-redacted).
Codex gate: exposing the full multi-line command in the expanded shell card
newly surfaces secrets on non-first lines (the collapsed header only ever showed
line 1). The old redactor masked only password-shaped fields. Broaden it to mask
common secret forms across the WHOLE command: env/colon assignments for
*TOKEN/*API_KEY/*SECRET/*ACCESS_KEY/*PRIVATE_KEY/*CREDENTIAL/*CLIENT_SECRET/
*SESSION_KEY and *AUTH*=, --token/--api-key/--secret style flags,
Authorization: Bearer/Bot/Token headers, and secret-looking URL query params.
Test now drives the REAL redactor (not an identity stub) and asserts non-first-line
secrets are masked while the collapsed header stays first-line.
In the transparent activity stream, a shell tool card's collapsed header
correctly shows only the first line of the command, but the EXPANDED detail
lead (`$ <command>`) reused that same first-line-only value via
_toolTargetLabel, so a multi-line script still rendered as line 1 when expanded.
The full command is available frontend-side; the truncation was purely in the
label derivation.
Adds _toolFullCommandLabel(tc) (full multi-line, redacted, no first-line
collapse) and uses it in _toolDetailLeadText for the shell kind, while the
collapsed header keeps the first-line _toolTargetLabel. Part of #4925.
Inline code pass converts `code` to <code>...</code> BEFORE the table
pass runs, so the _protectPipes backtick regex never matched. Changed
to protect pipes inside <code>...</code> tag pairs instead.
The _protectPipes function protected | inside brackets but not inside
backtick inline-code spans, so || in `code || here` inside a table
cell was split into extra columns. Added a second replace for matched
backtick pairs in the same iterative loop.
LLMs routinely emit JSON/YAML *fragments* in fenced blocks — a bare
`"key": "value"` line, snippets with `...`, trailing commas — which
legitimately fail JSON.parse. initTreeViews() stamped an unstyled
`<div class="tree-parse-note">parse failed</div>` below such blocks,
producing bare white text under an otherwise-clean code box.
The note was incidental, not designed: it was bolted onto the YAML
lazy-load commit (29a31a6e), has no CSS, no behavior test, and originally
even fired on *valid* YAML before the CDN script finished loading (later
fixed). The block still renders as syntax-highlighted raw, so the note
added only noise.
Remove the note insertion; fall through silently to raw view. The per-block
Raw/Tree toggle, YAML lazy-load, and the #484 default-view config are all
unchanged. The `parse_failed_note` i18n key is intentionally retained (kept
green by test_chinese_locale.py) to avoid touching all 13 locales.
Verification: ./scripts/test.sh tests/test_issue484_json_tree_viewer.py
tests/test_chinese_locale.py — 29 passed.
Codex re-gate: the capture-phase window 'drop' listener fired BEFORE the target
el.ondrop, clearing _wsActiveDragPath before _isWorkspaceTreeMoveDrag/_wsDragSrcPath
ran — re-breaking the macOS stripped-MIME move it set out to fix. setTimeout(_clearWsDragData,0)
lets the real drop handler complete, then clears the lingering flag.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Codex gate found a SILENT hazard: if a workspace drag is abandoned without
ondragend firing (cancel/drop-outside/blur), _wsActiveDragPath lingered and a
later FOREIGN text/plain drag could be misread as a workspace move. Hardening:
- _wsDragSrcPath now only trusts the active flag when the drop's own text/plain
EQUALS _wsActiveDragPath (a foreign/empty text drag resolves to '' -> no move).
- Shared _clearWsDragData() called from ondragend, a drop finally, and
window-level dragend/drop/pagehide/blur fallbacks (lost-dragend cleanup).
- 2 regression tests (stale-flag foreign-text -> empty; _clearWsDragData resets),
proven non-vacuous.
Co-authored-by: Rod Boev <rod.boev@gmail.com>
Codex found the guard had a first-event gap: programmatic seeds of _lastScrollTop
(_setMessageScrollToBottom, scrollToBottom, snapshot restore paths) didn't seed
_lastMessageClientHeight, so the FIRST native toolbar-collapse scroll saw a null
prior height -> grew=false -> the false-unpin still fired. Now every programmatic
_lastScrollTop=el.scrollTop is paired with _lastMessageClientHeight=el.clientHeight.
iOS Safari portrait resolves its dynamic toolbar height after first paint; when the
toolbar collapses the #messages scroller grows (clientHeight up), firing a scroll
event with decreased scrollTop that was misread as an upward user scroll -> falsely
set _messageUserUnpinned, and the ResizeObserver settle then self-cancelled, leaving
the reader stranded at the TOP of a freshly-opened conversation (landscape unaffected
since it doesn't resize late). Sibling of #4701.
Fix (touch-safe, no-op on desktop/landscape where clientHeight is stable):
- Track _lastMessageClientHeight; gate movedUp on !grew so a container-height
INCREASE can't be read as an upward scroll. Reset on session switch.
- For explicit (open) settles, also observe the scroller itself so a late portrait
toolbar collapse re-anchors the bottom.
Independent of #4818 (overflow-anchor paint-gap) — different mechanism, coexists.
Diagnosis credit: investigation. Co-authored-by: reinocheong <reporter>
- Remove dead _scrollAnchorRestore variable (returns undefined)
- Remove redundant _fixMobileScrollJank() call from _doRender()
(CSS already gives overflow-anchor:auto on mobile via media query;
desktop exits guard early — call was a no-op on every tick)
- Update JSDoc on _fixMobileScrollJank to reflect actual usage
Root cause: .messages had overflow-anchor:none unconditionally. On mobile
touch devices (iOS Safari, Android Chrome), the compositor can paint a frame
with scrollTop=0 between innerHTML='' and snapshot restore, scroll-janking
the user to the top of the transcript.
Three-layer fix:
1. CSS (style.css): Use overflow-anchor:auto on touch devices (mobile),
keep overflow-anchor:none on desktop (mouse-driven) via
@media (hover:hover) and (pointer:fine) — preserves the existing
behavior where tool/card inserts don't yank a reading user.
2. JS guard (ui.js): Add window._fixMobileScrollJank() that temporarily
sets overflowAnchor='auto' on #messages before innerHTML='' and
clears it one rAF later — prevents the compositor frame with scrollTop=0.
3. Streaming protection (messages.js): Call _fixMobileScrollJank() at the
top of each _doRender() tick so streaming DOM growth doesn't jank the
viewport position.
Tests:
- Updated test_issue1360_streaming_scroll_hardening.py: split the
overflow-anchor test into desktop (none) and mobile (auto) variants.
- Added test_mobile_scroll_jank.html: interactive visual test that
simulates the scroll jank on a mobile-sized viewport.
The JSON/YAML tree viewer (#484) hardcoded its default: valid json/yaml
fenced blocks opened in Tree view at 10+ lines (`const showTree=lineCount>=10;`).
Make that default user-configurable while preserving current behavior.
- New settings (persisted via /api/settings -> settings.json):
- structured_code_default_view: auto | on | off (default auto)
- structured_code_auto_tree_lines: int 1..1000 (default 10)
- ui.js: replace the hardcode with a sanitized, unit-testable decision
helper _structuredCodeShowTree(mode, threshold, lineCount); Tree/Raw
toggle, Raw <pre><code>, parse-failure fallback, and YAML lazy-load
are unchanged.
- Appearance settings: 'JSON/YAML code blocks' select + 'Auto threshold
lines' input (disabled outside auto mode); wired through the existing
appearance-autosave and explicit-save paths; live re-render on change.
- boot.js seeds window globals; i18n labels added to all 13 locales.
- Default (auto + 10) reproduces the original >=10 behavior exactly.
Opus caught a narrow timing race: if a reader re-pins between the scroll-rebuild
guard engaging and its deferred rAF firing, the stale re-restore would yank them
back off the bottom. Gate the re-restore on _messageUserUnpinned at rAF time; the
minHeight release still always runs. Normal single-render unpinned case unaffected.
Codex regression gate caught: the guard predicate keyed on a raw messagesEl.scrollTop>0,
so a pinned reader transiently at bottomDistance>250 mid-render (assistant body grows
before the anchor scene re-renders) got flipped to _messageUserUnpinned=true/_scrollPinned=false
and lost auto-follow. Require an explicit non-follow signal (_messageUserUnpinned || _scrollPinned===false)
instead. Updated the existing guard test to a genuine unpinned reader + added a pinned-large-growth
regression. Co-authored-by: franksong2702