Skip to content

Preserve Claude chat resume state and surface orphan lanes#175

Merged
arul28 merged 7 commits intomainfrom
work-chat-resume-polish
Apr 23, 2026
Merged

Preserve Claude chat resume state and surface orphan lanes#175
arul28 merged 7 commits intomainfrom
work-chat-resume-polish

Conversation

@arul28
Copy link
Copy Markdown
Owner

@arul28 arul28 commented Apr 22, 2026

Summary

  • Preserve Claude runtime resume metadata (sdkSessionId, lane directive, runtimeInvalidated) on non-terminal chat teardowns (idle_ttl, budget_eviction, pool_compaction, paused_run, project_close, shutdown) so the next turn re-attaches to the same V2 session instead of cold-starting.
  • Render orphan-lane sessions (laneId missing from current lanes list) as their own collapsible sticky groups in both desktop SessionListPane and iOS workSessionGroupsByLane, sorted by latest startedAt with name tiebreak and labeled with the session's preserved laneName.
  • Grow the iOS chat history window: request a 2 MB snapshot on chat_subscribe, merge truncated snapshots with existing events instead of replacing, retain up to 1,000 events per session, and fall back to messageId when assistant text is missing itemId so Claude- and Codex-produced turns stay aligned.
  • Update internal docs (chat lifecycle, terminals UI surfaces, iOS companion sync) to describe the new behavior.

Test plan

  • Desktop typecheck
  • ade-cli typecheck
  • web typecheck
  • Desktop lint
  • Desktop vitest (8 shards, 3,801 passed / 11 skipped)
  • ade-cli tests
  • Desktop, ade-cli, and web builds
  • node scripts/validate-docs.mjs
  • iOS xcodebuild build-for-testing on iPhone 17 Pro simulator; targeted tests green (orphan-lane grouping, chat merge, messageId fallback)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Chat event history retrieval for agent sessions with optional pagination.
    • Runtime-per-project control to enable/disable host discovery.
  • Improvements

    • Migration to a shared device identity file for consistent device IDs across projects.
    • More accurate Tailscale route detection and connectivity behavior.
    • Improved assistant message merging and chat event ordering.
    • Orphan/removed-lane sessions shown as individual groups.
    • iOS transport security updates for additional domains.

Greptile Summary

This PR preserves Claude V2 session resume metadata across non-terminal teardowns, surfaces orphaned-lane sessions as individual collapsible groups (desktop + iOS), grows the iOS chat history window to 2 MB with merge-on-truncation semantics, and centralizes device identity to a shared userData file. The implementation is well-tested with targeted vitest and XCTest coverage across all new code paths.

  • P1 – silent event loss in mergeEnvelopeStreams: the dedup key ${timestamp}#${event.type} is too coarse. Rapid streaming text events within the same millisecond are treated as duplicates and the later fragments are silently dropped, which would manifest as truncated assistant replies after a project switch triggers a disk/memory merge.

Confidence Score: 4/5

Safe to merge after addressing the mergeEnvelopeStreams dedup key, which can silently drop streaming text fragments during post-project-switch history hydration.

All other changes are well-structured and well-tested. The single P1 finding (timestamp+type dedup collision) is a correctness issue on a new code path — it does not break existing behaviour but can silently truncate chat history after an idle_ttl teardown and project switch, which is exactly the scenario this PR is meant to fix.

apps/desktop/src/main/services/chat/agentChatService.ts — specifically the mergeEnvelopeStreams dedup key around line 397

Important Files Changed

Filename Overview
apps/desktop/src/main/services/chat/agentChatService.ts Adds in-memory ring buffer, getChatEventHistory, and teardownRuntime resume-state preservation; dedup key in mergeEnvelopeStreams is too coarse for rapid streaming events
apps/desktop/src/main/services/chat/agentChatService.test.ts Comprehensive new tests for getChatEventHistory hydration, cleanup on delete, idle_ttl resume preservation, and terminal teardown clearing
apps/ios/ADE/Services/SyncService.swift Adds syncNormalizedRouteHost/syncIsTailscaleRoute helpers replacing inline IPv4-only detection, grows chat subscription to 2 MB, merge-vs-replace on truncated snapshots, and improved timestamp ordering
apps/desktop/src/main/services/sync/deviceRegistryService.ts Adds optional shared device-identity file path with safe legacy-to-global migration; existing iOS pairings preserved when shared file disagrees
apps/desktop/src/main/services/sync/syncHostService.ts Adds setDiscoveryEnabled / unpublishLanDiscovery and gates Tailnet/LAN discovery on per-project active state; cleanup on dispose is correct
apps/desktop/src/renderer/components/terminals/SessionListPane.tsx Adds missingLaneSessionGroups inside byLaneList block; NaN-safe latestStartedAt uses Number.isFinite filter; orphan groups correctly scoped to by-lane view
apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift Adds previousEnvelopeWasAssistantText flag to prevent nil-itemId text fragments from merging across intervening tool calls; flag reset on every non-assistantText branch
apps/desktop/src/renderer/components/chat/AgentChatPane.tsx Switches history loading to prefer getEventHistory IPC with graceful fallback to transcript tail; clears loadedHistoryRef on failure to allow retry on remount
apps/desktop/src/main/main.ts setActiveProject now gates per-project host discovery; localDeviceIdPath pointed to shared userData location
apps/ios/ADE/Info.plist Adds NSExceptionDomains for ade-sync and *.ts.net to allow plaintext WebSocket connections to Tailscale hosts; intentional for sync use case
apps/ios/ADE/Views/Work/WorkSessionGrouping.swift Replaces single Other orphan bucket with per-lane groups, sorted by latest startedAt then name; correctly uses two ISO8601DateFormatter instances for fractional-second variants
apps/ios/ADE/Views/Work/WorkEventMapping.swift Falls back from itemId to messageId to produce a stableItemId for text events; priority matches WorkTranscriptParser after previously-noted thread fix

Sequence Diagram

sequenceDiagram
    participant R as Renderer
    participant IPC
    participant ACS as AgentChatService
    participant FS as Filesystem

    Note over ACS: idle_ttl fires
    ACS->>ACS: teardownRuntime(idle_ttl)
    ACS->>ACS: preserveClaudeResumeState = true
    ACS->>FS: persistChatState(sdkSessionId, laneDirectiveKey)
    ACS->>ACS: runtimeInvalidated = false

    Note over R: User switches back to project/tab
    R->>IPC: agentChat.getEventHistory(sessionId, maxEvents)
    IPC->>ACS: getChatEventHistory(sessionId)
    ACS->>ACS: Validate sessionId via sessionService
    alt Not yet hydrated
        ACS->>FS: readTranscriptEnvelopesForSessionId
        ACS->>ACS: mergeEnvelopeStreams(disk, memory buffer)
        ACS->>ACS: Mark hydrated
    end
    ACS-->>IPC: events + truncated flag
    IPC-->>R: Render full history

    Note over R: User sends next message
    R->>IPC: agentChat.runSessionTurn
    IPC->>ACS: runSessionTurn
    ACS->>ACS: Read persisted sdkSessionId
    ACS->>ACS: unstable_v2_resumeSession(sdkSessionId)
    Note over ACS: Session re-attaches to same V2 context
Loading

