Skip to content

ADE-90: Desktop/CLI security hardening: production renderer CSP, OS-bind the credential-store key, chmod the built-in-browser bridge socket - #490

Merged
arul28 merged 3 commits into
mainfrom
ade-90-desktop-cli-security-hardening-production-renderer-csp-os-bind-the-credential-store-key-chmod-the-built-in-browser-bridge-socket
May 31, 2026
Merged

ADE-90: Desktop/CLI security hardening: production renderer CSP, OS-bind the credential-store key, chmod the built-in-browser bridge socket#490
arul28 merged 3 commits into
mainfrom
ade-90-desktop-cli-security-hardening-production-renderer-csp-os-bind-the-credential-store-key-chmod-the-built-in-browser-bridge-socket

Conversation

@arul28

@arul28arul28 commented May 31, 2026

Copy link
Copy Markdown
Owner

Fixes ADE-90

Summary

  • Tighten packaged renderer CSP by removing production script-src 'unsafe-inline' and the blanket connect-src https: allowance while keeping the dev-only Vite inline-script allowance.
  • OS-bind the encrypted file credential store key with macOS Keychain-derived material, add legacy ciphertext migration, and route desktop credentials through Electron safeStorage when available.
  • Harden the built-in browser bridge Unix socket by enforcing private directory/socket modes, covering pre-existing directories, tightening umask during listen, and failing closed if socket chmod fails.

Validation

  • npm --prefix apps/desktop run test -- src/main/rendererCsp.test.ts src/main/services/builtInBrowser/desktopBridgeServer.test.ts
  • npm --prefix apps/ade-cli run test -- src/services/credentials/credentialStore.test.ts
  • npm --prefix apps/ade-cli run typecheck
  • npm --prefix apps/desktop run typecheck
  • git diff --check

Risks

  • Existing legacy credential files are migrated opportunistically on successful reads; if OS-bound material is unavailable for a migrated file, ADE now surfaces a clear unlock/credential-store availability error instead of silently falling back to the wrong format.
  • style-src 'unsafe-inline' intentionally remains unchanged for existing renderer styling behavior.

Linked Linear issues

ADEOpen in ADE · lane branch · PR #490

Greptile Summary

This PR applies three hardening changes to the ADE desktop and CLI: (1) the packaged renderer CSP drops script-src 'unsafe-inline' and the blanket connect-src https: in production, (2) the file-based credential store derives its encryption key by HKDF-mixing the machine key with macOS Keychain-retrieved material, and (3) the built-in browser bridge Unix socket directory is chmod'd unconditionally and a restrictive umask is applied before server.listen().

  • CSP tightening – removes 'unsafe-inline' from script-src in packaged builds and drops https: from connect-src in both prod and dev; dev mode retains 'unsafe-inline' for Vite preambles.
  • Credential-store OS binding – introduces EncryptedFileCredentialStore.keyMaterialProvider, a macOS Keychain round-trip via security(1) using stdin (avoiding CLI argument exposure), HKDF derivation, and a transparent in-place migration from legacy machine-key-only files.
  • Socket hardeningchmodSync(socketDir, 0o700) now runs unconditionally after mkdirSync to harden pre-existing directories, and process.umask(0o177) is set before listen() so the socket file itself is created restricted from the start.

Confidence Score: 4/5

Safe to merge with minor follow-up; the three previously flagged issues are all addressed, and no new blocking problems were found.

The credential-store migration logic is complex (two independent fallback chains that interact), and the keyMaterialProvider can be called twice within a single readAllwriteAll round-trip via the migration path. For the default in-process provider this is harmless due to the module-level cache, but the pattern is fragile for injected providers. The socket and CSP changes are clean and well-tested.

apps/ade-cli/src/services/credentials/credentialStore.ts — the migration write path in readAll() re-invokes keyMaterialProvider through writeAll, and the keychain bootstrap doesn't validate the round-trip after spawnSync succeeds.

Important Files Changed

