Skip to content

fix(swift-ios): show last message age on the thread activity row - #14021

Closed
saphid wants to merge 335 commits into
pingdotgg:t3code/rebuild-mobile-app-swiftfrom
saphid:pr/swiftui-message-age-20260928
Closed

saphid wants to merge 335 commits into
pingdotgg:t3code/rebuild-mobile-app-swiftfrom
saphid:pr/swiftui-message-age-20260928

Conversation

@saphid

@saphid saphid commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Review this layer

GitHub shows this PR against t3code/rebuild-mobile-app-swift, so its diff includes every earlier PR in the chain. Review only this layer's change:

This layer's diff: +90 / −68 lines across 6 files; app code +62 / −63, the rest is tests and docs.

Show time since the latest conversation message beneath “Agent is working” at the end of the transcript, and remove the thread-header receipt line added in #10766. Snapshot and connection receipts do not reset the message age; a new user or assistant message that is not queued does. The label wakes only at displayed unit boundaries (second, minute, hour, day), and the activity row disappears when work finishes. The user guide now describes the message age instead of the header receipt.

Delivery: stacked. This branch contains #10758, #10759, #10761, #10762, #10763, #10764, #10765, #10766; merge the chain in order (#10758 → #10759 → #10761 → #10762 → #10763 → #10764 → #10765 → #10766 → #14021 → #10767). Each PR's own change is its top commit(s).

Split from #10766 (2026-09-28). This is the second concern of the original #10766 (its last two commits, squashed because the second undid most of the first), plus the user-guide update. The code is byte-identical to those commits, and the chain top is unchanged apart from that guide paragraph.

Current layout, 9eab0475d:

Current thread: last-message age beneath agent activity

Same native WorkspaceView flow on the previous PR revision 578f1c68c and current 9eab0475d: receive a snapshot, append a conversation message, then finish work. Synthetic data; iPhone 17 Pro Simulator / iOS 26.5, dark mode, full 402×874-point viewport scaled to 390 pixels wide. Recordings play at real time; GIFs sample 12 fps. A three-second hold of the final captured frame makes the idle result readable. No taps are simulated in this event-driven fixture.

Before — prior header receipt presentation. This baseline recording includes transient incomplete redraws; their cause is unconfirmed and this PR does not claim to fix them. The current-head recording below is clean through idle:

Before: prior thread-header receipt presentation

After — age beside transcript activity, then clean idle state:

After: snapshot leaves message age unchanged; new message resets it; completion clears activity

Before clean video · After clean video · Captioned after video · Idle screenshot · Capture/edit receipt

Focused verification: candidate capture plus two message-age tests passed (3 tests, xcodebuild exit 0); matching previous-revision capture passed (1 test, exit 0). Tests cover conversation-message selection, queued/tool/system exclusions, future clock skew, and elapsed-unit formatting. CI passed on that original revision. The clock wakes at displayed unit boundaries. No live-backend or physical-device performance measurement is claimed.

Capture limits: earlier current-head captures contained incomplete redraws and were rejected; the replacement recording-only flow was inspected through completion. Xcode compiler-probe stalls required retries. An earlier capture fixture overfulfilled its navigation expectation; that fixture was corrected before the passing runs.

