Skip to content

fix(chat): a background turn no longer looks dead when you switch tabs - #18

Open
Amir-SSVLabs wants to merge 6 commits into
mainfrom
fix/background-turn-stability
Open

fix(chat): a background turn no longer looks dead when you switch tabs#18
Amir-SSVLabs wants to merge 6 commits into
mainfrom
fix/background-turn-stability

Conversation

@Amir-SSVLabs

@Amir-SSVLabs Amir-SSVLabs commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Leaving a chat while the agent is still answering, and coming back, could leave you looking
at your own message with nothing under it — no answer, no spinner, no way to tell whether
the app was working or wedged. In the worst case the answer had already arrived and was
sitting in the run's event log, permanently unreachable from the UI.

This fixes the three separate mechanisms behind that, plus the elapsed counter that reset
every time you switched tabs. All of it is reproducible on main today.


Background: how a turn and a tab relate

Three facts about the current design make the rest of this readable.

A conversation ("run") outlives the tab showing it. A run is a backend row plus a live
CLI process. It keeps working whether or not you are looking at it, and it is the only
place the answer exists until the UI reads it back.

Only the open session renders. The event stream carries events for every run, but the
chat view drops anything that isn't the active one:

if (!runId || ev.run_id !== runId) return;   // everything below is for the open chat only

Anything a background run says is therefore not "displayed later" — it is discarded, and
the UI has to re-read it from disk when you return.

A tab switch is two phases, and there is a gap between them.
showWorkspaceTabSurface swaps the visible state synchronously (instant), and
hydrateWorkspaceTabAfterPaint then does the IPC to restore the real transcript —
getRun, a CLI transcript sync, getBusEvents. That second phase takes hundreds of
milliseconds to seconds.

So three things must survive a switch: is this turn still working, what has it said so
far
, and which run does this tab even own. Each of the three was broken differently.


Mechanism 1 — the spinner was cleared and restored too late

showWorkspaceTabSurface did sending = false on every switch. That is correct for
leaving a chat (don't inherit the previous one's spinner) but wrong for arriving at one
— and the authoritative restore only ran at the end of phase two. For the whole gap, a live
turn rendered as an idle one.

There was a second, worse version of this: openSession could only ever set sending to
true. The "not running" case fell through and set nothing, so the restore could turn a
spinner on but never off.