FilenameOverview
apps/ade-cli/src/services/credentials/credentialStore.tsAdds OS-bound key derivation, macOS Keychain bootstrap via stdin pipe (addressing prior CLI-arg-exposure comment), and safeStorage migration with hasMagic guard; previously flagged fallback-on-any-error is resolved.
apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.tsSocket hardening: unconditional chmodSync(socketDir, 0o700) fixes pre-existing-directory gap, umask 0o177 before listen mitigates TOCTOU, and chmod failure now closes the server (fail-closed).
apps/desktop/src/main/rendererCsp.tsRemoves 'unsafe-inline' from production script-src and drops blanket https: from connect-src; dev-mode retains 'unsafe-inline' for Vite.
apps/desktop/src/main/main.tsReplaces four hardcoded EncryptedFileCredentialStore instantiations with createDesktopCredentialStore, which prefers ElectronSafeStorageCredentialStore and fails closed if safeStorage is unavailable but a safeStorage-format file exists.
apps/ade-cli/src/services/credentials/credentialStore.test.tsAdds tests for OS-bound key derivation, legacy migration paths, and the safeStorage no-fallback-after-magic guarantee; coverage is solid for the new paths.
apps/desktop/src/main/rendererCsp.test.tsAdds assertions for the tightened production CSP (no unsafe-inline in script-src, no https: in connect-src) and updates existing tests accordingly.
apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.tsNew test file; exercises socket directory chmod on pre-existing 0o755 dirs and verifies umask is set before listen and restored after.

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---### Issue 1 of 2
apps/ade-cli/src/services/credentials/credentialStore.ts:303-325
`readAll()` migration silently writes with OS-bound key, but `writeAll()` internally calls `readOrCreateMachineKey` again. More importantly, `writeAll()` could silently fail (`catch {}`) and the values are returned — fine. But there's a different subtle problem: the migration write re-reads the `keyMaterialProvider()`. If the provider returns a different buffer on the second call (e.g., a one-shot env passphrase that gets consumed, or a cache miss returning a fresh Keychain read), the written key would differ from the one used on the first `readAll()` call inside `writeAll()`. The `cachedDefaultOsBoundKeyMaterial` prevents this for the default provider, but an externally-supplied `keyMaterialProvider` has no such guarantee. Capturing the resolved material once at the start of `readAll` and reusing it in the same invocation is safer.
```suggestion private readAll(): Record<string, string> { const raw = readJsonObject(this.credentialsPath); const machineKey = readOrCreateMachineKey(this.machineKeyPath); const osMaterial = this.keyMaterialProvider(); const key = deriveOsBoundCredentialKey(machineKey, osMaterial); try { return deserializeStore(raw, key); } catch (error) { if (key.equals(machineKey)) throw error; const values = deserializeStore(raw, machineKey); try { this.writeAllWithKey(values, key); } catch { // Preserve read compatibility if migration cannot rewrite right now. } return values; } } private writeAll(values: Record<string, string>): void { const machineKey = readOrCreateMachineKey(this.machineKeyPath); const key = deriveOsBoundCredentialKey(machineKey, this.keyMaterialProvider()); this.writeAllWithKey(values, key); } private writeAllWithKey(values: Record<string, string>, key: Buffer): void { writeFileAtomic(this.credentialsPath, `${JSON.stringify(serializeStore(values, key), null, 2)}\n`); }```### Issue 2 of 2
apps/ade-cli/src/services/credentials/credentialStore.ts:222-223
When `readOrCreateMacKeychainMaterial()` creates a new keychain entry, it reads the secret back indirectly by reusing the generated `secret` string. But if `spawnSync` succeeds (status 0) and the tool did not actually consume stdin as the password (e.g., a future macOS version changes `-w` stdin behaviour), the stored value could silently be empty. The subsequent call to `execFileSync("security find-generic-password ... -w")` would then return an empty string, causing `decoded.length >= 32` to be false and falling to `Buffer.from("", "utf8")`, which triggers the `osMaterial.length === 0` guard and silently disables OS binding. Consider adding an explicit `decoded.length < 32` guard on the stored-value path so that an unexpectedly short round-trip throws rather than silently falling back to no OS binding.
```suggestion if (result.status !== 0) return null; const decoded = Buffer.from(secret, "base64"); if (decoded.length < 32) return null; return decoded;```

Reviews (3): Last reviewed commit: "Harden existing browser bridge socket di..." | Re-trigger Greptile

@linear-code

Copy link
Copy Markdown

ADE-90

@vercel

vercelBot commented May 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
adeIgnoredIgnoredPreviewMay 31, 2026 10:39am

@arul28

Copy link
Copy Markdown
OwnerAuthor

@copilot review but do not make fixes

@capy-ai