Verification

  • Focused tests at this head (2e25d10a9e's code; FeatureMessageAgeTests, FeatureThreadReceiptTests, NativeTranscriptTimelineTests, FeatureRootModelTests, NativeRetryIdentityTests, iOS Simulator): 23 XCTest and 126 Swift Testing tests passed.
  • Full T3CodeTests at the chain top (68d92c2752): 343 XCTest cases passed (1 skipped). Swift Testing ran 892 tests; the only 2 failures are UsageModelsTests currency formatting, which fail identically on unmodified 157476f1fb.
  • Split check: GPT-6 Astra (codex exec -m gpt-6-astra -c model_reasoning_effort="xhigh" --sandbox read-only) confirmed that the chain top's code is identical to the pre-split top, that this layer's code diff is byte-identical to the two original commits, and that the user guide matches each layer's UI. Its one finding (fix(swift-ios): record thread receipts and keep tool history chronological #10766 did not compile on its own) was fixed, and a re-check returned ready.

Model and harness: GPT-6 / Codex (original change). Split, guide update and verification: Claude Opus 5.5 / Claude Code.

t3dotgg and others added 30 commits August 18, 2026 14:32
- Cache EnvironmentStore's decoded document; publishes read it several
  times per second and previously hit disk + JSON decode every time
- Memoize parsed ISO8601 dates in NativeFeatureClient; every publish
  re-parsed ~10 timestamps per thread through two formatters
- Cache the mapped provider catalog per environment, invalidated on
  server config writes, instead of rebuilding hundreds of structs per
  publish
- Sort activity logs by raw ISO string instead of parsing dates inside
  the comparator (O(n log n) formatter calls per detail rebuild)
- Skip attachment hydration entirely for text-only threads
- Dedupe emitConnection so per-event yields only publish transitions
- Early-out acknowledgeDeliveredMessages when the outbox is empty
- Cap terminal buffers at 256KB; unbounded append could OOM on verbose
  commands and re-layout megabyte strings per chunk
- Key the markdown document cache by content fingerprint instead of the
  whole source string, stop inserting streaming intermediates (which
  churned completed messages out), and promote the final streamed render
  on completion instead of reparsing on the main thread
- Throttle streaming markdown renders (150ms leading-edge) instead of a
  200ms trailing debounce that never fired at an 80ms publish cadence,
  which left streaming messages as plain text for whole turns
- Share JSONEncoder/Decoder.t3 instances (were rebuilt per call) and
  skip sortedKeys on throwaway JSONValue bridging encodes
- Weak-capture the RPC connection loop and keepalive so released
  clients can actually deinit; snapshot dictionary keys before
  reentrant iteration in connected(); scope subscription Interrupts to
  the connection that assigned the request ID; add reconnect jitter
- Cache DPoP JWK + thumbprint (SHA-256 per managed request before) and
  the proof encoder; reuse ISO8601 formatter in command builders
- Replace crash-prone Dictionary(uniqueKeysWithValues:) on
  server-supplied IDs with first-wins reduces (model picker, agent
  awareness); retain per-thread states instead of full FeatureThread
  copies for transition signals; idle the home list timer to 60s when
  nothing is working

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Move table column-width estimation into the background render pass
  and store it on MarkdownRenderedTable; the view measured every cell's
  AttributedString per body evaluation on the main thread
- Make rendered markdown blocks Equatable (inline runs compare by
  reference thanks to the streaming run cache) and wrap block views in
  .equatable(), so only the changed tail of a streaming message
  re-renders instead of every block
- Gate the thread header's per-second TimelineView on working status;
  idle threads render a static status
- Decode local attachment previews off-main through the shared
  thumbnail cache; UIImage(data:) ran in body per reconfigure
- Memoize composer trigger parsing (was parsed 4x per keystroke)
- Drop markdown caches on memory warnings
- Stop the QR capture session on viewDidDisappear/backgrounding and
  resume on reappear; dismantle was the only stop path
- Retry pending workspace navigation requests when snapshot data lands
  so cold-start deep links are not silently dropped
- Scope approval/user-input resolution to details that contain the
  request instead of deep-comparing every cached transcript
- Binary-search diff hydration anchors (was O(n^2) on large files)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Move streaming markdown rendering out of .task(id:) into a renderer
  that survives revision changes; the per-revision task cancelled any
  in-flight parse, so messages that parse slower than the publish
  cadence never rendered. One render runs at a time, latest revision
  wins, 150ms throttle. Keep showing the previous streamed document
  between renders instead of flashing back to plain text.
- Cap the terminal buffer in UTF-8 bytes (the unit the limit is defined
  in) instead of Character count, snapping to a character boundary.
- Start the home list timer after items are populated so an initially
  working thread gets the 1Hz interval; re-evaluate on every update.
- Sort activities by memoized parsed dates (stable, wire-order ties);
  raw ISO strings sort wrong across mixed fractional representations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cancelled drain unwinding after a replacement started could clear the
replacement's task slot, allowing two concurrent drains to deliver out
of order. Generation-stamp each drain; only the current generation may
clear the shared slot, loop, or deliver.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stale-document fallback kept any revision-mismatched render while
streaming, so a reused transcript cell whose @State survived recycling
could briefly show another message's markdown. Only accept a stale
document whose source is a prefix of the current message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
github-actions Bot and others added 11 commits September 28, 2026 08:23
The open socket's updates marked a rejected peer connected again, and stream
repairs woke its HTTP loop before the back-off. Keep the rejection in the
peer's shared state.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The live-peer fixture this test needs now lands below the Stop layer, so
the regression moved out of the command-acknowledgement layer returns here.
Stop status refreshes the shell cache and reads it back. A live turn
advancing the shell during that read made the refresh discard itself as
superseded, so the status check failed and Stop could not be confirmed or
retried. While the environment's stream is authoritative on the current
socket, the cache it keeps current answers instead; after a disconnect or
lost authority the check still fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The receipt line in the thread header showed a snapshot or update receipt
time, which read as message freshness and stayed static while a quiet
thread was connected. Show the age of the last sent message on the
activity row while the agent works instead, advancing only at displayed
unit boundaries, and remove the header receipt line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
await perform {
try await client.cancelTurn(threadID: threadID)
}
try await client.cancelTurn(threadID: threadID)

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.

🟠 High Root/FeatureRootModel.swift:1219

cancelClaimedTurn can cancel a newer turn instead of the claimed key.turnID when a pending creation appears in snapshot while its outbox entry is being removed. This path calls client.cancelTurn(threadID:) without an expected turn ID, so pass expectedTurnID: key.turnID to reject the cancellation if the turn changed.

Suggested change
try await client.cancelTurn(threadID: threadID)
try await client.cancelTurn(threadID: threadID, expectedTurnID: key.turnID)
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/Features/Root/FeatureRootModel.swift around line 1219:

`cancelClaimedTurn` can cancel a newer turn instead of the claimed `key.turnID` when a pending creation appears in `snapshot` while its outbox entry is being removed. This path calls `client.cancelTurn(threadID:)` without an expected turn ID, so pass `expectedTurnID: key.turnID` to reject the cancellation if the turn changed.

return (messages + activities).sorted { $0.createdAt < $1.createdAt }
// Older pages contain earlier turn windows, but carry current session
// metadata. That metadata must not revive their historical tool rows.
return NativeTranscriptTimeline(

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.

🟡 Medium App/NativeFeatureClient.swift:7062

Historical pagination renders a completed-looking work-log row for a start-only tool.started activity instead of omitting it. renderedHistoryMessages passes sessionIsLive: false, and NativeTranscriptTimeline freezes active work in finishActiveWork(), so current-session activity carried by older pages becomes a stale transcript row; preserve the inactive-path behavior that drops active-only work before rendering history.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/swift-ios/App/NativeFeatureClient.swift around line 7062:

Historical pagination renders a completed-looking work-log row for a start-only `tool.started` activity instead of omitting it. `renderedHistoryMessages` passes `sessionIsLive: false`, and `NativeTranscriptTimeline` freezes active work in `finishActiveWork()`, so current-session activity carried by older pages becomes a stale transcript row; preserve the inactive-path behavior that drops active-only work before rendering history.

@macroscopeapp

macroscopeapp Bot commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is a large Swift iOS runtime and UI overhaul rather than a narrowly scoped display fix, introducing new cancellation semantics, transport reconciliation, transcript reconstruction, and presentation lifecycle behavior across production paths. Unresolved threshold-level correctness findings add further risk to the central client and stop flow.

Not approved because:

  • 2 blocking correctness issues found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

@saphid

saphid commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

@t3dotgg heads-up: #10766 had two concerns, so it's now split.

New merge order: #10758 → #10759 → #10761 → #10762 → #10763 → #10764 → #10765 → #10766 → #14021 → #10767. #12655 stays independent.

Each PR's GitHub diff includes the whole chain below it, so every description now starts with a link to that layer's own change:

PR Own change Lines
#10758 layer diff +992/−29
#10759 layer diff +522/−31
#10761 layer diff +2558/−464
#10762 layer diff +123/−2
#10763 layer diff +1257/−42
#10764 layer diff +711/−56
#10765 layer diff +4060/−25
#10766 layer diff +807/−330
#14021 layer diff +90/−68
#10767 layer diff +290/−77

@t3dotgg
t3dotgg force-pushed the t3code/rebuild-mobile-app-swift branch from 157476f to 285d2ab Compare October 1, 2026 04:50

Copy link
Copy Markdown
Member

Note

This comment is posted by Julius' dot

The linked six-file layer diff explains the message-age change, but the complete PR targets 285d2ab and diverges from it at 901db89, including the Swift client foundation, workflow, and earlier repairs. Please confirm the intended upstream baseline for this whole stack and restack it there, or explain the intended cumulative integration. That is needed to assess the complete PR under one problem; the isolated top-layer diff cannot establish that by itself. Leaving this open pending clarification.

@t3dotgg
t3dotgg force-pushed the t3code/rebuild-mobile-app-swift branch from 1637f70 to cf79028 Compare October 1, 2026 21:36
@t3dotgg

t3dotgg commented Oct 2, 2026

Copy link
Copy Markdown
Member

Note

🤖 GPT-6.1-Sol responding on behalf of Theo

Closing this to keep the SwiftUI rewrite aligned with React Native. This adds a last-message-age label to the thread activity row. React Native has no equivalent label, and the receipt header this replaces is absent from the current SwiftUI branch.

For reconsideration, show a concrete parity defect on the current branch and submit only the correction.

See the contribution scope guidance.

@t3dotgg t3dotgg closed this Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants