* fix(chat): bust live artifact iframe cache on file updates
* fix(chat): scope artifact cache refresh to live HTML previews
---------
Co-authored-by: lefarcen <935902669@qq.com>
* fix(chat): keep question form controls in the UI language
* test(web): locate questionnaire controls by visible semantics
---------
Co-authored-by: lefarcen <935902669@qq.com>
* fix(chat): preserve manual file ownership across run recovery (OPEND-2928)
Record successful workspace document writes and exclude their unchanged
receipts from assistant produced/trace files while retaining proven Agent
writes and existing unknown-runtime output handling.
Carry receipts through reconnects of the same scoped physical run, including
transport failure ordering and manual reconnect after a missing status.
Keep cleanup on real terminal exits, failed run creation and scope changes.
* fix(chat): preserve file ownership during inline artifact recovery
Use the resolved project authority for artifact writes and carry scoped
manual-write receipts through terminal error handling into artifact recovery.
Read the transport text when deciding whether artifact recovery is pending,
and filter existing produced/trace projections without dropping Agent output.
---------
Co-authored-by: lefarcen <935902669@qq.com>
durableRunState never serialized appliedPluginSnapshotId, so a daemon restart
restored the source Run without the field while the strategy task kept its
locked snapshot; the clarification continuation guard then rejected the task
with STRATEGY_TASK_SOURCE_RUN_INVALID. Persist the field on the durable run
state, and recover a missing id from the applied_plugin_snapshots run_id link,
which survives restarts. Runs whose snapshot id is set but differs, or whose
snapshot row is not linked to the source Run, keep failing the guard.
After the user answers the one clarification round, the agent's next turn
carries a complete Full Plan plus a Runtime State. On 0.22.1-prerelease.9
(OPEND-2954) that state said `inputStage: "request"` — the value every
Runtime State example in the protocol reference shows, and the one field the
clarification continuation never named — and the coordinator refused the turn
on that field alone. The task settled terminal-`blocked`, a plan that passed
every other gate never reached production, and no repair path could anchor on
it because the block was schema-valid and the mismatch is not a parser issue.
The stage is host-owned truth: the daemon issued it, wrote it into the
continuation wrapper, and holds it on the task, so a disagreeing declaration
carries no authority — the same footing as the execution mode a clarification
turn predicts, which the parser already discards. `adoptHostInputStage` now
gives the state the host's stage before validation whenever the corrected
state is a valid declaration for that stage, and records the decision as a
normalization rather than a reason code so an agent-declared `blocked` keeps
its own attribution. Where the field does carry meaning — a second
`clarification_required`, a `plan_ready` at production — the corrected state
fails the contract, the agent's value stands, and the turn is refused exactly
as before. The serialization-repair anchor gets the same treatment so a fenced
plan on a clarification turn can still enter its one repair.
The clarification continuation now also says which stage it runs at, mirroring
the production continuation's `inputStage production`, so the agent has one
sentence to follow instead of three examples that all say `request`.
Specs: a clarification turn declaring `request` / `plan_ready` with a valid
plan reaches `plan_ready` (red on main: blocked with
`od_next_protocol_stage_mismatch`); an agent-declared block with the wrong
stage keeps `od_next_agent_declared_block`; a second question round is still
refused; a fenced plan with the wrong stage still anchors a repair; the
continuation text names `inputStage clarification`.
Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
* fix(daemon): keep proxy Basic credentials off the media wait tool-token lane
The media task wait route selected the run-scoped tool-token lane on the
mere presence of an Authorization header. Tool tokens are only ever
parsed out of a Bearer header (bearerTokenFromRequest), so a reverse
proxy that authenticates browsers itself and forwards its own
`Authorization: Basic ...` could never satisfy that lane: every proxied
poll of POST /api/media/tasks/:id/wait failed 401 TOOL_TOKEN_MISSING,
so image and video generation never reported completion.
Gate the lane on the Bearer scheme. The existing intent is preserved —
once a Bearer caller does choose the lane, invalid, expired or
under-scoped credentials still must not downgrade to project
authorization; this only changes what counts as choosing it.
The other two authorizeToolRequest callers in this file,
/api/tools/media/scaffold and /api/tools/media/generate, are agent-only
endpoints that unconditionally require a tool token and are unchanged.
Same root cause as #7819, which covers the export and archive routes in
apps/daemon/src/import-export-routes.ts.
* fix(daemon): fail closed on a malformed Bearer credential
Review catch: `authorizationHeader.trim()` reduces `Authorization: Bearer `
to `Bearer`, which the `/^Bearer\s+/i` predicate rejected. That sent a
malformed Bearer credential to `authorizeProjectRequest` instead of
`authorizeToolRequest`, so a caller reaching for the tool-token lane could
reach the browser project-authority lane — before this branch, any
Authorization header produced TOOL_TOKEN_MISSING.
Classify the scheme independently of whether a token follows it, so both
`Bearer` and `Bearer <invalid>` stay on the tool-token lane and fail closed,
while Basic credentials still reach project authorization.
Regression coverage added for `Bearer ` and a bare `Bearer`; both return a
full task snapshot (HTTP 200) with the previous predicate and 401 with this
one.
* fix(daemon): keep proxy Basic credentials off the export tool-token lane
authorizeExportRead treated any Authorization header that was not the
daemon API token as a run-scoped agent tool-token call. Tool tokens are
only ever parsed out of a Bearer header (bearerTokenFromRequest), so a
reverse proxy that authenticates browsers itself and forwards its own
`Authorization: Basic ...` (Coolify native basic auth, Traefik basicAuth
without header stripping) could never satisfy that lane: every browser
export and archive request failed 401 TOOL_TOKEN_MISSING.
Gate the tool lane on the Bearer scheme so foreign Basic credentials fall
through to project authorization like a header-free browser request.
Foreign Bearer tokens still fail closed in the registry.
Covers export/{html,pptx,pdf-image,image}, the generic export route,
archive, archive/batch and the inline-HTML route, which all share the
helper.
Root-cause analysis and the Bearer-gate approach are @airano-ir's, from
the #7819 report.
Fixes#7819
Co-Authored-By: airano <244096521+airano-ir@users.noreply.github.com>
* fix(daemon): fail closed on a malformed Bearer credential
Carries the review finding from #8006 across to the export lane, which had
the same predicate. `authorizationHeader.trim()` reduces `Authorization:
Bearer ` to `Bearer`, which `/^Bearer\s+/i` rejected, downgrading a
malformed Bearer credential to browser project authority instead of failing
closed with TOOL_TOKEN_MISSING.
Classify the scheme independently of whether a token follows it. Regression
coverage added for `Bearer ` and a bare `Bearer`.
---------
Co-authored-by: airano <244096521+airano-ir@users.noreply.github.com>
* test(web): reproduce question title wrapping
* fix(web): keep question titles on one line
* fix(chat): stabilize question metadata and verify the rendered header
* fix(daemon): fail blocked OD Next runs before finalization
* test: align run fixtures with OD Next terminal status
Keep generic session and smoke fixtures on ordinary runs, and assert the blocked verdict in the strategy timing fixture.
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* test(daemon): isolate Codex resume and retry fixtures from OD Next
Select ordinary-run mode for plain-reply session and recovery fixtures, restoring the rollout override after each test.
Generated-By: looper 0.13.0 (runner=fixer, agent=codex)
* fix(daemon,web): stop a refused turn reporting a delivered deck as failed
One predicate answered two different questions, and the strict answer is the
wrong answer to the loose one.
`validateRunDeliverable` asks "did THIS run write the canonical entry". That is
the right gate for accepting an Agent's completion claim — a turn must not pass
by pointing at a file an earlier turn wrote. It is also `false` for the most
ordinary shape of an OD Next refusal: the user types 「继续」, the agent
re-checks work an earlier turn already finished, correctly rewrites nothing,
and the turn is refused over its machine block.
Both safety nets read that one answer, so both went down together. The
coordinator's completion inference declined, and `deliveredDespiteBlock` in the
web provider — the carve-out that exists to keep a delivered turn out of the
failure branch — could never fire. A field report shows the result: a red
「回复已收到,但没能记录下来」card under a finished 16-page deck the user could
see rendered in the preview pane beside it. The physical run had exited 0 and
the 2.27 MB file was on disk.
`validateProjectDeliverable` is the second question: does the user have it,
right now. Same entry resolution, same kind contract, same readability check,
minus the run-scoped gates. `GET /api/runs/:id` resolves it only for a terminal
run whose strategy task settled blocked — the one case a client has to
distinguish — and logs the answer, because withholding a failure the user
cannot act on is right and withholding it invisibly is not.
The verdict does not move: the task still blocks, the strategy contract still
refuses to complete on an unproven claim, and a turn that delivered nothing
still fails with its card. What changes is that a bookkeeping failure no longer
tells a user they lost work that is sitting in their project.
This is also what `docs/design/run-errors/error-ux-design.md` §3 already ruled
for the case: 「文件生成了但进程报错:算成功」, with the stated reason that doing
otherwise "把一堆无害报错变成失败潮".
Red spec: apps/web/tests/providers/strategy-blocked-verdict.test.ts
「does not become a failure when the project holds the deliverable」 — red on
main, green here.
* fix(daemon,web): name the gate that refused a turn, not the parser that noticed
The protocol parser raises `od_next_protocol_runtime_state_missing` before
`validateAcceptedTurn` — the function that owns the real gates — is ever
reached. So every block-less turn was filed under the parser's own code, and
`reasonCodes[0]` is what the failure card, the diagnostics export and the
analytics bucket all read.
The effect on a user who got nothing this round: they were told a marker was
missing. True of the reply, useless to them, and not the reason the turn could
not succeed — the identical failure with a DECLARED block reported
`od_next_canonical_deliverable_invalid`, the host fact they can act on. Two
names for one failure, and the less useful one was the one that shipped.
`reattributeUndeclaredTurn` already existed for exactly this, scoped to one
cell (a clarification turn that asked again). Extend it: when a block-less turn
was SHAPED like a completion the host may infer and the inference still
declined, the evidence is what refused — both helpers gate on the shape, which
just held, and the evidence. Lead with it. The parser's code stays behind it,
because `blocked_reason_codes_json` is the durable record of what the turn
actually wrote, and a triage that cannot tell "declared badly" from "declared
nothing" has lost the one distinction that decides whether a serialization
repair was ever possible.
ATTRIBUTION ONLY — no verdict moves. The two existing specs that pinned the old
name still assert the verdict they own and now assert the new attribution; the
turns they describe block exactly as before.
`od_next_canonical_deliverable_invalid` gets the card it now needs. It is
deliberately not the Runtime State copy: those say "your reply went missing",
this says "nothing came out this round", and only the second is true when the
project is empty. Retry earns its place here — the deliverable is missing
because this turn wrote nothing, and a re-run is the thing that can write it.
⚠️ The copy is engineering's, like the rows around it. `error-ux-design.md` has
no cell for it; S23 (跑完没生成文件) is the nearest and is listed there as
invisible in telemetry. Product should rewrite the wording, not the routing.
Red spec: apps/daemon/tests/strategies/od-next/coordinator.test.ts
「names the evidence that refused an undeclared completion, not the parser」 and
「gives the undeclared and declared shapes of one failure the same name」 — both
red on main, green here.
* feat(daemon): make an OD Next block countable instead of invisible
`run_finished.result` is derived from the physical run status
(`runResultFromStatus`: `succeeded → success`), and a refused OD Next turn
normally exits 0 with a complete reply on screen. So this entire class of
failure reported `result: 'success'` while the user was looking at a red card,
and nothing queryable disagreed — the reason codes lived only in the task
store's `blocked_reason_codes_json` and in a client-side error object.
`error-ux-design.md` §《线上频次与优先级》 already flags the shape of this
problem for S23/S24: 「被算成了成功 —— 看不到是坏事:线上到底有多少,我们不知
道」. OD Next made it worse than invisible by pairing the silent metric with a
visible failure card.
Carrying `reasonCodes[0]` — the same value the card keys on — makes the class
countable without changing what `result` means. Read off the run's own terminal
projection, the object the SSE `end` payload and the failure card were both
built from, so the three cannot drift. Omitted for every run whose strategy
task did not block, which is the common case.
This is the half that makes the suppression in the preceding commits safe to
ship: a failure the user is no longer shown is one we can still count.
* fix(web): do not go silent on a turn that produced no reply either
The preceding fix credits a deliverable an EARLIER turn wrote, which is right
for the case it was written for — the agent re-checks finished work and
correctly rewrites nothing — and wrong for the mirror case in #7564: a blank OD
Next response into a project that already holds a prototype. There the file is
real and the user still got nothing, so withholding the card swaps one wrong
answer for the other.
Require the turn to have actually said something before the looser field can
suppress a failure. The accumulated assistant text is already on hand in the
stream consumer, so this costs nothing and needs no new daemon field.
The stricter field keeps its existing behaviour untouched: an artifact THIS run
produced is delivery whether or not the agent narrated it.
Red spec: apps/web/tests/providers/strategy-blocked-verdict.test.ts
「still fails when the turn produced no reply at all」 — verified red with the
guard removed, green with it.
* refactor(contracts): make the OD Next block dimension a tested pure mapper
Observability that nothing exercises is observability that rots. The derivation
was inline in the run lifecycle, where the only way to reach it is to boot the
whole analytics flow — so it would have failed silently to "field absent",
which is indistinguishable from "no run ever blocked".
Move it next to `harnessAnalyticsFromRolloutDecision`, which answers the
neighbouring question about the same run and already has a suite. The edges
that matter are the ones a wrong answer would quietly mislabel: a task that has
not settled, one that settled on something other than `blocked`, and a blocked
one an older daemon projected without a reason.
---------
Co-authored-by: Cheems <216108319+hihicheems@users.noreply.github.com>
Co-authored-by: lefarcen <935902669@qq.com>
The watcher treated "I once saw this run completed" as permanent and stopped
polling the lane. GitHub re-runs a workflow IN PLACE — run_attempt increments,
status returns to in_progress, conclusion is rewritten, all under the same run
id — so the card froze on the first attempt's verdict for the rest of the watch.
Both dispatched lanes had it. Each now re-reads its run every cycle and refreshes
the paginated job list only when the run's own status/conclusion/run_attempt/
updated_at say it moved, so watching for a re-run costs no call the lane was not
already making.
Claude-Session: https://claude.ai/code/session_01PbTMU3bKHcu6cmJYy42eF3
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three follow-up suggestions under a completed turn are written by the
model, not picked from i18n files, so the only thing that decides their
language is the prompt. That prompt carried one soft rule — "write them in
the language the user is speaking" — printed directly under three English
examples, and nothing about the run's actual locale.
The locale rule lived only in `# UI locale override`, a section of the
cache-stable prompt head that `server.ts` deliberately drops on resume turns
to protect the upstream prompt cache. The keyed follow-up block is re-sent
every turn. So the rule was absent exactly on the turns that carry the
marker, and a zh-CN run closed with English suggestions — one of them a
near-copy of the prompt's own example.
Thread the run locale into `renderChatTurnHostProtocolInstructions` so the
per-turn block states its own language requirement, including the existing
brand/code/technical-identifier exception so `localStorage` and `CTA` still
survive. An English or absent locale renders byte-for-byte what it did
before. Also collapses the third copy of the zh-CN/zh-TW name mapping into
`prompts/ui-locale.ts`.
Fixes OPEND-2765
* fix(chat): render cards inside execution records
Render supported od-card payloads inside execution records with the existing
card components, instance scopes, and browser-assistance callback. Preserve
Markdown code examples and isolate card payloads from later Markdown parsing.
* test(web): await writable scope before deleting a conversation
* fix(web): buffer streamed card opener prefixes
* fix(web): remove undesigned ChatPanel cards (OPEND-2971)
Remove task-brief and rule-proposal presentation and their exclusive UI,
styles, and translations following the explicit product decision.
Preserve quoted card examples, neighboring prose, and the three retained
card kinds, including browser-assist callbacks.
* fix(chat): stop generating undesigned brief and rule cards
* test(web): use supported role query options for removed cards
* fix(chat): hide retired brand browser assistance cards
* fix(web): hide undesigned assistant message panels
* test(web): specify the brand project kind in hidden UI fixture
* style(web): remove trailing blank line after panel styles
* fix(web): hide verification scorecards in ChatPanel
* test(chat): align retired UI expectations with approved hiding
* fix(chat): keep card payloads opaque to question form parsing
* fix(web): preserve card parsing across replay metadata
Keep marker masking aligned with physical run boundaries, and replace repeated
Markdown tail scans with a forward indexed walk. Preserve actual forms and raw
code examples with replay and paragraph-boundary regression coverage.
* fix(web): keep form field syntax inside its payload
Recognize complete forms through the existing parser before scanning their
fields for card or code markers. Preserve sibling form actions and keep outer
card/code ownership with callback-based parsing that avoids an import cycle.
* fix(chat): share question form protocol context with host detection
---------
Co-authored-by: lefarcen <935902669@qq.com>
* fix(chat): wait for pending clarification before continuing todos
* fix(chat): preserve recovery for truncated clarification forms
---------
Co-authored-by: lefarcen <935902669@qq.com>
* refactor(release): extract the patch-cut pre-flight out of inline shell
Faithful port, no behavior change: resolve-patch-cut.ts computes the same
version and the same X.Y.0 gate target the shell did, and runs the same
gh release lookup. Moving it out of YAML is what makes the gate decision
testable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(release): pin the patch cut on the previous patch, not the minor base
Red against the current guard: with release/v0.22.2 cut and unshipped, the
pre-flight gates open-design-v0.22.0, finds it published, and cuts v0.22.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(release): gate the Thursday patch cut on the previous patch
The guard checked the minor base X.Y.0, so it only ever guarded a line's
first patch. Once X.Y.0 shipped, X.Y.2, X.Y.3, ... all passed no matter how
many unshipped releases had stacked up beneath them.
Gate on the release the cut actually stacks on — the highest release/vX.Y.Z
below it — which is the same target cut-release.yml uses for the Tuesday
minor, and still gives the answer the old minor-base comment wanted (cutting
0.15.1 checks open-design-v0.15.0, not the older 0.14.0).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(release): surface patch gate lookup failures
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(chat): restore persisted strategy task blocked fields in conversation history
* fix(chat): scope history task projection to its conversation
---------
Co-authored-by: lefarcen <935902669@qq.com>
* fix(chat): recover transient transcript loads within a bounded window
* fix(chat): retry transient transcript responses without a hint
* test(web): await writable scope before deleting a conversation
---------
Co-authored-by: lefarcen <935902669@qq.com>
Use a fake clock to verify the existing 10ms deadline and the final readiness
snapshot after multiple inspections. Cover a first inspection that consumes
the whole budget, where a single observation is valid.
Keep the readiness helper and application behavior unchanged.
Co-authored-by: lefarcen <935902669@qq.com>
* fix(chat): keep each folded run's own clock
A logical OD Next task runs as several physical Runs, and reopening a
conversation folds them into one turn. The fold keeps one message row, so
only Run 0's createdAt and Run N's endedAt survive and every Run boundary
in between is discarded. The renderer is then left with one clock for N
Runs, which shows up as two separate reports:
- a Run's shell with no timestamped events of its own (clarification
Runs typically have none, and the plain-stream agent family never
emits any) reaches neither the "opened this round" nor the "closed
this round" fallback, so its head is a bare "Done" with no duration;
- a thinking gap, inferred from "last timestamped event until the next
one", runs straight across the Run boundary and charges earlier Runs'
wall-clock time - including the minutes the user spent answering a
question form - to the current Run's Thoughts row, which then reports
more than the card head containing it.
Both are reload-only: strategyTaskRunIndex is served by the history
endpoint alone, so the same conversation renders correctly while live and
loses the numbers once reopened.
Restore the lost boundary rather than adjust either number. The fold
stamps each Run's own span onto the done_key it already emits, and the
renderer scopes the shell clock and the thinking gap to that Run. On the
recorded three-Run conversation the reloaded turn now reports 1m 20s /
2m 39s / 4m 42s and a 2m 57s Thoughts row - identical, to the millisecond,
to what the same Runs report live.
Also make a card's span cover the thinking row it credits, so the same
invariant holds for a second card opened mid-Run after a done marker.
OPEND-2823, OPEND-2824
* fix(chat): stop a card borrowing another run's clock
Review of #7921 caught the other half of the same defect. shellElapsed
still seeded from/to from the turn-global firstStartedAt/lastEndedAt when
a shell had no shellSpan of its own, then unioned those leftovers with the
new per-run bounds. A folded successor run that emits only status and text
- the typical clarification run, and every plain-stream agent - has no
shellSpan, so its card picked up the preceding run's tool timestamps and
reported that run's time as its own.
So the first commit fixed "a middle run shows no number" but left "the
number belongs to another run", behind a different trigger.
Drop the turn-global seed instead of clearing those clocks in closeRun().
firstStartedAt/lastEndedAt are the min/max of every timestamp in the turn,
so when this shell has no span of its own any value there was necessarily
stamped by a different shell: the fallback is, by construction, always
borrowing another card's clock and can never once be this card's own time.
Clearing at the run boundary would also only cover the cross-run trigger -
the identical borrow happens inside a single run when a second card opens
after a done marker with no timestamped events, where closeRun() never
runs - and it would silently change thinkGapStart and shellQuiet, which
read the same two clocks.
On the review's shape a card that ran for 20s reported 5m 10s; it now
reports 20s, which is what the same run reports live.
OPEND-2823, OPEND-2824
* docs(contracts): clarify missing folded-run timing boundaries
* feat(web): make chat telemetry name the run it is talking about
`setChatCorrelation()` has had no production caller since #7518. Every
`client_chat_*` event therefore shipped with an empty correlation block, and
`client_chat_interaction_latency` — the one chat observability event with real
traffic (2,694 events / 30 days) — derives its `streaming` flag from
`chatCorrelation().run_id`. With no producer the flag was false on every event
ever sent, so the panel read "every chat stall happens while the app sits
idle": a self-consistent, wrong story, and the reason the metric was worse
than no metric.
Wire the four producers that already had their values in hand:
- run id + agent id, opened at every run start (`streamViaDaemon`, the next
physical run of a strategy-task chain, and `reattachDaemonRun` — the path a
page refresh takes back onto a run still in flight) and closed in the same
`finally` that settles the stuck-run watchdog. Named helpers, because the
pair is load-bearing: only an opener and `streaming` is stuck true forever;
only a closer and it is stuck false.
- conversation id + project id, once `listMessages` resolves.
- release channel, off `/api/version` — the daemon has always answered with
`channel` and the web read `version` and dropped the rest.
- replay session id, handed over from the posthog-js instance inside
`loaded()`. This is the only possible source: the ESM build never publishes
itself as `window.posthog`, so the global-lookup fallback returns undefined
forever while looking implemented.
No new events. This only makes the traffic already being sent tell the truth.
`model_id` stays absent — it is not in scope at either run-start site, and a
guess would be worse than the gap.
* feat(analytics): record which failure sentence the user actually read
`run_failed_toast` says a user was shown a failure card and names the daemon's
`error_code`. It cannot say what the user READ — those two facts are separated
by a mapping table, and `resolveRunErrorCardDescription` states in as many
words that the table can always be one row short. When it is, the card falls
back to a blank "the task failed" with no diagnosis. That is the worst
impression this surface produces, and it was the one we could not count.
Add two fields to the exposure:
- `message_key` — the i18n key of the mapped copy, or `generic_fallback` when
the table had no line for it. Always present, never omitted: the fallback
RATE is the number that matters, and a rate needs a denominator, so an
omitted key would drop the fallback impressions out of the very count meant
to measure them.
- `failure_category` — the daemon's own classification off the run's error
event, defaulting to the enum's existing `unknown` member rather than
vanishing, so this event stays joinable against `run_finished` and
`run_recovery_action`.
`message_key` is typed as `string`, not the web's `RunFailureMessageKey`
union: that union lives in apps/web and grows with every copy change, and
pinning it in contracts would make adding a sentence a contracts change.
* feat(web): attach the chat-health monitor to the chat log
`chat-health.ts` has answered four questions since #7518 — how long until the
user can read the conversation, is the DOM growing without bound, who is about
to OOM, how janky is the UI while a run streams — and nothing but its own unit
spec has ever called it. All four tiles were empty in production.
Open a surface on the `.chat-log` element while the Chat tab is showing it,
and tear it down when it goes away. Two effect dependencies, each guarding a
real failure:
- `tab`, because the log is conditionally rendered and `logRef.current` is
null on the Comments tab — without it, coming back to Chat never reattaches.
- `activeConversationId`, because that div carries no conversation key, so
React reuses the very same DOM node across a switch. There is no node-level
signal that the surface should be replaced; the id is the only one. The
spec pins this by asserting node identity is unchanged AND that a second
surface opened anyway.
`markFirstPaint` lands together with `setStreamEventCount`, deliberately: a
first-paint duration on its own cannot be attributed, because a slow open is
either "many messages" or "one message with hundreds of tool events behind
it", and only the event count separates them. Rendered-row count uses the same
`:scope > *` definition `client_chat_dom_growth` already uses, so the two
events are comparable.
Run start / end feed the jank window from the daemon provider, which is where
the authoritative run lifecycle lives. Both no-op when no chat surface is
mounted.
Also extracts `isChatVirtualized()` so the renderer and the telemetry read the
same predicate. Two copies of `items.length > THRESHOLD` are identical today
and drift the first time the rule grows a second term — at which point the
telemetry would describe a render mode the renderer no longer uses, silently.
`markChatOpenIntent` is NOT wired here. Hanging it off the two conversation
handlers would only cover `conversation_switch`; `project_switch` has 10+
navigate sites in App.tsx and `cold_boot` passes through no handler at all.
The right home is `router.ts`, which is the single navigation entry point and
can derive the open kind from the route transition. That is its own change.
Until then `open_kind` honestly reads `remount` rather than a fabricated
`cold_boot`.
* fix(web): stop the wiring from reintroducing the bug it removes
Three review findings, all real, all the same family: an event that cannot
name what it measured.
1. `client_chat_stream_health` was going out with an empty `run_id`.
`chatSurfaceRunEnded()` does not merely bookkeep — it FLUSHES the jank
window, and the flush spreads `chatCorrelation()`. The terminal path
cleared the correlation one line earlier, so the one event this PR newly
enables shipped anonymous: the exact defect the PR exists to remove,
reintroduced by the fix for it.
One rule now covers both ends of a run: the chat-health call goes FIRST,
because it is the one that can emit; the correlation mutation goes second.
Applied symmetrically at the three run-start sites too — `runStarted()`
flushes any window a previous run left open (its terminal event never
arrived), and that flush belongs to the OLD run, so it has to happen before
the block is repointed. `trackRunTerminal` is unaffected either way; it
carries its own context and never reads this block.
2. A surface opened mid-run did not know a run was running. Surfaces are
recreated on every conversation switch and every return to the Chat tab,
but `chatSurfaceRunStarted` only ever reached the surface that existed when
the provider fired it. The new surface kept its window shut and dropped
every long task until the next run start — precisely the stretch a user is
watching, because they came back to the tab to see something generate.
`openChatSurface` now adopts the in-flight run. It reads it from the
correlation context rather than keeping a second flag: `chat-interaction.ts`
already derives its `streaming` breakdown the same way, and one source
cannot disagree with itself. No double counting — `runCount` and the window
clock are per-surface, and adoption only ever runs against the surface
constructed a line above it. The spec pins this with three long tasks split
across a mid-run swap and asserts they add up to three, not four.
3. `conversation_id` / `project_id` moved from ProjectView to the ChatPane
boundary. ChatPane has three hosts — ProjectView, DesignSystemFlow and
workspace/SideChatTab — feeding one set of observability modules and one
dashboard. Setting it in a single host left the other two emitting orphan
rows indistinguishable from genuinely un-correlated ones. Both already pass
the ids in as props. The effect is declared ahead of the surface-open effect
so the opening `conversation_open` sample is already stamped.
* fix(chat): make the run-failure card's retry tell the truth about the send gate
Three reports that all land on the same gate in ProjectView:
`currentConversationActionDisabled`, which every recovery handler consults
and no button was ever wired to.
OPEND-2821 — the retry button had no `disabled` at all, so it always looked
live. Clicking it reported a `manual_retry` recovery click and then
`handleRetry` returned. Split that boolean into a four-way reason
(read-only / messages-unavailable / billing-unresolved / conversation-busy),
disable the card's recovery actions when it is set, and say which one it is.
The split is a partition of the same predicate, proven exhaustively.
OPEND-2758 — `handleSend` paints the new turn before the preflight and before
POST /api/runs (OPEND-2614, deliberate), and that paint makes
`retryableAssistantMessage` return null, so the card vanished 1-2s before
anything was confirmed. Announce the pending retry, pin the card on the
message being retried, lock the button and label it "Retrying", and drop the
announcement only when the replacement message gets its run id -- or when the
send never started, which restores the original card and its actions.
OPEND-2719 — an exhausted wallet parked the send in the pending-send queue.
The queue means "this runs once capacity frees up"; an empty wallet never
frees up, so the message sat there forever. Stop queueing that one branch
(`signed_out` keeps its queue-and-resume path) and hand the draft back to the
composer through the existing `restore-draft` outcome. The report's other
half did not reproduce: the dialog and balance card were already re-firing on
every blocked send.
Note on telemetry: a blocked click no longer counts as a recovery click. It
never produced a run to join against, so those were unjoinable orphans; the
existing surface_view still records that the action was offered.
* fix(chat): claim the composer draft positively instead of by exclusion
Review on #7927: the no-queue path for an exhausted wallet used an
exclusion predicate — "not a retry and not a queue drain, therefore the
composer owns it". ProjectView has eleven other direct `handleSend`
callers (share to community, design-system feedback and review decisions,
continue remaining tasks, resume run, question-form answers, Home
auto-send, resend-failed-user-message, brand extraction and enrichment,
the auto-audit repair effect, Antigravity OAuth repair). None of them
holds its payload in the composer, and all of them were being swept onto
that path — which silently took away their queue. The question-form
answer is the worst case: it keeps the only copy of the answer and its
uploads, and releases the form only on a durable-queue acknowledgement.
Replace it with an explicit `composerOwnedDraft` marker that only
`handleComposerSend` sets. Everything else falls through to the queueing
path it had before this branch, unchanged. The marker is transport-only
and is stripped alongside `acceptDurableQueue` before queue persistence:
once a send is parked, the queue item owns the payload, not the composer.