capy-aiBot commented May 31, 2026

Copy link
Copy Markdown

Capy auto-review is paused for this organization because the monthly auto-review limit has been reached. Increase the limit or turn it off in billing settings to resume automatic reviews.

@coderabbitai

coderabbitaiBot commented May 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@arul28, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 13 minutes and 47 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1a86192c-35ac-4cce-a59c-149603f5c16d

📥 Commits

Reviewing files that changed from the base of the PR and between ca46616 and acb6163.

📒 Files selected for processing (7)
  • apps/ade-cli/src/services/credentials/credentialStore.test.ts
  • apps/ade-cli/src/services/credentials/credentialStore.ts
  • apps/desktop/src/main/main.ts
  • apps/desktop/src/main/rendererCsp.test.ts
  • apps/desktop/src/main/rendererCsp.ts
  • apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.test.ts
  • apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ade-90-desktop-cli-security-hardening-production-renderer-csp-os-bind-the-credential-store-key-chmod-the-built-in-browser-bridge-socket

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cursorcursorBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Scope: 7 file(s), +320 / −26
Verdict: Minor issues

This PR tightens production renderer CSP (drops blanket https: in connect-src and packaged unsafe-inline scripts), OS-binds file-store encryption via macOS Keychain HKDF on Darwin, routes desktop credential access through safeStorage with legacy migration, and hardens the built-in browser bridge Unix socket directory/socket modes. The direction is sound; a couple of availability edge cases around credential fallback are worth addressing before merge.


🐛 Functionality

[Medium] OS-bound credential file unreadable when key material is unavailable

File: apps/ade-cli/src/services/credentials/credentialStore.ts:L263-L278
Issue: After credentials are re-encrypted with the OS-bound HKDF key, readAll() only falls back to the on-disk .machine-key when the derived key differs from machineKey. If keyMaterialProvider() returns null (locked macOS Keychain, ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING=1, or non-Darwin), decryption uses machineKey and fails with no legacy fallback—reads throw and stored tokens are unavailable until OS material is available again.
Repro: On macOS, write credentials with default OS binding; lock the login Keychain or run with ADE_CREDENTIAL_STORE_DISABLE_OS_BINDING=1; call getSync on the same secretsDir (CLI daemon or desktop file-store fallback).
Fix: On decrypt failure, attempt deserializeStore(raw, machineKey) whenever OS material is non-null (not only when !key.equals(machineKey)), or keep a version flag in the envelope so pre-bound ciphertext remains readable during keychain lock.

[Medium] safeStorage-migrated blob incompatible with file-store fallback

File: apps/desktop/src/main/main.ts:L376-L389
Issue: createDesktopCredentialStore() returns a bare EncryptedFileCredentialStore when safeStorage.isEncryptionAvailable() is false. If credentials.json.enc was already rewritten as a safeStorage ciphertext, the file store path tries AES-GCM JSON deserialization and fails—there is no path to read the safeStorage blob without Electron.
Repro: On macOS, persist credentials via ElectronSafeStorageCredentialStore; later start ADE when safeStorage.isEncryptionAvailable() is false (Keychain unavailable / headless mis-detect); observe credential reads error despite the file existing.
Fix: When safeStorage is unavailable, still wrap with a read-only migration path (or detect safeStorage magic/format and surface a clear “unlock Keychain” error), or avoid returning the AES store alone once the file is in safeStorage format.


🔒 Security

[Low] Unix socket may retain permissive mode if chmod fails

File: apps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts:L119-L128
Issue: The bridge sets 0o600 on the socket in the listen callback, but only logs on failure. A default umask can leave the socket group/world accessible on multi-user hosts, weakening the hardening this PR adds.
Attack path: Local attacker on a shared machine connects to a world-readable desktop-bridge.sock and invokes allowlisted built_in_browser.* JSON-RPC (browser control) without going through ADE UI.
Fix: Treat chmod failure as fatal for Unix sockets (stop listening / retry), or set umask(0o077) before listen and verify mode before advertising the socket path to the daemon.


Notes

  • CSP changes align with actual renderer network usage (IPC + localhost simulator paths); tests document the intended packaged vs dev split.
  • OS binding and safeStorage migration tests cover happy-path upgrades well; Linux/CI still rely on file-only encryption (no Darwin Keychain), which matches current platform support.
  • Could not run Vitest in this environment (vitest not on PATH under apps/ade-cli); findings are from static review of the diff at b4bbcf9f.