Comments Outside Diff (3)

  1. apps/desktop/src/renderer/components/terminals/SessionListPane.tsx, line 210-211 (link)

    P2 Math.max(...sessions.map(...)) with invalid dates produces NaN

    latestStartedAt spreads the mapped timestamps into Math.max. If any session.startedAt is malformed (e.g., ""), new Date(...).getTime() returns NaN, and Math.max propagates NaN to the comparator. The sort callback then receives NaN - NaN = NaN, which causes unpredictable ordering in V8's Array.sort. The sessions.length > 0 guard prevents the zero-element case (-Infinity), but not the bad-date case. A small defensive fix:

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/renderer/components/terminals/SessionListPane.tsx
    Line: 210-211
    
    Comment:
    **`Math.max(...sessions.map(...))` with invalid dates produces `NaN`**
    
    `latestStartedAt` spreads the mapped timestamps into `Math.max`. If any `session.startedAt` is malformed (e.g., `""`), `new Date(...).getTime()` returns `NaN`, and `Math.max` propagates `NaN` to the comparator. The sort callback then receives `NaN - NaN = NaN`, which causes unpredictable ordering in V8's `Array.sort`. The `sessions.length > 0` guard prevents the zero-element case (`-Infinity`), but not the bad-date case. A small defensive fix:
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

  2. apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift, line 352-357 (link)

    P2 previousEnvelopeWasAssistantText flag invariant is implicit

    The logic is correct for current variants, but if a new WorkChatEvent case is added without resetting the flag, the merge guard could fire incorrectly. Consider adding a comment at the reset sites to make the invariant explicit for future contributors.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift
    Line: 352-357
    
    Comment:
    **`previousEnvelopeWasAssistantText` flag invariant is implicit**
    
    The logic is correct for current variants, but if a new `WorkChatEvent` case is added without resetting the flag, the merge guard could fire incorrectly. Consider adding a comment at the reset sites to make the invariant explicit for future contributors.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

    Fix in Claude Code

  3. apps/desktop/src/main/services/chat/agentChatService.ts, line 397-400 (link)

    P1 timestamp#type dedup key can silently drop streaming text fragments

    mergeEnvelopeStreams keys each envelope as ${entry.timestamp}#${entry.event.type}. Claude streaming turns routinely emit many text events in quick succession; if two fragments are timestamped within the same millisecond the second is permanently dropped from the merged history. This would silently produce a truncated assistant reply every time a user returns to a project after an idle_ttl teardown.

    A more collision-resistant key that stays cross-run stable would incorporate sequence as a secondary discriminator (sequence restarts per-run, but within a single run every event that lands in the in-memory buffer has a unique sequence; for the disk-only path, sequence is also monotone). Alternatively, keying on the first ~64 bytes of text content for text events would be sufficient to distinguish adjacent fragments.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/desktop/src/main/services/chat/agentChatService.ts
    Line: 397-400
    
    Comment:
    **`timestamp#type` dedup key can silently drop streaming text fragments**
    
    `mergeEnvelopeStreams` keys each envelope as `${entry.timestamp}#${entry.event.type}`. Claude streaming turns routinely emit many `text` events in quick succession; if two fragments are timestamped within the same millisecond the second is permanently dropped from the merged history. This would silently produce a truncated assistant reply every time a user returns to a project after an `idle_ttl` teardown.
    
    A more collision-resistant key that stays cross-run stable would incorporate `sequence` as a secondary discriminator (sequence restarts per-run, but within a single run every event that lands in the in-memory buffer has a unique sequence; for the disk-only path, sequence is also monotone). Alternatively, keying on the first ~64 bytes of text content for `text` events would be sufficient to distinguish adjacent fragments.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/services/chat/agentChatService.ts
Line: 397-400

Comment:
**`timestamp#type` dedup key can silently drop streaming text fragments**

`mergeEnvelopeStreams` keys each envelope as `${entry.timestamp}#${entry.event.type}`. Claude streaming turns routinely emit many `text` events in quick succession; if two fragments are timestamped within the same millisecond the second is permanently dropped from the merged history. This would silently produce a truncated assistant reply every time a user returns to a project after an `idle_ttl` teardown.

A more collision-resistant key that stays cross-run stable would incorporate `sequence` as a secondary discriminator (sequence restarts per-run, but within a single run every event that lands in the in-memory buffer has a unique sequence; for the disk-only path, sequence is also monotone). Alternatively, keying on the first ~64 bytes of text content for `text` events would be sufficient to distinguish adjacent fragments.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (5): Last reviewed commit: "Address second round of review feedback" | Re-trigger Greptile

Keep Claude runtime resume metadata (sdkSessionId, lane directive,
runtimeInvalidated) intact when a managed chat is torn down for a
non-terminal reason (idle_ttl, budget_eviction, pool_compaction,
paused_run, project_close, shutdown). Only terminal reasons
(handle_close, ended_session, model_switch) fully invalidate the
runtime now, so subsequent turns can re-attach to the same V2 session
instead of starting cold.

Render orphan-lane sessions (whose laneId is missing from the current
lanes list) as their own collapsible sticky groups in the Work session
list on desktop and iOS. Sort by latest startedAt with name tiebreak
and label with the session's preserved laneName.

Grow the iOS chat history window: request a 2 MB snapshot, merge
truncated snapshots with existing events instead of replacing, retain
up to 1,000 events per session, and fall back to messageId when
assistant text is missing itemId so Claude- and Codex-produced turns
stay aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 22, 2026

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

Project Deployment Actions Updated (UTC)
ade Ready Ready Preview, Comment Apr 23, 2026 1:11am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 22, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1092841a-e8fc-4e83-852b-f83ee30734df

📥 Commits

Reviewing files that changed from the base of the PR and between 4c208e2 and ca6a302.

📒 Files selected for processing (11)
  • apps/desktop/src/main/services/chat/agentChatService.test.ts
  • apps/desktop/src/main/services/chat/agentChatService.ts
  • apps/desktop/src/main/services/ipc/registerIpc.ts
  • apps/desktop/src/main/services/sync/deviceRegistryService.ts
  • apps/desktop/src/main/services/sync/syncService.test.ts
  • apps/desktop/src/renderer/browserMock.ts
  • apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
  • apps/desktop/src/renderer/components/terminals/SessionListPane.tsx
  • apps/ios/ADE/Services/SyncService.swift
  • apps/ios/ADE/Views/Work/WorkSessionGrouping.swift
  • apps/ios/ADETests/ADETests.swift

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


📝 Walkthrough

Walkthrough

Adds agent chat event-history APIs and IPC, per-session in-memory history with lazy hydration, Claude resume-state persistence on selective teardowns, runtime-controllable sync host discovery and configurable/shared local device ID handling, UI changes for snapshot-first chat history hydration and orphan-lane session grouping, plus related iOS sync/chat and tests.

Changes

