Problem
The client decides whether a session has ended by inferring it from two flags: ptyAttached === false && status === 'idle'.
That pair is ambiguous — it is equally true of a session that has not started yet, because /api/sessions/resume and /api/sessions/start answer before the PTY attaches.
#503 fixed the resume case by suppressing the ended-session redirect while a route carries starting=1.
That works, but it patches the ambiguity at one call site with a URL param rather than removing it.
Why this is worth the cross-repo cost
The same ambiguity is reachable from every entry point that can land on a session before its PTY attaches, not just resume:
- a
threadbase://session/<id> deep link (services/live-activity.ts:155)
- a Live Activity tap
- a push notification
- a hub row / favorite / quick-access tap on a session that is mid-spawn (
components/sessions/*)
None of those carry starting=1, so each is one timing window away from the same bounce.
A phase: 'starting' | 'live' | 'ended' on the session — authoritative, server-derived — collapses all of them at once, and the client stops reconstructing a state machine from two booleans at each call site.
Scope, measured
ptyAttached is read in 6 files, 22 occurrences (grep -rn ptyAttached app components hooks lib services stores utils).
The behavioural decision sites:
app/session/[id].tsx — ready detection (:177), unused-fresh discard on back (:535), live-stream gate (:566), ended-session redirect (:596), read-only placeholder (:724, :895, :938), pending gate (:437)
app/conversation/[id].tsx:295 — 404 conversation → live session redirect
services/live-activity.ts:27 — idle && !ptyAttached drives the Live Activity state
utils/terminalSession.ts:34 — external terminal-session detection
lib/sessionPresentation.ts:66 — shared badge/status derivation, consumed by 3 components
So the refactor is wide even though the direct-read count is small: the fan-out is through sessionPresentation, and each site has its own notion of what the two flags mean.
Sequencing — do not start this yet
Blocked on the Hermes sampling profile from #506 (perf(conversation): instrument the conversation-open path end to end).
#506 is open against app/conversation/[id].tsx, which is one of the files this refactor rewrites.
If the profile names the lifecycle inference or the re-render churn around it, this becomes part of the perf fix rather than a parallel refactor colliding with it.
If it names something else, the two are genuinely independent and nothing was lost by waiting.
File now, start after the profile lands.
Found while fixing #503.
Update 2026-08-03 — the blocker is cleared, and a client-side derivation was tried and rejected
The #506 sequencing block above is resolved: #506 landed and #522 corrected its conclusion.
The profile named the measurement harness, not the lifecycle inference, so this refactor and the perf work are genuinely independent.
A client-side derivation was then attempted and deliberately abandoned, because completedAt cannot carry the ended/starting distinction on its own.
sessionPhase() was added to lib/sessionPresentation.ts and adopted only in services/live-activity.ts, where it is safe.
The ended-session redirect in app/session/[id].tsx was not converted, and should not be until the server contract changes.
Why the redirect cannot be converted client-side
The pre-existing integration fixture in __tests__/integration/components/SessionScreen.endedRedirect.test.tsx states the ambiguity as executable spec.
Its endedSession helper builds { ptyAttached: false, status: 'idle' } with no completedAt, and tests 1 and 2 assert that payload redirects.
Test 4 passes the byte-identical payload plus starting=1 and asserts it does not redirect.
So an ended session and a starting session are the same payload today, separated only by a URL parameter — which is exactly what this issue describes, written down by whoever shipped #503.
completedAt's reliability cannot be established from this repository.
types/api.ts declares completedAt?: string with no comment, while neighbouring optional fields (repoUrl, model, effort, permissionMode) each carry an explicit "Additive; older servers omit it" note.
e2e/mock-server.js never sets it, exactly one fixture in the repo contains it, and no live streamer contract doc specifies it.
Converting the redirect on that basis means one of two things, both unacceptable: shipping a regression where a session that ended on a server not setting completedAt never redirects and strands the user on the read-only screen, or editing the fixture so the test agrees with the implementation rather than with reality.
An "ever attached this mount" ref does not rescue it
Tracking whether the screen observed ptyAttached: true during this mount was considered and fails the cold-landing case the existing tests assert on — tapping a hub row for a session that finished earlier never observes an attach, so the ref is false, completedAt is absent, and the redirect never fires.
The ambiguity is inherent to a single snapshot with no memory of prior state.
Conclusion
This issue's original recommendation stands: an explicit server-derived phase (or an equivalent authoritative "this session has ended" signal) is required, and a client-side derivation from existing fields is not a substitute for the redirect site.
sessionPhase() is the intended home for that logic once the contract exists.
The next step is a streamer-side question, not a mobile one: confirm or add the guarantee, then convert the redirect and the remaining decision sites.
Problem
The client decides whether a session has ended by inferring it from two flags:
ptyAttached === false && status === 'idle'.That pair is ambiguous — it is equally true of a session that has not started yet, because
/api/sessions/resumeand/api/sessions/startanswer before the PTY attaches.#503 fixed the resume case by suppressing the ended-session redirect while a route carries
starting=1.That works, but it patches the ambiguity at one call site with a URL param rather than removing it.
Why this is worth the cross-repo cost
The same ambiguity is reachable from every entry point that can land on a session before its PTY attaches, not just resume:
threadbase://session/<id>deep link (services/live-activity.ts:155)components/sessions/*)None of those carry
starting=1, so each is one timing window away from the same bounce.A
phase: 'starting' | 'live' | 'ended'on the session — authoritative, server-derived — collapses all of them at once, and the client stops reconstructing a state machine from two booleans at each call site.Scope, measured
ptyAttachedis read in 6 files, 22 occurrences (grep -rn ptyAttached app components hooks lib services stores utils).The behavioural decision sites:
app/session/[id].tsx— ready detection (:177), unused-fresh discard on back (:535), live-stream gate (:566), ended-session redirect (:596), read-only placeholder (:724,:895,:938), pending gate (:437)app/conversation/[id].tsx:295— 404 conversation → live session redirectservices/live-activity.ts:27—idle && !ptyAttacheddrives the Live Activity stateutils/terminalSession.ts:34— external terminal-session detectionlib/sessionPresentation.ts:66— shared badge/status derivation, consumed by 3 componentsSo the refactor is wide even though the direct-read count is small: the fan-out is through
sessionPresentation, and each site has its own notion of what the two flags mean.Sequencing — do not start this yet
Blocked on the Hermes sampling profile from #506 (
perf(conversation): instrument the conversation-open path end to end).#506 is open against
app/conversation/[id].tsx, which is one of the files this refactor rewrites.If the profile names the lifecycle inference or the re-render churn around it, this becomes part of the perf fix rather than a parallel refactor colliding with it.
If it names something else, the two are genuinely independent and nothing was lost by waiting.
File now, start after the profile lands.
Found while fixing #503.
Update 2026-08-03 — the blocker is cleared, and a client-side derivation was tried and rejected
The #506 sequencing block above is resolved: #506 landed and #522 corrected its conclusion.
The profile named the measurement harness, not the lifecycle inference, so this refactor and the perf work are genuinely independent.
A client-side derivation was then attempted and deliberately abandoned, because
completedAtcannot carry the ended/starting distinction on its own.sessionPhase()was added tolib/sessionPresentation.tsand adopted only inservices/live-activity.ts, where it is safe.The ended-session redirect in
app/session/[id].tsxwas not converted, and should not be until the server contract changes.Why the redirect cannot be converted client-side
The pre-existing integration fixture in
__tests__/integration/components/SessionScreen.endedRedirect.test.tsxstates the ambiguity as executable spec.Its
endedSessionhelper builds{ ptyAttached: false, status: 'idle' }with nocompletedAt, and tests 1 and 2 assert that payload redirects.Test 4 passes the byte-identical payload plus
starting=1and asserts it does not redirect.So an ended session and a starting session are the same payload today, separated only by a URL parameter — which is exactly what this issue describes, written down by whoever shipped #503.
completedAt's reliability cannot be established from this repository.types/api.tsdeclarescompletedAt?: stringwith no comment, while neighbouring optional fields (repoUrl,model,effort,permissionMode) each carry an explicit "Additive; older servers omit it" note.e2e/mock-server.jsnever sets it, exactly one fixture in the repo contains it, and no live streamer contract doc specifies it.Converting the redirect on that basis means one of two things, both unacceptable: shipping a regression where a session that ended on a server not setting
completedAtnever redirects and strands the user on the read-only screen, or editing the fixture so the test agrees with the implementation rather than with reality.An "ever attached this mount" ref does not rescue it
Tracking whether the screen observed
ptyAttached: trueduring this mount was considered and fails the cold-landing case the existing tests assert on — tapping a hub row for a session that finished earlier never observes an attach, so the ref is false,completedAtis absent, and the redirect never fires.The ambiguity is inherent to a single snapshot with no memory of prior state.
Conclusion
This issue's original recommendation stands: an explicit server-derived
phase(or an equivalent authoritative "this session has ended" signal) is required, and a client-side derivation from existing fields is not a substitute for the redirect site.sessionPhase()is the intended home for that logic once the contract exists.The next step is a streamer-side question, not a mobile one: confirm or add the guarantee, then convert the redirect and the remaining decision sites.