Skip to content

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

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

saphid wants to merge 29 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.

github-actions Bot and others added 29 commits September 26, 2026 16:56
A detached accepted-command refresh could finish after deleteThread and
publish the deleted thread again. Cancel it once the server accepts the
delete.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng older state

A detail read that started before a newer send was accepted could publish
after it and hide the newer message until the next read. Skip publishing a
superseded read (its refresh loop reads again), and stop a cancelled shell
refresh from writing a shell read before the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ives

A Stop accepted during a send's detail read discarded that read without
requesting another, and an older detail could replace the transcript before
the accepted message reached it. Re-read detail after a discarded read, and
keep delivered messages until a server transcript includes them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Do not retain a delivered message that a server transcript already
confirmed, keep only its display copy, and drop retained messages when
their thread details are removed or cleared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Evicting a cached thread detail forgot its accepted messages, so a stale
reopen hid them. A restored outbox or a new task's local detail was also
recorded as the server transcript, so its accepted prompt was treated as
confirmed and disappeared on the next older read. Only server transcripts
now confirm delivery, and eviction keeps retained messages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A newer send can be delivered while an older one waits to retry. Delivered
copies were placed before every queued copy, which reversed them. Merge
both by send time.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A transient environment catalog read failure ended the active client's
configuration subscription, so later provider and settings updates were
ignored. Skip that publish instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e probe

A passive peer's catalogue probe started at bootstrap could finish after
the user saved a preference there and replace it with the older value.
Discard the probe when a config already arrived.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A peer whose credential was rejected before its catalogue loaded kept
opening a probe client every 20 seconds. The probe's own error cannot show
the rejection (the socket ticket 401 surfaces as a timeout), so stop once
the peer's shell read has marked it as needing pairing; re-pairing starts
a new worker.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ismissed early

Reopening a thread in the compact split view can report the new thread view
as disappeared right after it appears while it stays on screen. That
cancelled the draft restore, so the composer showed a busy send button
until relaunch. Restore outside the view task's lifetime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…cted

Pairing rejection cancelled the polling tasks but left the reconciliation
loop issuing rejected shell reads every 30 seconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A passive peer whose socket stayed open kept its 30-second quiet shell
reads after a 401, and showed as connected. Handle the rejected credential
before the live-stream shortcut and switch to the long back-off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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

This branch has not been deployed

No deployments
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.

1 participant