Open in WebView Automation

Sent by Cursor Automation: BUGBOT in Versic

Comment threadapps/desktop/src/main/services/builtInBrowser/desktopBridgeServer.ts Outdated
Comment threadapps/ade-cli/src/services/credentials/credentialStore.ts
Comment threadapps/ade-cli/src/services/credentials/credentialStore.ts
@arul28
arul28force-pushed the ade-90-desktop-cli-security-hardening-production-renderer-csp-os-bind-the-credential-store-key-chmod-the-built-in-browser-bridge-socket branch from fdca051 to acb6163CompareMay 31, 2026 10:39
@arul28
arul28 merged commit b004068 into mainMay 31, 2026
5 checks passed
@arul28
arul28 deleted the ade-90-desktop-cli-security-hardening-production-renderer-csp-os-bind-the-credential-store-key-chmod-the-built-in-browser-bridge-socket branch May 31, 2026 17:07
arul28 added a commit that referenced this pull request Aug 25, 2026
* ios(work chat): make transcript scroll corrections defer to the reader
Four scroll-correctness defects in the Work chat transcript:
- A prepend correction whose probe described a different row than the armed
anchor fell through with a zero row shift, which reduces the correction to
the reader's own scroll delta and applies it a second time. It now bails out
and waits for a usable measurement.
- Programmatic scroll writes only checked the drag gesture, which ends at
finger-up rather than at the end of the fling. They now also defer to the
scroll phase (tracking/interacting/decelerating), so a pin or a correction
never fights a fling for the offset. A correction deferred this way stays
armed and applies once the fling settles — the measurement isolates the
insertion from the reader's scrolling, so it restores the same position.
- A second prepend inside an open correction window was dropped, leaving the
first insertion uncorrected. Overlapping prepends now keep the existing
anchor, whose row was pushed down by both insertions, and only extend its
window.
- The opening pin fired once and disarmed, so hydration landing after the
retry ladder grew the content under an offset nobody re-pinned. It now stays
armed until the content size has been quiet for 600ms or the reader drags
deliberately (16pt, up from the 2pt stickiness deadband). Chats also open at
the tail via defaultScrollAnchor(.bottom, for: .initialOffset), short
transcripts render top-anchored like desktop, and a one-entry chat skips the
force-pin entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): derive transcript scroll distances from one geometry sample
The content-top and content-bottom probes each laid out a GeometryReader and
pushed a value through the preference reduce/observe machinery on every frame
of every scroll, to report two numbers the scroll view already publishes.
Both are gone; distance-from-top and distance-from-bottom now come off the
existing `onScrollGeometryChange` sample.
The per-frame observer is now strictly O(1) work. The tail scan in
`resolvePendingInitialBottomPinAfterLayout` moved onto a second observer keyed
to content SIZE, which by construction cannot fire while the reader is only
scrolling — it also drives the short-transcript top-anchor flip.
`workChatShouldRequestOlderHistory` now takes `distanceFromTop` (grows
downward) instead of the probe's `topY` (grew upward, negative).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): stabilize row identity, budgets, and per-refresh text cost
Row identity: markdown block ids were content-derived, so every streaming
delta and every "Show more" step handed the LazyVStack a new identity for a
row that was still the same row — reuse was impossible by construction. Ids
are now position-stable (`markdown-block-<index>`) and the content digest
moved to its own field, used only for change detection.
Per-refresh text work, all of it on the main thread and proportional to the
whole visible transcript:
- The preview cache recomputed `markdown.utf8.count` + `markdown.hashValue`
on every lookup, so a cache HIT still cost O(message). It now keys off the
digest the (off-main) snapshot fold stamps on each message, and holds one
preview per line budget instead of only the initial one.
- The presentation signature hashed every message's full markdown, every
preview's full text, every monospaced slice, and rebuilt each markdown
block's `cacheKey` (a full copy of the block's text) to hash that too. All
four now read stored digests plus the preview's shape.
- `workAssistantMessagePreview` copied the whole message to normalize CRLF
even when there was no CR to normalize.
- `workToolResultTruncate` counted graphemes over the whole result on every
body pass; it now pre-filters on stored UTF-8 length.
Row views: WorkChatMessageBubble, WorkToolCardView, WorkEventCardView,
WorkAdeCardView, WorkCommandCardView and WorkFileChangeCardView are Equatable
and rendered through `.equatable()`. WorkToolCardView's navigation-reference
extraction (which concatenates a tool's arguments and result) moved inside the
view, so a collapsed row no longer pays for it.
"Show more" is now deterministic:
- A message's budget may grow but never shrink. The newest assistant message
renders tail-anchored under a generous budget; when a newer message arrived
it flipped to head-anchoring and dropped back to 48 lines, so a message the
reader had just read in full grew a "Show more" behind their back. The
budget it already rendered under is now its floor.
- The bubble's private `@State` budget is gone. Both show-more paths write the
transcript's shared budget map, so expansion survives LazyVStack recycling
and the two paths cannot disagree.
- A tap no longer re-pins the transcript to its bottom (which threw the reader
to the end of the chat for asking to see more of a message in the middle of
it). The message flips to head-anchoring and the tapped row is held in place.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): de-nest the transcript's inner box scrollers
WorkStructuredOutputBlock, WorkDiffOutputBlock and WorkInlineDiffPreview each
put a vertical ScrollView inside the transcript's own vertical ScrollView, then
capped it with a maxHeight. Nested same-axis scrollers compete for every drag
that starts on them, and these ones only ever clipped — no gesture reached past
their cap from inside the box anyway. They are clipped fixed-height content
now; the diff blocks keep their horizontal scroller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): cover the scroll and budget contracts with tests
Extracts the two prepend-anchor decisions (arm, correct) into pure functions so
the policy is assertable rather than reachable only through a live ScrollView,
then covers:
- probe/anchor row mismatch produces no correction
- overlapping prepends keep the anchor that accumulates both insertions
- a correction waits out the reader without spending an attempt
- the correction isolates an insertion from the reader's own scrolling
- programmatic scrolls defer to the whole interaction, and `.animating` (our
own animation) is not the reader's
- a short transcript renders from the top
- a message rendered fully as the tail is never truncated afterwards, and
"Show more" steps from the budget it is actually rendering under
- markdown block ids are index-based and survive content edits, including
across streaming deltas
Also documents the new contracts in the iOS companion doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): drop the now-unused text signature helper
Every caller reads a stored digest instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): add a full-screen viewer for boxed output
Phase 1 de-nested the transcript's inner scrollers, so tool results, command
output and diffs now clip at a fixed height with nothing to scroll — whatever
sits past the cut is unreachable in place. This is where it goes.
One screen serves every kind of box: monospaced, line-numbered, lazy (the text
can be a 100k-character tool result, and one Text that long with a gutter would
lay the whole thing out before drawing a row), with a wrap toggle, in-text
search that counts occurrences rather than lines and steps through them, Copy
all, and the system share sheet. Diffs keep their add/remove tinting and code
keeps its syntax highlighting, except on a line the search matched — two sets
of competing colours on one line reads as noise.
Also lands the pieces the boxes need:
- `WorkOutputViewerModel` in the environment, so a surface owns one
presentation host instead of every transcript row carrying a `fullScreenCover`
it almost never fires.
- `workOutputBoxOverflows`, which decides whether a box is clipping and the
viewer is worth offering, without scanning a long result to find out.
- `workTruncatedOutputAffordance`, the hybrid ladder as one decision.
- `WorkCodeBlockSource`, which locates a rendered code block inside the message
it was sliced from so Copy can reach the whole thing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): copy the whole output, and open it, from every box
Copy controls in the transcript were handing over previews of their own
content. A fenced code block copied the slice the transcript happened to be
rendering; the tool-result box copied its 500-character truncation. Both look
like a working Copy button and both lose data silently.
- Code blocks resolve against the message they were sliced from, by ordinal:
from the front for a head-anchored preview, from the back for a tail-anchored
one (which also carries a synthetic opening fence, and is exactly the case
that copied a fragment). Resolution runs at tap time, not per render pass.
- The tool-result box takes the untruncated result as `copyText` while it keeps
displaying the slice.
- The diff boxes, which had no Copy at all, get one — plus the file path, so the
viewer they open can be titled with it.
Every box that is clipping now offers the viewer: from its header, and by
tapping the clipped region itself. Copy and Open both take 44pt targets.
The transcript's expand ladder becomes hybrid. The first "Show more" still
expands downward in place; anything still bounded after that step opens the
viewer rather than paginating a reader through a thousand more lines four
dozen at a time. For the result box that step is mandatory reasoning, not
taste: it clips at 180pt, so a second in-place expansion would add text nobody
can see.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): cover full-content copy and the expand ladder
Runs the real render path — preview slicer, timeline entries, per-block render
models — rather than the resolver alone, so the plumbing is what is asserted:
- a head-anchored slice's code blocks copy the whole block, and the last one
really was partial (otherwise the test proves nothing)
- a tail-anchored slice does too, through the synthetic opening fence, with
ordinals counted from the end; the fence block copies the full block and not
the fragment on screen
- an ordinal that cannot be located falls back to what is on screen instead of
copying some other block
- the tool-result box shows a slice and copies the whole result, in both states
- the hybrid ladder: show more once, then the viewer, and nothing at all when
the whole box is visible
- `workOutputBoxOverflows` counts wrapped lines for a wrapping box but only
hard breaks for a diff, which scrolls horizontally instead
- viewer search counts occurrences rather than lines, and steps wrap both ways
Also documents the ladder and the copy contract in the iOS companion doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): keep scroll-back alive after a dropped history page
One failed older-history page latched `olderHistoryLoadError`, and both
scroll-back gates refused to fire while it was set. Because the host answered
`unavailable` for this session's event history (the runtime behind it was
unreachable), the very first page request failed — and from then on the
transcript would not page back at all, including through the ~25 timeline
entries the phone had already buffered and could have shown with no network.
Reading it as a user, the transcript simply stops: drag up, hit a wall, and
nothing above it ever loads.
Two changes, both scoping the failure to the thing that actually failed:
- Buffered entries bypass the error at both gates. They are already on the
device and cost nothing to reveal, so a dropped host page has no business
hiding them. This matters most in `workChatShouldContinueAutomaticOlderHistory`:
a transcript still shorter than the viewport cannot be scrolled at all, so
there is no gesture left that could re-arm anything.
- Scrolling back down past the re-arm distance retires the failure. A dropped
page is nearly always a transient host timeout, and keeping it until someone
finds the retry row means the next approach to the top silently does nothing.
The retry gesture is now the one the reader already makes, and it cannot spin:
a fresh attempt still costs a full round trip past the re-arm distance.
Verified on the simulator against the live 954-event "Close PR3 DAW ingestion
lane" chat with the host history page failing: scroll-back now walks from the
10:15 tail down to 08:25 messages, the failure row stays visible and tappable at
the top, and Latest returns to the bottom. Before the change the same chat
stopped dead ~16 minutes back and never moved again.
* ios(work chat): send the active-turn mode with the steer, not after it
Picking "Send during turn" or "Interrupt & send" used to stage the message
first and then promote it with a second chat.dispatchSteer round-trip. Every
active-turn send therefore flashed through the staged strip on its way out,
and the branch that resends as a steer after the host rejects a plain send
("turn already active") never made that second call at all — it captured
useSteer as false, so the promotion block was unreachable and the mode the
user chose was silently discarded.
The mode now rides chat.steer itself as dispatchMode, which the host has
accepted and validated since #791, and is resolved once before the send so
both the direct steer and the active-turn resend carry it. A host that honors
it dispatches in the same round-trip and answers queued:false, so nothing is
written to the queue and no optimistic staged entry is created.
manualSteerDispatchModes now carries the chat.dispatchSteer host gate itself
rather than having each call site remember to apply it, so the send path and
the staged strip's buttons read one list. A brain old enough to advertise
chat.dispatchSteer but too old to accept dispatchMode answers queued:true;
that case falls back to the legacy promotion instead of dropping the choice.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): open up the queued strip and remember how you send
The staged strip was built when every active-turn send passed through it, so
it defended itself with an accordion: a "1 staged" header you had to expand
before Send now / Interrupt / Edit / Cancel were reachable. Now that only an
explicit "Send after turn" produces a row, that ceremony guards nothing.
One queued message is a single compact card — waiting glyph, one truncated
line of the message, its disposition beneath it, and the four actions as
visible icon-only buttons whose touch areas stay 44pt tall. Only a pile-up
keeps a header, and it is now a slim "N queued" label rather than a control.
While a turn is running the clock glyph breathes and the line reads "sends
when turn ends"; on an idle session it sits still and reads "after turn". The
timestamp is gone — a queued message is always "a moment ago". The pulse goes
through ADEMotion.pulse, so Reduce Motion draws the glyph at full strength.
The active-turn send mode is also a working habit rather than a per-turn
decision, so it is remembered per chat in WorkActiveSendModeStore (App Group
defaults, the same bounded JSON map the composer drafts use). A turn starting
or ending no longer resets it; a provider change only snaps it back when the
new provider cannot honor what was remembered.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): cover atomic send modes and the queued-strip rule
Pins the wire contract (dispatchMode is absent for a staged steer, and is the
desktop's exact "inline"/"interrupt" spelling otherwise), the mode mapping
including the empty-list case that both a queue-only provider and an
un-upgraded host resolve to, the rule that only a "queued" delivery state
produces a strip entry, and the per-chat send-mode round-trip with its
blank-session-id guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): fold every finished turn down to one line
A chat you reopen used to be a wall: every tool card, plan and CI card from
every past turn rendered exactly as wide open as it did while it was running.
Now the turn in flight reads as it always has, and the moment it ends its cards
fold to a single 44pt row — glyph, short summary, right-aligned count chips,
chevron. History opens collapsed, because nothing is streaming.
Expansion moves out of the rows and into one WorkCardExpansionState held above
the list. Per-row @State was losing itself twice over: a LazyVStack recycle
silently shut whatever the reader had opened, and no row can collapse its
siblings when a turn ends. The state stores only the reader's *disagreement*
with a card's own default, which is what lets "keep this shut while it runs"
survive the next streaming delta and a manual expand outlive the sweep.
Also collapses two composer chips into one. The Subagents capsule opened the
very same sheet as Chat Info, and between them they carried enough text to
squeeze the PR chip's label into an ellipsis inside a row pinned to 34pt while
its capsules asked for 44. One Chat Info chip now counts the whole sheet, the
PR chip is icon plus CI glyph with its number and state moved into the
accessibility label, and the strip scrolls so a future chip can never truncate
its neighbours.
Long-press any collapsed CI, plan, tool-cluster, command or diff row to peek at
the full card in a context-menu preview, without moving the transcript.
Two defects found on the way: a titleless ade_card inherits the raw wire
variant as its title, so the collapsed row would have shown "pr_ci" to a
reader; and the diagnostics DisclosureGroup binding toggled on any write,
including SwiftUI re-sending the value it already had.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): cover the collapse rules and the collapsed one-liners
Pins the parts of auto-collapse that have no UI to lean on: which rows belong
to the turn in flight (everything after the last turn-end marker, and nothing
at all once the transcript ends on one), what a turn ending does to both kinds
of manual override, and that an expand and a collapse of the same id never hash
alike — the render signature is the only reason a toggled row redraws.
Then the text itself: "CI · PR #490" with 18✓ and 3✕, a zero count drawing no
chip, "Plan · Run the suite" with 4/7 counted the same way the expanded
checklist counts it, the spoken forms, and the refusal to ever print a raw
variant slug.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): give a finished turn's tool calls their row back
A Claude turn whose whole body was one Read and one approved shell command
rendered no trace of either: user bubble, "Thought", "Answered", the reply, and
a turn-end hairline. The work itself was gone.
Both calls fold into one normalized tool cluster, and the transcript was
throwing every cluster away before it ever reached the list. That rule was
written when a cluster had no compact form and N stacked tool cards ate the
phone viewport, so the only route back to the calls was an 8pt chevron on the
turn-end marker at 55% opacity. A finished cluster is now a single 44pt row in
the same one-liner grammar the changed-files panel uses right beside it — which
also means the transcript had been drawing one kind of cluster and swallowing
the other.
The filter moves out of the view into workPresentedTimelineEntries, so what
reaches the visible timeline is something a test can hold.
Also: WorkTurnActivitySheet still built the calls panel the old way, with the
member expansion that used to be the panel's own @State. It defaults to an empty
set and a no-op, so every call in the sheet drew permanently shut and tapping
one did nothing — in the one surface whose whole job is showing the turn's work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ios(work chat): bound streaming render work
* ios(work): refresh stable transcript overlap payloads
* ios(work chat): bound streaming preview work
* ios(work chat): preserve split markdown fences
* ios(work): harden streaming timeline correctness
* ios(work): finish streaming chat surface overhaul
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@arul28