Cohort / File(s) Summary
Desktop Agent Chat Service
apps/desktop/src/main/services/chat/agentChatService.ts, apps/desktop/src/main/services/chat/agentChatService.test.ts
Adds per-session in-memory ring buffer, lazy hydration from on-disk transcripts, public getChatEventHistory() API, and teardown changes to persist Claude resume metadata; expands tests for history, resume, and deletion behavior.
Desktop IPC / Preload / Types / Mock
apps/desktop/src/main/services/ipc/registerIpc.ts, apps/desktop/src/preload/global.d.ts, apps/desktop/src/preload/preload.ts, apps/desktop/src/shared/ipc.ts, apps/desktop/src/renderer/browserMock.ts
Introduces agentChatGetEventHistory IPC channel, exposes ade.agentChat.getEventHistory in preload/types, and adds renderer mock implementation.
Desktop Sync: Host Discovery & Service
apps/desktop/src/main/services/sync/syncHostService.ts, apps/desktop/src/main/services/sync/syncService.ts, apps/desktop/src/main/services/sync/syncService.test.ts, apps/desktop/src/main/main.ts
Adds runtime discoveryEnabled flag surfaced as setHostDiscoveryEnabled, gates LAN/tailnet publish/unpublish behavior, initializes discovery state per active project, and forwards localDeviceIdPath into sync service construction.
Desktop Sync: Device Identity
apps/desktop/src/main/services/sync/deviceRegistryService.ts, apps/desktop/src/main/services/sync/deviceRegistryService.test.ts
Adds optional localDeviceIdPath arg for shared device-id file, preserves legacy per-project sync-device-id when present (migration rules), and tests shared identity and legacy-migration cases.
Desktop Chat Renderer
apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Changes history loading to prefer main-process snapshot via getEventHistory (snapshot-first), merges live events after snapshot, clears loaded-state on errors, and forces reload on mount in locked-single-session mode.
Desktop Terminals UI
apps/desktop/src/renderer/components/terminals/SessionListPane.tsx, apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx
Adds orphan-lane group rendering when sessions reference unknown lanes, orders orphan groups by latest startedAt, and adds a test validating lane grouping and session rendering.
iOS Sync & Chat Core
apps/ios/ADE/Services/SyncService.swift, apps/ios/ADE/Info.plist
Introduces route-aware Tailscale detection (syncIsTailscaleRoute), updates host-selection and tailnet logic, adjusts discovery candidates, increases chat snapshot handling and history cap/merge/dedup/sort behavior, and adds ATS exceptions for ade-sync/ts.net in Info.plist.
iOS Chat Mapping, Merging & Grouping
apps/ios/ADE/Views/Work/WorkEventMapping.swift, apps/ios/ADE/Views/Work/WorkTranscriptParser.swift, apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift, apps/ios/ADE/Views/Work/WorkSessionGrouping.swift
Derives stable itemId from trimmed messageId/itemId, tightens assistant-text merging to require prior-assistant context, prevents cross-event merging, and replaces single “Other” orphan group with per-lane orphan groups ordered by latest session time.
iOS Tests
apps/ios/ADETests/ADETests.swift
Adds tests for Tailscale-route recognition, chat subscription maxBytes persistence, snapshot merge/replace semantics and ordering, transcript segmentation, and orphan-lane grouping; updates test helper signature.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

desktop, ios, docs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'Preserve Claude chat resume state and surface orphan lanes' accurately and concisely summarizes the two main changes: preserving Claude session resume metadata and displaying orphan lane sessions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch work-chat-resume-polish

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.

Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

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

Added 2 comments

Comment thread apps/desktop/src/main/services/chat/agentChatService.ts Outdated
Comment thread apps/ios/ADE/Views/Work/WorkTranscriptParser.swift Outdated
Comment thread apps/ios/ADE/Views/Work/WorkEventMapping.swift Outdated
- Fix shutdown resume-state persistence: move `managed.deleted = true` after
  `teardownRuntime("shutdown")` in `forceDisposeAll` so its `persistChatState`
  call actually writes the Claude sdkSessionId/lane directive before the
  tombstone gate bails (capy-ai).
- Flip `WorkEventMapping.stableItemId` priority so `itemId` wins over
  `messageId`, matching `WorkTranscriptParser` — same Claude turn now groups
  identically live and on replay (Greptile P1).
- Normalize `itemId` via `optionalString` in `WorkTranscriptParser` text
  branch so empty `itemId` falls through to `messageId` (capy-ai).
- Guard `latestStartedAt` in `SessionListPane` against `NaN` from malformed
  `startedAt` values to keep orphan-lane sort deterministic (Greptile P2).
- Document `previousEnvelopeWasAssistantText` reset invariant at the flag's
  declaration (Greptile P2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

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

Added 2 comments

Comment thread apps/desktop/src/main/services/chat/agentChatService.ts Outdated
Comment thread apps/ios/ADE/Views/Work/WorkSessionGrouping.swift
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 22, 2026

@codex review

Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

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

Added 2 comments

Comment thread apps/desktop/src/main/services/sync/deviceRegistryService.ts
Comment thread apps/ios/ADE/Services/SyncService.swift
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4048884885

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/ios/ADE/Services/SyncService.swift Outdated
Comment thread apps/ios/ADE/Services/SyncService.swift
arul28 and others added 2 commits April 22, 2026 19:47
…sync ordering

Chat history and freeze recovery on remount
- Add in-memory per-session chat event ring buffer in agentChatService
  alongside the on-disk transcript, wired into every commitChatEvent path.
- New getChatEventHistory() merges the buffer with the on-disk transcript on
  first read, so a renderer that missed live events (project switch, tab
  switch, transient IPC drop) still recovers the full timeline.
- Renderer AgentChatPane prefers the new snapshot API and clears its
  loaded-history gate in the catch path so a transient read failure no
  longer leaves the pane frozen with no retry. Single-session (Work tile)
  now force-reloads on mount, matching the other chat surfaces.
- Clean up the buffer on deleteSession so a re-created session id doesn't
  inherit stale events.

teardownRuntime no longer clobbers preserved Claude resume metadata
- If teardownRuntime runs a second time on a session whose runtime was
  already torn down (e.g., idle_ttl eviction followed by app shutdown),
  bail early rather than falling through to the invalidating path that
  would reset runtimeInvalidated and clearLaneDirectiveKey.
- Addresses PR #175 review comment on agentChatService.ts:5526.
- Regression test: Claude session survives idle_ttl → shutdown with
  sdkSessionId and lastLaneDirectiveKey intact.

iOS chat event ordering across truncated/merged snapshots
- deduplicatedChatEventHistory now parses ISO-8601 timestamps with a
  fractional-seconds formatter + no-fraction fallback before comparing,
  so mixed-precision snapshots from the desktop sort chronologically
  instead of by lexical byte order. Addresses PR #175 review on
  SyncService.swift:4533.
- recordChatEventEnvelope falls back to the full dedup/sort path when a
  live envelope predates the last-recorded envelope — keeps the common
  append-in-order fast path but heals out-of-order arrivals after a
  merge. Addresses PR #175 review on SyncService.swift:4504.
- Regression tests for mixed fractional variants and delayed
  out-of-order live inserts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

Pushed commit 4c208e2d addressing the outstanding review feedback, plus a separate chat-freeze bug we spotted in parallel:

Review comments:

  • agentChatService.ts:5526 (capy-ai, forceDisposeAll re-clears preserved resume state): Fixed. teardownRuntime now early-returns when managed.runtime is already null, so a shutdown teardown after a prior idle_ttl teardown no longer clobbers the preserved sdkSessionId/lastLaneDirectiveKey. Regression test added (preserves Claude resume metadata across idle_ttl followed by shutdown).
  • agentChatService.ts:5533 (capy-ai, shutdown cannot persist): Was already addressed in c50200dforceDisposeAll deliberately calls teardownRuntime(managed, "shutdown") before setting managed.deleted = true, so persistChatState writes while deleted is still false. See the comment at line 13052 documenting the ordering.
  • SyncService.swift:4533 (capy-ai, lexicographic ISO-8601 compare): Fixed. deduplicatedChatEventHistory now parses both timestamps with a fractional-seconds formatter + no-fraction fallback before comparing, so mixed-precision snapshots sort chronologically. Regression test added (testChatEventHistoryOrdersByParsedTimestampAcrossMixedFractionalVariants).
  • SyncService.swift:4504 (codex, recordChatEventEnvelope unsorted after merge): Fixed. Live envelopes now fall through to the dedup/sort path when they predate the last-recorded envelope, healing out-of-order arrivals after a snapshot merge. Common append-in-order case stays O(1). Regression test added (testRecordChatEventEnvelopeSortsWhenLiveEventArrivesOutOfOrder).
  • WorkSessionGrouping.swift:146 (capy-ai, iOS fetch drops truly-missing lanes): Accepted as design trade-off for this PR. The orphan path correctly surfaces archived-lane sessions (which is the primary use case — orderedLanes comes from fetchLanes(includeArchived: false) while fetchSessions is archive-agnostic). True FK-orphans are filtered at the DB layer by the left join … where l.project_id = ? query; surfacing those would require a schema/query change that's out of scope for this PR.
  • deviceRegistryService.ts:149 (capy-ai, first-project-wins device ID): Acknowledged trade-off. The shared device ID is intentionally per-device, not per-project; the first opened project's legacy ID becomes the canonical shared ID. Users whose only paired project was not the first-opened one will need to re-pair — accepted as a one-time migration cost.
  • SyncService.swift:195 (codex, preserve legacy tailnet candidates): Intentional cleanup. The desktop app only ever advertised on svc:ade-sync on port 8787 (syncHostService.ts constants, verified via git grep against production history); ade-desktop:8788 was an unused iOS-only candidate. No production host was ever reachable on that route.

Bonus: chat-freeze bug. While verifying the review fixes, we discovered the desktop renderer had no snapshot-on-resubscribe path for chat events — switching projects or tabbing away could drop IPC events, and on return the pane saw only the transcript tail, missing in-flight events and (if readTranscriptTail errored) leaving the pane permanently frozen with no retry. Same commit adds a per-session in-memory ring buffer + getChatEventHistory API that merges buffer + disk transcript, and AgentChatPane clears its load-gate in the catch path so transient failures recover on remount. iOS had already solved the equivalent with mergeChatEventHistory / deduplicatedChatEventHistory; this brings the desktop to parity.

Comment thread apps/ios/ADE/Services/SyncService.swift
Comment thread apps/desktop/src/main/services/sync/deviceRegistryService.ts Outdated
Comment thread apps/desktop/src/main/services/chat/agentChatService.ts Outdated
Comment thread apps/desktop/src/main/services/chat/agentChatService.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/desktop/src/renderer/components/chat/AgentChatPane.tsx (1)

1472-1491: ⚠️ Potential issue | 🟠 Major

Sort and de-dupe the realtime tail before appending.

The merge keeps existing tail order as-is. If live IPC events arrive out of order or duplicate, the UI can render turns in the wrong order and deriveRuntimeState() can compute state from a non-canonical event sequence.

Proposed fix
         const tail = existing.filter((entry) => {
           if (lastParsedSequence != null && typeof entry.sequence === "number") {
             return entry.sequence > lastParsedSequence;
           }
           return entry.timestamp > lastParsedTs;
         });
-        merged = tail.length ? [...parsed, ...tail] : parsed;
+        const seenTail = new Set<string>();
+        const orderedTail = [...tail]
+          .sort((left, right) => {
+            if (typeof left.sequence === "number" && typeof right.sequence === "number" && left.sequence !== right.sequence) {
+              return left.sequence - right.sequence;
+            }
+            return left.timestamp.localeCompare(right.timestamp);
+          })
+          .filter((entry) => {
+            const key = typeof entry.sequence === "number"
+              ? `seq:${entry.sequence}`
+              : `${entry.timestamp}:${entry.event.type}`;
+            if (seenTail.has(key)) return false;
+            seenTail.add(key);
+            return true;
+          });
+        merged = orderedTail.length ? [...parsed, ...orderedTail] : parsed;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` around lines
1472 - 1491, The merge logic in AgentChatPane that builds merged from parsed and
the real-time tail (using eventsBySessionRef.current, existing, parsed, tail,
merged) must sort and deduplicate the tail before appending so out-of-order or
duplicate IPC events don't corrupt the event sequence used by
deriveRuntimeState(); update the tail construction to (1) sort by monotonic
sequence when present (falling back to timestamp) and (2) dedupe events (using a
stable unique key such as sequence if available, otherwise timestamp+sender/id)
so merged becomes [...parsed, ...sortedDedupedTail] (or parsed if tail empty).
Ensure you still prefer sequence ordering when available and keep
sessionId/AgentChatEventEnvelope semantics intact.
apps/desktop/src/main/services/sync/syncHostService.ts (1)

914-930: ⚠️ Potential issue | 🟠 Major

Serialize tailnet unpublish before later publish attempts.

The new runtime toggle can leave tailnet discovery advertised after it is disabled. If a tailscale serve ... target publish is already in flight, setDiscoveryEnabled(false) starts serve off, but the earlier publish process can complete after that and re-enable the service externally while its stale promise is ignored. A quick disable→enable has the same ordering hazard.

Consider tracking the unpublish promise and only republishing after it settles, or forcing a second serve off when a stale publish completes while discoveryEnabled === false.

Also applies to: 1025-1047, 2056-2078

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/sync/syncHostService.ts` around lines 914 -
930, publishTailnetDiscovery can race with in-flight unpublish calls causing
stale publishes to re-enable discovery; fix by serializing unpublish/publish:
introduce and track a shared Promise (e.g., activeUnpublishPromise) that
unpublishTailnetDiscovery assigns to, have publishTailnetDiscovery await that
promise (or check its settled state) before issuing a new publish, and when a
publish completes check discoveryEnabled and, if false, immediately trigger or
await a fresh unpublishTailnetDiscovery to force-off any stale external state;
apply the same pattern to the other publish/unpublish sites referenced (lines
~1025-1047 and ~2056-2078) to ensure all serve off/publish operations are
serialized.
apps/ios/ADE/Services/SyncService.swift (1)

4509-4562: ⚠️ Potential issue | 🟠 Major

Respect sequence when timestamps represent the same instant.

The fast path appends equal-date events even when the incoming sequence is lower than the last event, and the full sort falls back to lexicographic timestamp when parsed dates are equal but string formats differ. That can misorder same-timestamp chat fragments.

Proposed fix
     let canAppendInOrder: Bool = {
       guard let last = events.last else { return true }
       let lastDate = Self.parseIso8601(last.timestamp)
       let envelopeDate = Self.parseIso8601(envelope.timestamp)
-      if let lhs = envelopeDate, let rhs = lastDate { return lhs >= rhs }
-      return envelope.timestamp >= last.timestamp
+      if let lhs = envelopeDate, let rhs = lastDate {
+        if lhs != rhs { return lhs > rhs }
+        return (envelope.sequence ?? 0) >= (last.sequence ?? 0)
+      }
+      if envelope.timestamp != last.timestamp {
+        return envelope.timestamp > last.timestamp
+      }
+      return (envelope.sequence ?? 0) >= (last.sequence ?? 0)
     }()
       let lhsDate = Self.parseIso8601(lhs.timestamp)
       let rhsDate = Self.parseIso8601(rhs.timestamp)
       if lhsDate == rhsDate {
-        if lhs.timestamp == rhs.timestamp {
-          return (lhs.sequence ?? 0) < (rhs.sequence ?? 0)
-        }
-        return lhs.timestamp < rhs.timestamp
+        let lhsSequence = lhs.sequence ?? 0
+        let rhsSequence = rhs.sequence ?? 0
+        if lhsSequence != rhsSequence { return lhsSequence < rhsSequence }
+        return lhs.timestamp < rhs.timestamp
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ios/ADE/Services/SyncService.swift` around lines 4509 - 4562, The
append-fast-path and the full dedup/sort path both fail to respect the
envelope.sequence for events that represent the same instant; update the
canAppendInOrder check and the comparator inside deduplicatedChatEventHistory to
treat equal parsed timestamps as ordered by (sequence ?? 0) before falling back
to string timestamps. Concretely: in the canAppendInOrder closure (the code
comparing last.timestamp and envelope.timestamp using Self.parseIso8601) when
envelopeDate == lastDate use (envelope.sequence ?? 0) >= (last.sequence ?? 0) to
decide appendability; and inside deduplicatedChatEventHistory's sort comparator
(where lhsDate == rhsDate) first compare (lhs.sequence ?? 0) vs (rhs.sequence ??
0) and only if equal fall back to comparing timestamp strings.
apps/ios/ADETests/ADETests.swift (1)

337-344: ⚠️ Potential issue | 🟡 Minor

Assert maxBytes after disconnect too.

Line 339 only proves the initial subscription payload includes maxBytes; a disconnect path could still drop it and this test would pass.

🧪 Proposed test tightening
     service.disconnect(clearCredentials: false)
 
     XCTAssertEqual(service.subscribedChatSessionIds, Set(["session-1", "session-2"]))
-    XCTAssertEqual(service.chatSubscriptionPayloads().compactMap { $0["sessionId"] as? String }.sorted(), ["session-1", "session-2"])
+    let payloadsAfterDisconnect = service.chatSubscriptionPayloads()
+    XCTAssertEqual(payloadsAfterDisconnect.compactMap { $0["sessionId"] as? String }.sorted(), ["session-1", "session-2"])
+    XCTAssertEqual(payloadsAfterDisconnect.compactMap { $0["maxBytes"] as? Int }, [2_000_000, 2_000_000])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/ios/ADETests/ADETests.swift` around lines 337 - 344, The test currently
asserts maxBytes only before disconnect; ensure the disconnect path preserves
maxBytes by adding assertions after calling service.disconnect(clearCredentials:
false) that validate service.chatSubscriptionPayloads().compactMap {
$0["maxBytes"] as? Int } still equals [2_000_000, 2_000_000] (and keep the
existing checks on subscribedChatSessionIds and sessionId payloads) so the
subscription payloads retain their maxBytes through the disconnect flow in
ADETests.swift.
♻️ Duplicate comments (1)
apps/desktop/src/main/services/sync/deviceRegistryService.ts (1)

146-158: ⚠️ Potential issue | 🟠 Major

Make shared device ID creation and migration conflict-safe.

Two project registries initialized concurrently with the same missing localDeviceIdPath can both generate different UUIDs and race through writeTextAtomic; the last write wins on disk while the losing registry keeps a stale in-memory localDeviceId. This also still leaves the existing-global-versus-legacy-local-brain upgrade case unreconciled: an old project whose cluster points at its legacy device ID can become a viewer when some other project created the shared file first.

Use create-if-absent semantics for the shared file, then re-read on EEXIST, and reconcile project DB rows when a legacy local-brain ID differs from the selected shared ID.

Sketch of the safer file-create pattern
-    const created = randomUUID();
-    writeTextAtomic(deviceIdPath, `${created}\n`);
-    return created;
+    const created = randomUUID();
+    try {
+      fs.writeFileSync(deviceIdPath, `${created}\n`, { encoding: "utf8", flag: "wx" });
+      return created;
+    } catch (error) {
+      if ((error as NodeJS.ErrnoException).code === "EEXIST") {
+        const racedExisting = fs.readFileSync(deviceIdPath, "utf8").trim();
+        if (racedExisting.length > 0) return racedExisting;
+      }
+      throw error;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/services/sync/deviceRegistryService.ts` around lines
146 - 158, readOrCreateLocalDeviceId currently races when multiple registries
create deviceIdPath: change it to use create-if-absent semantics (attempt atomic
create/open with exclusive flag for deviceIdPath), on EEXIST re-read the file to
get the winner value instead of returning the locally generated UUID, and if a
legacyProjectDeviceIdPath exists reconcile project DB rows when legacy !==
selected shared ID (update any rows pointing at the legacy ID to the chosen
shared ID) so in-memory localDeviceId and disk are consistent; reference
readOrCreateLocalDeviceId, deviceIdPath, legacyProjectDeviceIdPath, randomUUID,
writeTextAtomic and ensure error handling specifically handles EEXIST by
re-reading and returning the actual disk value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/services/chat/agentChatService.test.ts`:
- Around line 4540-4560: The test deletes a session that has no history, so it
won't catch failures to clear hydrated cache; before calling
service.deleteSession({ sessionId: session.id }) seed the session history (e.g.
call service.sendMessage or otherwise append an AgentChatEventEnvelope to the
session's transcript) so the session actually has events, then delete and assert
that service.getChatEventHistory(session.id).events is empty and truncated is
false; update the test around createSession, sendMessage (or direct transcript
seeding) and deleteSession accordingly to ensure deletion clears both in-memory
and hydrated-from-disk state.

In `@apps/desktop/src/main/services/chat/agentChatService.ts`:
- Around line 3692-3713: The readTranscriptEnvelopesForSessionId function uses
an unvalidated sessionId to build file paths
(transcriptsDir/${sessionId}.chat.jsonl and
chatTranscriptsDir/${sessionId}.jsonl) before confirming the id belongs to an
agent chat session; update the start of readTranscriptEnvelopesForSessionId to
trim and validate the sessionId by calling sessionService.get(trimmedId) and
checking isChatToolType(session?.toolType) (or return [] if invalid), then
proceed to use managedSessions, transcriptsDir and chatTranscriptsDir only after
this validation to prevent arbitrary path access.
- Around line 5662-5678: The early return when managed.runtime is falsy prevents
terminal invalidation from running and lets preserved Claude resume metadata
survive (e.g., idle_ttl then ended_session); change the logic so terminal
invalidation always runs even if managed.runtime is null: compute
preserveClaudeResumeState only when managed.runtime exists (use
managed.runtime?.kind or guard the kind check), but move or duplicate the call
that invalidates terminal reasons (the cleanup that handles
openCodeReason/ended_session) to execute before the early return (or remove the
early return and gate only the preserveClaudeResumeState-dependent reset).
Ensure references in the patch include managed.runtime,
preserveClaudeResumeState, and openCodeReason so reviewers can find the updated
flow.

In `@apps/desktop/src/main/services/ipc/registerIpc.ts`:
- Around line 4412-4421: The IPC handler for IPC.agentChatGetEventHistory
forwards NaN because it only checks typeof maxEvents; update the handler
(ipcMain.handle callback that calls getCtx() and
ctx.agentChatService.getChatEventHistory) to guard maxEvents with
Number.isFinite and convert to an integer (e.g., Math.floor) or pass undefined
when invalid—i.e., replace the typeof arg?.maxEvents === "number" check with a
finite-number check and only pass a sanitized integer to getChatEventHistory so
NaN/Infinity are not forwarded.

In `@apps/desktop/src/main/services/sync/syncService.test.ts`:
- Line 40: The per-test host mock override used in the retry-test block is
missing the new setDiscoveryEnabled method, causing
hostService?.setDiscoveryEnabled(...) to throw; update the override in
syncService.test.ts (the mock returned in the retry-test override around the
507-546 block) to include setDiscoveryEnabled: vi.fn() (or the appropriate
jest/vi mock function) so it matches the default mock shape and prevents runtime
errors when tests toggle discovery.

In `@apps/desktop/src/renderer/browserMock.ts`:
- Line 2385: The mock implementation for getEventHistory always returns
sessionId: "mock", which breaks session matching; update the getEventHistory
entry to echo the requested sessionId by using the same resolvedArg helper but
returning the sessionId from the incoming args (i.e., read args.sessionId or the
appropriate parameter passed to resolvedArg) instead of the hardcoded "mock" so
the browserMock mirrors the real API behavior.

In `@apps/desktop/src/renderer/components/chat/AgentChatPane.tsx`:
- Around line 1461-1463: Inside the if (!usedSnapshotPath) block in
AgentChatPane (where you call window.ade.sessions.get(sessionId)), clear the
loaded guard before any early return: when (!summary ||
!isChatToolType(summary.toolType)) set loadedHistoryRef.current = undefined (or
null) immediately prior to returning so a transient sessions.get() miss doesn't
permanently block retries; update the same block that references
usedSnapshotPath and loadedHistoryRef to ensure the guard is reset on this
fallback path.

In `@apps/desktop/src/renderer/components/terminals/SessionListPane.tsx`:
- Around line 268-280: The label computation for missing lane groups uses
list[0]?.laneName without trimming, so blank or whitespace-only lane names
render as empty headers; update the logic where missingLaneSessionGroups is
mapped (the label variable used for the StickyGroupHeader) to trim
list[0]?.laneName and if the trimmed string is empty fall back to laneId,
ensuring StickyGroupHeader receives a non-blank label; keep the rest of the
props (sectionId, count, collapsed, onToggleCollapsed) unchanged.

In `@apps/ios/ADE/Services/SyncService.swift`:
- Around line 225-229: The host normalization currently strips everything after
the last ':' which mangles unbracketed IPv6 literals like "::1"; update the
logic in the host normalization block (the code that uses bracketEnd and the
subsequent colon check) to only treat the trailing ":port" form when there is
exactly one colon in the host string — e.g., count occurrences of ":" (or
compare firstIndex(of: ":") to lastIndex(of: ":")) and only run the host =
String(host[..<colon]) branch when there is a single colon; leave hosts with
multiple colons (unbracketed IPv6) untouched so the IPv6 allowlist can still
match.

In `@apps/ios/ADE/Views/Work/WorkSessionGrouping.swift`:
- Around line 152-158: The orphan lane label logic should trim whitespace and
fall back to the laneId when laneName is present but empty; update the
comparator that builds leftName/rightName (used in the sort) to trim characters
from left.value.first?.laneName and right.value.first?.laneName before using
them, and change the orphanEntries loop that computes label for WorkSessionGroup
(currently using list.first?.laneName ?? laneId) to use a trimmed value and fall
back to laneId when the trimmed string is empty (you can add a small helper like
orphanLaneLabel(sessions:fallback:) or inline trim + isEmpty check).

In `@apps/ios/ADETests/ADETests.swift`:
- Around line 3384-3442: The test
testWorkSessionGroupsByLaneSurfacesOrphanLanesPerLaneId currently gives every
session the same startedAt via makeTerminalSessionSummary, so it doesn't
exercise orphan-lane ordering by latest startedAt; update the three orphan calls
(orphanOldSession, orphanNewSession, orphanNewSessionSibling) to pass distinct
startedAt values (e.g., orphanNew* with a later timestamp than orphanOld) when
calling makeTerminalSessionSummary so workSessionGroupsByLane's orphan sorting
by latest startedAt is actually tested while keeping the rest of the assertions
(ids, labels, and last?.sessions.count) unchanged.

---

Outside diff comments:
In `@apps/desktop/src/main/services/sync/syncHostService.ts`:
- Around line 914-930: publishTailnetDiscovery can race with in-flight unpublish
calls causing stale publishes to re-enable discovery; fix by serializing
unpublish/publish: introduce and track a shared Promise (e.g.,
activeUnpublishPromise) that unpublishTailnetDiscovery assigns to, have
publishTailnetDiscovery await that promise (or check its settled state) before
issuing a new publish, and when a publish completes check discoveryEnabled and,
if false, immediately trigger or await a fresh unpublishTailnetDiscovery to
force-off any stale external state; apply the same pattern to the other
publish/unpublish sites referenced (lines ~1025-1047 and ~2056-2078) to ensure
all serve off/publish operations are serialized.

In `@apps/desktop/src/renderer/components/chat/AgentChatPane.tsx`:
- Around line 1472-1491: The merge logic in AgentChatPane that builds merged
from parsed and the real-time tail (using eventsBySessionRef.current, existing,
parsed, tail, merged) must sort and deduplicate the tail before appending so
out-of-order or duplicate IPC events don't corrupt the event sequence used by
deriveRuntimeState(); update the tail construction to (1) sort by monotonic
sequence when present (falling back to timestamp) and (2) dedupe events (using a
stable unique key such as sequence if available, otherwise timestamp+sender/id)
so merged becomes [...parsed, ...sortedDedupedTail] (or parsed if tail empty).
Ensure you still prefer sequence ordering when available and keep
sessionId/AgentChatEventEnvelope semantics intact.

In `@apps/ios/ADE/Services/SyncService.swift`:
- Around line 4509-4562: The append-fast-path and the full dedup/sort path both
fail to respect the envelope.sequence for events that represent the same
instant; update the canAppendInOrder check and the comparator inside
deduplicatedChatEventHistory to treat equal parsed timestamps as ordered by
(sequence ?? 0) before falling back to string timestamps. Concretely: in the
canAppendInOrder closure (the code comparing last.timestamp and
envelope.timestamp using Self.parseIso8601) when envelopeDate == lastDate use
(envelope.sequence ?? 0) >= (last.sequence ?? 0) to decide appendability; and
inside deduplicatedChatEventHistory's sort comparator (where lhsDate == rhsDate)
first compare (lhs.sequence ?? 0) vs (rhs.sequence ?? 0) and only if equal fall
back to comparing timestamp strings.

In `@apps/ios/ADETests/ADETests.swift`:
- Around line 337-344: The test currently asserts maxBytes only before
disconnect; ensure the disconnect path preserves maxBytes by adding assertions
after calling service.disconnect(clearCredentials: false) that validate
service.chatSubscriptionPayloads().compactMap { $0["maxBytes"] as? Int } still
equals [2_000_000, 2_000_000] (and keep the existing checks on
subscribedChatSessionIds and sessionId payloads) so the subscription payloads
retain their maxBytes through the disconnect flow in ADETests.swift.

---

Duplicate comments:
In `@apps/desktop/src/main/services/sync/deviceRegistryService.ts`:
- Around line 146-158: readOrCreateLocalDeviceId currently races when multiple
registries create deviceIdPath: change it to use create-if-absent semantics
(attempt atomic create/open with exclusive flag for deviceIdPath), on EEXIST
re-read the file to get the winner value instead of returning the locally
generated UUID, and if a legacyProjectDeviceIdPath exists reconcile project DB
rows when legacy !== selected shared ID (update any rows pointing at the legacy
ID to the chosen shared ID) so in-memory localDeviceId and disk are consistent;
reference readOrCreateLocalDeviceId, deviceIdPath, legacyProjectDeviceIdPath,
randomUUID, writeTextAtomic and ensure error handling specifically handles
EEXIST by re-reading and returning the actual disk value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1e458e0a-ec94-4dbb-8f88-2523a3653ef2

📥 Commits

Reviewing files that changed from the base of the PR and between 0112f51 and 4c208e2.

⛔ Files ignored due to path filters (3)
  • docs/features/chat/README.md is excluded by !docs/**
  • docs/features/sync-and-multi-device/ios-companion.md is excluded by !docs/**
  • docs/features/terminals-and-sessions/ui-surfaces.md is excluded by !docs/**
📒 Files selected for processing (24)
  • apps/desktop/src/main/main.ts
  • apps/desktop/src/main/services/chat/agentChatService.test.ts
  • apps/desktop/src/main/services/chat/agentChatService.ts
  • apps/desktop/src/main/services/ipc/registerIpc.ts
  • apps/desktop/src/main/services/sync/deviceRegistryService.test.ts
  • apps/desktop/src/main/services/sync/deviceRegistryService.ts
  • apps/desktop/src/main/services/sync/syncHostService.ts
  • apps/desktop/src/main/services/sync/syncService.test.ts
  • apps/desktop/src/main/services/sync/syncService.ts
  • apps/desktop/src/preload/global.d.ts
  • apps/desktop/src/preload/preload.ts
  • apps/desktop/src/renderer/browserMock.ts
  • apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
  • apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx
  • apps/desktop/src/renderer/components/terminals/SessionListPane.tsx
  • apps/desktop/src/shared/ipc.ts
  • apps/ios/ADE/Info.plist
  • apps/ios/ADE/Services/SyncService.swift
  • apps/ios/ADE/Views/Settings/SettingsPairingSection.swift
  • apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift
  • apps/ios/ADE/Views/Work/WorkEventMapping.swift
  • apps/ios/ADE/Views/Work/WorkSessionGrouping.swift
  • apps/ios/ADE/Views/Work/WorkTranscriptParser.swift
  • apps/ios/ADETests/ADETests.swift

Comment thread apps/desktop/src/main/services/chat/agentChatService.test.ts
Comment thread apps/desktop/src/main/services/chat/agentChatService.ts
Comment thread apps/desktop/src/main/services/chat/agentChatService.ts Outdated
Comment thread apps/desktop/src/main/services/ipc/registerIpc.ts
Comment thread apps/desktop/src/main/services/sync/syncService.test.ts
Comment thread apps/desktop/src/renderer/components/chat/AgentChatPane.tsx Outdated
Comment thread apps/desktop/src/renderer/components/terminals/SessionListPane.tsx
Comment thread apps/ios/ADE/Services/SyncService.swift
Comment thread apps/ios/ADE/Views/Work/WorkSessionGrouping.swift Outdated
Comment thread apps/ios/ADETests/ADETests.swift
Terminal teardown must still invalidate when runtime was already torn down
- teardownRuntime no longer skips runtimeInvalidated/clearLaneDirectiveKey
  for terminal reasons (handle_close / ended_session / model_switch) when
  managed.runtime is already null. Previously: idle_ttl preserved, then
  ended_session returned early, leaving a stale sdkSessionId/lane key that
  a future resume could reattach to.
- Added the mirror regression test to the preserve-across-shutdown one.
- Fixes capy-ai + coderabbit Major on agentChatService.ts:5667/5678.

getChatEventHistory validates sessionId before reading transcript paths
- The new IPC-reachable history API builds filesystem paths from sessionId,
  so reject ids that aren't registered agent-chat sessions up front instead
  of trusting raw input. Fixes coderabbit Major on agentChatService.ts:3713.

Device registry preserves per-project legacy IDs
- If a project has a legacy per-project sync-device-id, always use it so
  existing iOS pairings and sync_cluster_state.brain_device_id stay valid;
  only write the shared file when it would agree. Prevents opening project
  B after A from silently adopting A's ID and dropping B into viewer mode.
- Fixes capy-ai High on deviceRegistryService.ts:149.

mergeEnvelopeStreams dedup by timestamp+type, not sequence
- eventSequence restarts at 0 on session rebuild, so the same ordinals
  legitimately appear in the persisted transcript and the current-run
  buffer. Dedup on (timestamp, event.type) so fresh live envelopes aren't
  dropped as false duplicates on first hydration after process restart.
- Fixes capy-ai Medium on agentChatService.ts:3738.

iOS + renderer polish
- Restore port 8788 to iOS tailnet candidates — desktop still falls back
  to 8788 when 8787 is busy (syncService.ts retry).
- Preserve unbracketed IPv6 literals in syncNormalizedRouteHost (strip
  host:port only when there's exactly one colon).
- Fall back from blank laneName to laneId in orphan-lane group labels
  on both desktop and iOS.
- iOS orphan-lane ordering test uses distinct startedAt timestamps so
  chronological-sort is actually exercised.
- AgentChatPane fallback early-return also clears loadedHistoryRef so a
  session-lookup miss no longer blocks future retries.
- browserMock.getEventHistory echoes the requested sessionId.
- syncService.test host-mock override stubs setDiscoveryEnabled.
- agentChatService.test "drops history" now seeds history before delete
  so a regression that fails to clear the hydrated cache is actually
  caught.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

Iteration 2 pushed as d0e23501 addressing the new round of feedback:

Critical fixes:

  • Terminal teardown invalidation (capy-ai + CodeRabbit Major, agentChatService.ts:5667/5678): my iteration-1 early-return was too broad — it skipped invalidation even for terminal reasons (handle_close, ended_session, model_switch) when runtime was already null. Now the early-return only preserves for non-terminal reasons; terminal reasons still set runtimeInvalidated=true and clear the lane directive. Added regression test clears Claude resume metadata when a terminal teardown runs after idle_ttl.
  • getChatEventHistory path validation (CodeRabbit Major, agentChatService.ts:3713): the new IPC-reachable history API now validates sessionId via sessionService.get + isChatToolType before constructing any filesystem path.
  • Device registry per-project ID preservation (capy-ai High, deviceRegistryService.ts:149): replaced the first-project-wins migration with a preserve-if-present policy. If a project has a legacy .ade/secrets/sync-device-id, we always keep using it and only update the shared file when it would agree (empty or matching). Opening project B after A no longer flips B's identity to A's ID.
  • mergeEnvelopeStreams dedup fix (capy-ai Medium, agentChatService.ts:3738): eventSequence restarts at 0 on session rebuild, so sequence-based dedup was dropping fresh live events as false duplicates on first hydration. Now dedup is timestamp+type; sequence is only a tiebreak.

iOS / renderer polish:

  • Restored port 8788 to iOS tailnet candidates (desktop still retries to 8788 when 8787 is busy).
  • Preserved unbracketed IPv6 literals in syncNormalizedRouteHost.
  • Trim-and-fallback blank laneName to laneId in orphan-lane labels (desktop + iOS).
  • iOS orphan-lane ordering test now uses distinct startedAts so chronological sort is actually exercised.
  • AgentChatPane fallback early-return clears loadedHistoryRef so session-lookup misses don't block future retries.
  • browserMock.getEventHistory echoes the requested sessionId.
  • syncService.test.ts host-mock override stubs setDiscoveryEnabled.
  • agentChatService.test.ts "drops history when deleted" now seeds history before delete.

All 443 chat + sync + terminals tests pass; typecheck clean.

Copy link
Copy Markdown

@capy-ai capy-ai Bot left a comment

Choose a reason for hiding this comment

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

Added 4 comments

Comment thread apps/desktop/src/main/services/chat/agentChatService.ts Outdated
Comment thread apps/desktop/src/main/services/chat/agentChatService.ts Outdated
Comment thread apps/desktop/src/main/services/sync/deviceRegistryService.ts
Comment thread apps/ios/ADE/Views/Work/WorkSessionGrouping.swift
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR improves chat/session continuity across desktop and iOS by preserving Claude resume metadata on non-terminal teardowns, surfacing sessions whose lane no longer exists (“orphan lanes”), and expanding/merging chat history snapshots to reduce event loss across reconnects and project switches.

Changes:

  • Desktop: add a main-process getChatEventHistory snapshot API backed by an in-memory per-session ring buffer and use it in the renderer; preserve Claude V2 resume metadata across non-terminal teardowns; surface orphan-lane sessions in SessionListPane.
  • iOS: request larger chat snapshots (2 MB), merge truncated snapshots instead of replacing, retain up to 1,000 events, improve ordering/dedup, and normalize assistant text identity via messageId fallback.
  • Sync: centralize device identity to a shared path and gate host discovery (LAN + Tailnet) based on active project; update docs accordingly.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/features/terminals-and-sessions/ui-surfaces.md Documents orphan-lane sticky groups in by-lane session list mode.
docs/features/sync-and-multi-device/ios-companion.md Documents iOS 2 MB chat snapshot, merge-on-truncation, 1,000 event cap, and messageId fallback.
docs/features/chat/README.md Documents Claude non-terminal teardown resume-state preservation behavior.
apps/ios/ADETests/ADETests.swift Adds iOS tests for tailscale route detection, chat snapshot maxBytes, merge/replace semantics, timestamp ordering, messageId fallback, and orphan-lane grouping.
apps/ios/ADE/Views/Work/WorkTranscriptParser.swift Falls back from itemId to messageId when parsing assistant text events.
apps/ios/ADE/Views/Work/WorkSessionGrouping.swift Splits orphan sessions into per-lane groups and sorts by latest startedAt then name.
apps/ios/ADE/Views/Work/WorkEventMapping.swift Uses trimmed itemId or messageId as a stable assistant text identity.
apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift Prevents merging unidentified assistant text across tool/user events; documents invariant.
apps/ios/ADE/Views/Settings/SettingsPairingSection.swift Updates pairing UI routing heuristics to use generalized tailscale-route detection.
apps/ios/ADE/Services/SyncService.swift Adds tailscale route normalization, increases chat snapshot window, merge-on-truncation, dedup+sort+trim chat history.
apps/ios/ADE/Info.plist Adds ATS exception domains for ade-sync and *.ts.net plaintext websocket connectivity.
apps/desktop/src/shared/ipc.ts Adds IPC channel constant for agentChatGetEventHistory.
apps/desktop/src/renderer/components/terminals/SessionListPane.tsx Renders orphan-lane sticky groups and sorts them by latest startedAt then name.
apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx Adds a renderer test ensuring missing-lane sessions render in by-lane view.
apps/desktop/src/renderer/components/chat/AgentChatPane.tsx Prefers snapshot IPC history loading with fallback to transcript tail; retries on failure.
apps/desktop/src/renderer/browserMock.ts Mocks agentChat.getEventHistory in the browser mock.
apps/desktop/src/preload/preload.ts Exposes agentChat.getEventHistory via preload bridge.
apps/desktop/src/preload/global.d.ts Adds typings for window.ade.agentChat.getEventHistory.
apps/desktop/src/main/services/sync/syncService.ts Threads localDeviceIdPath and hostDiscoveryEnabled through sync service; allows toggling discovery.
apps/desktop/src/main/services/sync/syncService.test.ts Updates sync host mock to include setDiscoveryEnabled.
apps/desktop/src/main/services/sync/syncHostService.ts Adds runtime-gated LAN/Tailnet discovery publishing and cleanup via setDiscoveryEnabled.
apps/desktop/src/main/services/sync/deviceRegistryService.ts Supports shared device-id path with safe legacy-per-project migration/precedence.
apps/desktop/src/main/services/sync/deviceRegistryService.test.ts Adds tests for shared device identity and legacy migration behavior.
apps/desktop/src/main/services/ipc/registerIpc.ts Registers IPC handler for agentChatGetEventHistory with input validation.
apps/desktop/src/main/services/chat/agentChatService.ts Adds in-memory event history ring buffer, transcript hydration, snapshot API, and Claude resume-state preservation rules.
apps/desktop/src/main/services/chat/agentChatService.test.ts Adds tests for snapshot hydration/cleanup and Claude resume preservation across teardowns.
apps/desktop/src/main/main.ts Uses shared userData device-id path and gates host discovery based on active project.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/desktop/src/main/services/chat/agentChatService.ts
Comment thread apps/desktop/src/main/services/chat/agentChatService.ts Outdated
…gments

Claude V2 emits multiple text/reasoning deltas inside tight streaming
loops, so two legitimate envelopes can legitimately share the same
millisecond timestamp and event.type. The previous timestamp+type dedup
key would collapse those distinct fragments into one on the first
hydration, silently dropping the later fragment from the resumed
history.

Include a payload signature (JSON.stringify of the event) in the dedup
key so true duplicates still collapse while distinct fragments at the
same timestamp survive. Sequence stays out of the key since it restarts
per run. Added regression test with two text envelopes at a shared
timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

Iteration 3 pushed as ca6a3020:

  • mergeEnvelopeStreams dedup fix (capy-ai Medium :3734 + :3737): the previous timestamp#type key would wrongly collapse Claude streaming fragments that share a millisecond (valid case: multiple text/reasoning deltas within the same tight loop). Key is now timestamp#type#JSON.stringify(event) so true duplicates still collapse while distinct-payload fragments survive. Added regression test keeps Claude streaming fragments that share a timestamp when hydrating.

Not fixing in this PR (responding to the remaining two Medium flags):

  • deviceRegistryService.ts:157 — capy-ai wants convergence to a single cross-project desktop identity. We're explicitly choosing the opposite for this PR: preserve per-project legacy IDs so existing iOS pairings don't break silently. Converging would invalidate pairings on the N-1 projects whose legacy ID didn't happen to be adopted by the shared file. The per-project preservation is idempotent and race-safe at the level Electron actually serializes creations (single main process, synchronous file I/O within readOrCreateLocalDeviceId). An explicit "consolidate device identity" migration is worth doing, but as a standalone feature with a user-visible prompt — not silently on first multi-project open.
  • WorkSessionGrouping.swift:150 — the iOS orphan-lane code only renders sessions that Database.fetchSessions() returns, and that query inner-joins on lanes (where l.project_id = ?). So the grouping works for archived-lane sessions (still in lanes, just excluded from the active orderedLanes list), but not for truly-gone FK-orphan rows. Making those visible needs a schema change (e.g. add project_id to terminal_sessions so the fetch can skip the lanes join) and a data migration, which is larger than this PR. Filed as a known limitation.

@arul28 arul28 merged commit c6f246e into main Apr 23, 2026
3 checks passed
@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@codex review

@arul28
Copy link
Copy Markdown
Owner Author

arul28 commented Apr 23, 2026

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

arul28 added a commit that referenced this pull request Apr 23, 2026
Covers all work since v1.1.0 — folds in the iOS polish / App Store
review prep originally planned for v1.1.1 plus the chat-continuity
work from PR #175 (chat history on remount, Claude resume survival,
orphan lanes, Tailscale routing cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c6f246e103

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

sessionId,
maxEvents: MAX_SELECTED_CHAT_SESSION_EVENTS,
});
if (snapshot?.events?.length || snapshot?.sessionId === sessionId) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Treat empty snapshot responses as cache misses

This branch marks the snapshot path as successful whenever snapshot.sessionId === sessionId, even if the backend returned no events because the session lookup failed (e.g., transient project-switch timing). In that case loadedHistoryRef stays set and the pane stops retrying history hydration, recreating the “stuck empty chat until remount/new event” behavior. Please require a positive existence signal (or fall back when the snapshot is empty) before setting usedSnapshotPath.

Useful? React with 👍 / 👎.

Comment on lines 191 to 193
static let hostCandidates = [
"ade-sync",
"ade-desktop",
]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve legacy tailnet alias for reconnect compatibility

Dropping "ade-desktop" from tailnet host candidates breaks backward compatibility for existing saved routes that still use that alias. After this change those addresses are no longer classified as tailnet, and the plaintext route filter rejects them, so previously paired devices can fail to reconnect unless users manually edit/re-pair. Keep the legacy alias in the candidate set while newer ade-sync routes roll out.

Useful? React with 👍 / 👎.

Sign up for free to 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.

2 participants