Fix. Both paths now seed from workingRuns — the set that live events already maintain
for every run, not just the open one. (That set is why the tab dot stayed correct while the
chat body looked dead; the information was already there, the chat just wasn't reading it.)
The restore is now an explicit three-way decision — waiting-for-user / working / idle
— in a pure, tested helper, so an optimistic seed always gets corrected. Paths that return
early without ever learning the run's real state (a failed getRun, a vendor handover) now
clear the seed rather than leaving a spinner on forever.

The subtle part: spawning

This one is worth a paragraph because getting it wrong silently reintroduces the whole bug.

The live handler decides "is this run working?" by exclusion — anything that isn't
idle/completed/failed/stopped counts as working. That includes spawning, the
state a run sits in while a cold CLI process boots, which is 10–20 seconds.

My first version of the restore listed the working states positively: running and
pending. Those two rules disagree about spawning. The consequence: switch tabs during a
cold start and come back, and the "authoritative correction" would decide a live turn was
idle, tear down its spinner, and disarm its watchdog — the original bug, restored, in the
slowest window there is.

So the terminal list is now a single shared constant, TERMINAL_RUN_STATES, used by both
the live handler and the restore, and both directions are tested. Any state either side
doesn't recognise means working, which is the safe default: a spinner that shouldn't be
there gets cleared a second later by the real state, whereas a missing one looks like a
crashed app.


Mechanism 2 — a tab could lose its link to its run, with no way back

This is the one that produced "my answer never came back".

A tab points at its conversation through tab.sessionId. That is persisted state, and in a
real occurrence on a dev machine it was gone: the run that had answered appeared against
no tab at all in session-ui.json, and the active tab held sessionId: null.

The damage came from how that case was handled. With no session id, the hydrate step
resolved the tab to nothing and hit a silent early return — no IPC, no log, no error. So
the tab rendered its cached prompt forever, while the finished answer sat in the event log a
single getBusEvents call away. The app log confirmed the shape exactly: a get_run for the
tab being switched to, and never one for the tab being switched back to.

Fix. Dispatch now records which run each tab sent (lastRunByTab), deliberately in
separate storage from the binding it backs up. When hydrate finds a tab with a transcript
but no session, it repairs the binding from that record and restores properly instead of
stopping. A second, weaker check in the event handler covers the narrower case where the
workspace still knows the run but the tab has lost it.


Mechanism 3 — partial answers resumed mid-word, sometimes as raw HTML

Because background events are discarded rather than buffered, returning to a turn
mid-sentence started accumulating text from whatever chunk arrived next. Everything before
it was simply gone, which is why the answer appeared to begin in the middle of a word:

ched tabs and came back<button onclick="probe()">Probe environment…

The markup in that line is the second half of the problem. Scratchpad documents travel
inside a fenced block that the UI strips before rendering. Rejoining mid-stream means the
opening fence arrived while you were away, so the strip had nothing to match on and the
document's raw HTML rendered as if the agent had written it as prose.

Fix. Deltas are now buffered per run, so both restore paths resume the answer from its
first word. The buffer is dropped when the message completes and on every path that ends a
turn without completing; silent turns are never buffered at all; and past its size cap it
keeps the tail behind a visible marker rather than splicing an unmarked hole into the middle
of the text. Separately, stripStreamingBlocks hides any block that is open-but-unterminated
— which also fixes a pre-existing wart where raw JSON was briefly visible in the transcript
whenever any scratchpad streamed.


Also: the elapsed counter was measuring the wrong thing

The timer restarted at zero on every tab switch, so a turn running for two minutes could
read 3s. Its effect stamped a start time whenever sending became true — and returning to
a tab makes that true again — so it was measuring how long you had been looking at the
turn
, not the turn's age.

The start time is now recorded per run, stamped when you press send. A cold start is
therefore counted rather than hidden, which matters because that is the slowest part of the
wait.


Bookkeeping that had to come with it

Marking runs as working introduced state that needed an owner. Every per-run trace (working
flag, stream buffer, start time, silent-turn flag) is now cleared through one function,
called not only on the terminal event but on the paths that end a turn without one: a
dispatch that fails after the run row exists, an explicit stop, a session delete. Without
that, a failed send would leave a run spinning in the sidebar for the rest of the session.

Run ids are also normalised to strings at every read and write of those collections — a
write under 123 and a delete under "123" would leak the entry permanently.


Known gap — please read

What clears the tab↔run binding in the first place is still unidentified. I found the
mechanism of the damage, not its origin.

This PR makes losing the binding recoverable and loud rather than fatal and invisible:
both repair paths log a warning naming the tab and the run. That warning is deliberately a
defect detector — if it shows up in normal use, something is still clearing bindings, and
the log will finally say where. It is not a root-cause fix and shouldn't be read as one.


Why the @types/node commit

vite.config.ts reads process.env.TAURI_DEV_HOST, but no Node types were installed, so
npm run check reports one error on main today: Cannot find name 'process'. The repo's
own pre-commit hook runs that check, so it can't pass without this. Pinned to 22.17.0, the
Node version the Recall sidecar ships against.

CI's typecheck leg is continue-on-error, so this doesn't change CI colour — it unblocks
the local hook, and the leg is now clean enough to ratchet to blocking in a follow-up.


Verification

gate result
npm run test (vitest — blocking in CI) 1716 passed, 68 files
npm run check (svelte-check) 0 errors, 129 warnings
npm run lint (blocking in CI) 0 errors, 32 warnings (all pre-existing)
CI all legs green, including the blocking macOS cargo test

npm run verify does not pass, for a pre-existing repo-wide reason: format:check
reports ~288 unformatted files on main. Every file this PR touches is Prettier-clean, and
that leg is continue-on-error in CI.

Exercised by hand on Windows / Claude / signed-in: switch away mid-turn and return; let
a turn finish while away and return; watch the counter continue instead of resetting.

Not tested: macOS, Linux, or the Codex vendor path. The changes are vendor-agnostic by
construction — the diff adds no reference to agentFor, currentProvider, or any vendor
name — but I have not exercised Codex end to end.

On test coverage, honestly: the new tests cover the extracted pure helpers
(turn-restore.ts, stripStreamingBlocks), including the spawning case above. No test in
this repo imports a .svelte file, so the +page.svelte wiring that calls them is asserted
nowhere and was verified by hand only.


Scope

Only the general chat/tab fixes. These surfaced during unrelated feature work; that work is
deliberately not in this PR and stays on its own branch.

Amir-SSVLabs and others added 4 commits August 6, 2026 13:11
vite.config.ts reads process.env.TAURI_DEV_HOST, but no Node types are installed, so \
pm run check\ reports \Cannot find name 'process'\ - one error, on main, today. The repo's own pre-commit hook runs that check, so it cannot pass without this.

Pinned to 22.17.0, matching the Node the Recall sidecar ships against. CI's typecheck leg is continue-on-error, so this does not change CI colour - it unblocks the local hook and makes the leg honest enough to ratchet later.
Ask a question, switch to another chat, come back: the transcript showed
the user's own message with nothing under it and no spinner, which is
indistinguishable from a hung app. The turn was fine the whole time.

Two invariants were broken. They are committed together because they are
the same one seen from both ends: a running turn must look running, and
the tab it runs in must stay attached to it.

**A running turn must look running.**

- showWorkspaceTabSurface set `sending = false` on every tab switch, and
  the authoritative restore only lands after getRun + a CLI sync + the
  transcript read. Both it and openSession now SEED from `workingRuns` —
  the set live run_state events already maintain for EVERY run, not just
  the open one, which is why the tab dot stayed right while the chat body
  looked dead — so the spinner is correct on the first frame.
- openSession could only ever set `sending` true; the not-running case
  fell through, so an optimistic seed could stick. It is now an explicit
  three-way decision in a pure, tested `restoredTurnActivity`, and the
  paths that leave early without learning the run's real state (a failed
  getRun, a vendor handover) clear the seed instead of stranding it.
- A run was only tracked as working once its first run_state arrived, so
  a user who switched away during the spawn came back to a tab that had
  bound its run but showed no activity. Dispatch marks it immediately,
  and — because a turn that never starts emits no terminal state — the
  failure path un-marks it, or the sidebar would spin for the session.

`spawning` is the sharp edge: the event handler counts every non-terminal
state as working, so a restore recognising only running/pending would
call the whole cold-start window idle and tear down a live turn.
TERMINAL_RUN_STATES is shared by both now, and both directions are tested.

**A tab must stay bound to its run.**

Evidence from a real occurrence: the answering run appeared against no
tab in session-ui.json while the active tab held sessionId=null. With no
binding, hydrateWorkspaceTabAfterPaint resolved the tab to no session and
took a silent early return — no IPC at all, so the cached prompt rendered
forever while the finished answer sat unreachable in the event log.

Dispatch now records the run each tab sent, and the hydrate path repairs
the binding from it rather than stopping silently. A second, weaker check
in the event handler covers the case where the workspace still knows the
run but the tab does not.

Honest limit: which path drops the binding is still unidentified. This
makes losing it recoverable and loud (both repairs log a warning naming
tab and run) rather than fatal and invisible — it is not a root-cause fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oads

Returning to a running turn showed a fragment of the answer starting
mid-word, with document markup in it:

  ched tabs and came back<button onclick="probe()">Probe environment…

Two causes, one visible symptom. Deltas are only rendered for the open
session, so a switch-back began accumulating from whatever arrived next
— hence the severed "…ched tabs". And because the opening ```fence had
arrived while the user was away, the scratchpad strip could not match it,
so the document's raw HTML rendered as the agent's own message.

Deltas are now buffered per run and both restore paths seed from that
buffer, so the answer resumes from its first word. The buffer is dropped
on completion and on every path that ends a turn without one; silent
turns are never buffered at all; and past the cap it keeps the TAIL
behind an explicit marker rather than splicing an unmarked gap into the
middle of the restored text.

stripStreamingBlocks then hides any block that is open-but-unterminated.
Complete blocks are stripped first, so prose following a finished block
survives — tested, because cutting to end-of-string would otherwise eat
the agent's closing question. This also fixes the pre-existing case where
raw JSON was visible in the transcript while any scratchpad streamed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The elapsed counter restarted at zero on every tab switch: its effect stamped a start time whenever `sending` flipped true, and switching back flips it true again. A turn that had been running two minutes read as 3s - the counter was measuring how long the user had been LOOKING at the turn.

Turn start is now recorded per run: at send time, so the CLI cold start the user waits through is counted rather than hidden, and on the first run_state for a turn begun in a background tab. The stamp is cleared with every other per-run trace when the turn ends, by any route - terminal state, explicit stop, failed dispatch, or session delete - so a later turn on the same run can never inherit the previous one's age.
@Amir-SSVLabs
Amir-SSVLabs requested a review from alonmuroch August 6, 2026 10:44

@alonmuroch alonmuroch left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good PR — real root-cause work rather than symptom-patching, and the extracted pure helpers (turn-restore.ts, stripStreamingBlocks) are properly tested. The spawning analysis is correct: commands/session.rs:647 does emit "spawning", and map_state_to_run_status folds it into RunStatus::Running, so the narrow running/pending list would indeed have called the entire cold-start window idle. The write-up of the known gap is the right way to ship an incomplete root cause.

Four things I'd like fixed before this merges. The first one reopens a narrower version of the bug the PR exists to fix.


1. The optimistic working mark isn't durable — loadSessions() wipes it mid-dispatch

src/routes/+page.svelte:8310 replaces the set wholesale:

workingRuns = new Set(
  runs.filter((r) => String(r.status).toLowerCase() === "running").map((r) => r.id),
);

dispatch calls loadSessions() itself at :7800, unawaited, a few lines after this PR's new markRunWorking(targetRunId, sentAt) and before sendOrResume. On the warm-session path (+ New → first message) the run is idle in the DB at that moment — warmSession leaves the process "alive and idle" (:4666) — so the filter drops it and the mark is gone until the first run_state event arrives. Switch tabs inside that window and the turn reads as dead again.

The cold-spawn path at :7901 survives only incidentally: startRun has already emitted spawning, which maps to RunStatus::Running, so the row happens to match the filter.

The comment at :8306 blesses exactly this ("it's fine if a just-spawned active run still reads idle here") — on the premise that workingRuns only drives a sidebar dot. This PR makes it the seed for tabIsWorkingOnOpen, so the premise no longer holds and the comment should change with it.

Suggest merging rather than replacing: keep any run already marked in-flight whose persisted status isn't terminal.

Related, same site: the wholesale rebuild also re-adds runs left running in the DB by a crash or kill. That was cosmetic before; now it seeds a spinner on tab switch, and silentTurnDisposition("running") returns wait forever, so the watchdog can't clear it. Pre-existing, not introduced here — but worth a look while you're in this function.

2. inspectSilentTurn bypasses forgetRunActivity

:2311-2317 still hand-deletes from workingRuns only:

sending = false;
turnStopping = false;
actorLive = disposition.actorLive;
const next = new Set(workingRuns);
next.delete(target);
workingRuns = next;

This is one of the "ends a turn without a terminal event" paths the PR's own bookkeeping section is about, and it's the one that got missed. It leaves streamingByRun, turnStartedByRun and hiddenRuns populated, with three visible consequences:

  • the stale partial answer re-seeds streaming on the next visit to that tab, on top of the hydrated transcript;
  • the next turn on that run inherits the old start time — markRunWorking only sets when absent — so the elapsed counter opens inflated;
  • a run whose silent turn settled this way stays in hiddenRuns permanently, so its later visible turns are never buffered at all.

forgetRunActivity(target) here.

3. The handover guard still uses the narrow definition

:8564 is untouched:

const running = st === "running" || st === "pending";

and it still gates shouldHandoverOnContinue. So opening a session during a cold spawn, with a different vendor selected, can trigger a vendor handover on a turn that is mid-flight — the same spawning disagreement the PR argues is invisible and brutal, just in the other consumer of st. Should be !isTerminalRunState(st).

4. The event-handler rebinding can't tell "unbound" from "gone"

if (activeTabId && !workspaceTabs.find((tab) => tab.id === activeTabId)?.sessionId) {

A missing tab and an unbound tab both land here, and bindWorkspaceTabToSession no-ops on an unknown id — so in that case the warn, the workspaceTabs reassignment and persistWorkspaceTabs() would fire on every delta of the turn. I couldn't find a path where activeTabId outlives its tab, so this is defensive rather than a live bug, but a block whose whole purpose is to be a defect detector shouldn't be able to bury the log. Check the tab exists, and log once.


Minor

  • The doc comment for markRunWorking is orphaned: two /** */ blocks stack, and the first one (describing the mark) sits above turnStartedByRun.
  • package-lock.json carries an unrelated 0.5.8 → 0.6.0 version re-sync.
  • @types/node pinned at 22.17.0 and the reasoning for it: no objection.

Happy to re-review quickly once 1 and 2 are in — 3 and 4 are small enough to take or leave with a reply.

Review follow-up. Four defects, the first of which reopened a narrower
version of the bug this branch exists to fix.

**1. `loadSessions()` erased the mark it was supposed to preserve.**
It rebuilt `workingRuns` wholesale from persisted status, and `dispatch`
calls it unawaited a few lines after marking its run in-flight. On the
warm path that run is still `idle` in the DB — the process is alive and
waiting — so the rebuild dropped the mark until the first `run_state`
arrived, and a tab switch inside that window read the turn as dead again.

The poll is now a SEED, not the authority: `mergeWorkingRuns` adds runs
the backend reports as running and keeps existing marks. Note that the
obvious predicate — keep a mark unless persisted status is terminal —
does NOT fix this, because `idle` is terminal by that definition and
`idle` is exactly what the warm run reads. So marks are reaped only on a
state a live process cannot sit in: completed / failed / stopped.
`runStateIsSettled` names that distinction and is tested against it.

Clearing marks stays with the definite signals — terminal events, an
explicit stop, a failed dispatch, the watchdog.

**2. The watchdog path bypassed the cleanup helper.**
`inspectSilentTurn` hand-deleted from `workingRuns` only, so it left the
stream buffer, the turn's start time and the silent-turn flag behind — a
stale partial answer re-seeded over the hydrated transcript, the next
turn's clock opening inflated, and a run permanently excluded from
buffering. It now goes through `forgetRunActivity`, which is the whole
point of that function existing.

**3. The handover guard kept the narrow definition.**
It judged mid-turn by `running`/`pending`, so opening a session during a
cold spawn with another vendor selected could hand over a live turn — the
same `spawning` disagreement this branch argues is invisible, in the other
consumer of that state. Both now ask `runStateIsWorking`.

**4. The rebind could not tell "unbound" from "gone".**
A missing tab took the same branch as an unbound one, and the bind no-ops
on an unknown id, so the warning and a `persistWorkspaceTabs()` would
have fired on every delta. A detector must not be able to bury its own
signal: it now requires the tab to exist.

Also: `markRunWorking`'s doc comment had drifted above `turnStartedByRun`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Amir-SSVLabs

Copy link
Copy Markdown
Contributor Author

Thanks — all four were real, and #1 was the one worth catching. Fixed in 19d338a.

1. loadSessions() erasing the mark — fixed, but your predicate wouldn't have worked

Your diagnosis is exactly right, including that the cold path survives only
incidentally via spawning → RunStatus::Running.

One correction on the suggested fix, though: "keep any run already marked in-flight
whose persisted status isn't terminal" does not fix this case.
idle is terminal by
that definition, and idle is precisely what the warm run reads — as you noted yourself
from :4666. So that rule would have dropped the mark for the very path it was meant to
protect.

What the situation actually needs is a distinction the codebase didn't have a name for:
idle means no turn is running, but it does not mean the run is over — a warm
process sits idle waiting for its first message. So marks are now reaped only on a state
a live process cannot sit in:

const SETTLED_RUN_STATES = ["completed", "failed", "stopped"] as const;

mergeWorkingRuns(marked, runs) adds what the backend reports as running, keeps existing
marks, and reaps a mark only when the run has genuinely settled. A run absent from the
list is kept too — that list can lag or be filtered, and dropping a live turn is worse
than holding a mark one poll longer. Extracted and tested, including your exact scenario
(marked: warm-1, persisted idle → still marked), because this is the one that reopens
the bug if it regresses.

Clearing stays with the definite signals: terminal events, explicit stop, failed dispatch,
watchdog. And you're right that the old comment was blessing the behaviour on a premise
this PR invalidates — it's rewritten to say why the poll is a seed and not the authority.

On the crash-left running rows: agreed on the analysis, and agreed it's pre-existing
— but I've left it, deliberately. Reaping them needs a way to tell "row left running by a
crash" from "row running with a live CLI this app hasn't attached to yet", and that's what
catchUpFromCli exists to resolve. Guessing here would either strand real turns or paper
over the silentTurnDisposition("running") === wait loop you spotted, which is the actual
bug and deserves its own change. Happy to file it.

2. inspectSilentTurn bypassing forgetRunActivity — fixed

Straightforwardly missed, and your three consequences are right. It now calls
forgetRunActivity(target), which is the reason that function exists. The comment there
now names this as one of the no-terminal-event paths so the next person doesn't re-open it.

3. Handover guard — fixed

Both consumers of st now go through runStateIsWorking, so there is one rule rather than
two. I used that helper rather than !isTerminalRunState(st) on purpose: the latter treats
an empty state as working, which would have suppressed handovers for sessions with no run
state at all. runStateIsWorking treats empty as not-working and everything non-terminal
(including spawning) as working, which preserves today's behaviour for "" while closing
the gap you identified.

4. Rebind conflating "unbound" with "gone" — fixed

Agreed on the reasoning even without a live path: it now requires the tab to exist, so a
missing id is a no-op rather than a per-delta warning. "A defect detector shouldn't be able
to bury the log" is the right framing and I've put it in the comment.

Minor

  • Doc comment un-orphaned.
  • package-lock.json 0.5.8 → 0.6.0: worth flagging back — that's not drift I
    introduced. main has package.json at 0.6.0 and the lock still at 0.5.8, so the
    lock is stale on main and npm install corrected it. I've kept the correction rather
    than writing the stale value back, since reverting it just means the next person's
    install regenerates the same two lines. Happy to strip it if you'd rather it moved
    separately.

Re-verified: 1722 tests pass (6 new), svelte-check 0 errors, lint 0 errors. Verification
axes unchanged — Windows / Claude / signed-in, and still not macOS, Linux, or Codex.

Comment thread src/routes/+page.svelte
actorLive = true;
armWatchdog();
} else {
sending = false;

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.

🟡 openSession's authoritative restore clears sending when the run resolves idle but never clears the streaming it seeded from streamingByRun at line 8720, and the streaming bubble renders on streaming truthiness alone (line 12267). If the background run's message_complete/terminal run_state push was dropped — the file's own premise is that push delivery is not guaranteed, and reconcileRunEvents only covers the open run — the buffer goes stale and the finished answer renders twice forever: once in the hydrated transcript and again as a live streaming bubble. In the activity !== "working" branch, also set streaming = "" and streamingByRun.delete(id).

Comment thread src/routes/+page.svelte
// arrived, and a tab switch inside that window read the turn as dead again.
// This set is no longer just a sidebar dot: it seeds the spinner on restore
// (`tabIsWorkingOnOpen`), so an in-memory mark outranks a stale row.
workingRuns = mergeWorkingRuns(workingRuns, runs);

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.

🟡 A stale workingRuns mark is now unreapable: mergeWorkingRuns deliberately keeps marks for runs whose row reports idle (and for rows missing from the list), and nothing else removes the mark when the terminal run_state push for a background run is dropped — background runs have no reconciler, and openSession's restore corrects only sending, never workingRuns. The old replace-from-DB seeding self-healed within one loadSessions; now the sidebar/tab-dot spinner persists for the rest of the session and every switch-back re-seeds sending = true via tabIsWorkingOnOpen until hydrate flips it back. Call forgetRunActivity(id) in openSession when restoredTurnActivity resolves to idle/waiting-for-user — the on-disk event log is authoritative there, so the reap is safe.

Comment thread src/routes/+page.svelte
// Dropped as soon as the message completes (it becomes a transcript row).
const streamingByRun = new Map<string, string>();
const STREAM_BUFFER_CAP = 400_000;
const STREAM_TRUNCATED = "\n\n… (earlier output dropped from the background buffer) …\n\n";

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.

🟡 STREAM_TRUNCATED adds new user-facing UI text ("… (earlier output dropped from the background buffer) …", rendered inline in the chat transcript) without updating messages/en.json and messages/zh-CN.json. CONTRIBUTING.md ("i18n": when adding new UI text, update both locale files).

@sebastian-ssvlabs sebastian-ssvlabs left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed-at: 2e5f021

Comment thread src/routes/+page.svelte
} else if (running) {
// Authoritative turn state — also CORRECTS the optimistic seed taken when
// the tab was shown, so an over-eager spinner can never get stuck on.
const activity = restoredTurnActivity({ hasPendingAsk: !!restoredPendingAsk, runState: st });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 The elapsed-turn clock only survives a tab switch, not an app relaunch (or the first time this window sees an already-running background run).

turnStartedByRun is populated only two ways: markRunWorking() at dispatch time (this window sent the message), or a live run_state event once it arrives (+page.svelte:2129). openSession's authoritative restore here computes activity from st and correctly sets sending = true for a working turn, but never seeds turnStartedByRun for that run — so when the effect at +page.svelte:3269 runs (turnStartedByRun.get(runId) ?? Date.now()), it falls back to Date.now() and the counter restarts at 0. That's the exact symptom this PR sets out to fix ("the counter continue[s] instead of resetting"), just reached a different way.

Concretely: relaunch the app while a long turn is still running (or is picked up cold via maybeRestoreLastSession). catchUpFromCli a few lines above already exists specifically to handle "quit, crashed, or restarted mid-turn" for the transcript — spinner and content restore correctly for that path via tabIsWorkingOnOpen/restoredTurnActivity — but the elapsed counter doesn't get the same treatment and will read seconds-since-relaunch instead of the turn's true age.

RunMeta.started_at (backend) is set once at run creation, not per turn, so it can't be dropped in as-is for a multi-turn session — but some per-turn timestamp derived from event history would fix this. Worth a follow-up if elapsed-time accuracy matters across restarts, not blocking this PR.

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.

4 participants