feat(web/console): chat workflow-completion card + SSE-lock + jump-to-bottom#1885
Conversation
…-bottom
Three changes that make the console's project-scoped chat a credible /chat
replacement — all web-side; the data already flowed and was being dropped.
Completion card: a chat-launched workflow's `workflow_result` message (summary +
{workflowName, runId}) was swallowed because ChatStream filters the whole
`workflow_` category prefix and toMessage never parsed `workflowResult`. Now
message.ts parses `workflowResult` (mirroring the `dispatch` parse, both fields
guarded), and ChatStream lets `workflow_result` through the filter — without
un-suppressing the other workflow_* narration — and renders a new
ConsoleWorkflowResultCard: status icon + node counts + duration + cost +
"Open run →" + the summary body. The card fetches authoritative state via
skill.getRun (same call RunDetailPage uses) and degrades to the summary alone
if the run can't be loaded.
SSE-lock: the composer stayed disabled ~6s (SETTLE_MS) after each reply because
the SSE lock event wasn't wired. ChatPage now passes a useCallback-stable
onLockChange to useConversationSSE that clears the settle timer + setBusy(false)
on conversation_lock:false. The settle timer remains the correctness fallback
when SSE is absent. (Stable callback is required — the hook's effect depends on
it, so an inline lambda would reconnect the EventSource every render.)
Jump-to-bottom: an `atBottom` state (from an onScroll handler) shows a floating
button when scrolled up; kept in sync with the existing auto-scroll lastBottomRef.
Tests: message.test.ts covers the workflowResult parse (well-formed, malformed→null,
dispatch regression, isSystemCategory). 36 → 42 console tests.
📝 WalkthroughWalkthroughAdds workflow result metadata to messages, a ConsoleWorkflowResultCard that loads authoritative run state and node counts, integrates rendering into ChatStream (allowing workflow_result passthrough), adds countTerminalNodes with tests, and improves ChatPage SSE lock handling and near-bottom scrolling with a jump-to-bottom button. ChangesWorkflow Result Display in Console Chat
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Caution Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted. Error details |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/web/src/experiments/console/routes/ChatPage.tsx`:
- Around line 83-91: When handling an unlock inside onLockChange (the callback
passed to useConversationSSE), ensure you trigger a final messages refresh
before clearing busy and stopping the settle timer: after clearing
settleTimerRef (if present) call the existing cache invalidation/fetch for the
conversation messages (the K.messages(activeConvId) invalidation or the
queryClient/invalidateQueries equivalent) so the assistant reply is fetched,
then setBusy(false); retain the early return for locked true and keep the
settleTimerRef clearing logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 07e1a29e-176c-4e43-afcd-8b42e3108ab4
📒 Files selected for processing (5)
packages/web/src/experiments/console/components/ChatStream.tsxpackages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsxpackages/web/src/experiments/console/primitives/message.test.tspackages/web/src/experiments/console/primitives/message.tspackages/web/src/experiments/console/routes/ChatPage.tsx
| const onLockChange = useCallback((locked: boolean): void => { | ||
| if (locked) return; | ||
| if (settleTimerRef.current !== null) { | ||
| clearTimeout(settleTimerRef.current); | ||
| settleTimerRef.current = null; | ||
| } | ||
| setBusy(false); | ||
| }, []); | ||
| useConversationSSE(activeConvId, onLockChange); |
There was a problem hiding this comment.
Force a final message refresh before clearing busy on unlock.
Line 89 stops the recovery poll, but this path never invalidates K.messages(activeConvId). In the exact burst-drop scenario called out on Lines 18-20, a surviving conversation_lock:false can land without the final text/tool invalidate, leaving the trailing user message cached and the assistant reply never fetched.
Suggested fix
- const onLockChange = useCallback((locked: boolean): void => {
+ const onLockChange = useCallback((locked: boolean): void => {
if (locked) return;
+ if (activeConvId !== null) {
+ invalidate(K.messages(activeConvId));
+ }
if (settleTimerRef.current !== null) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
setBusy(false);
- }, []);
+ }, [activeConvId]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/web/src/experiments/console/routes/ChatPage.tsx` around lines 83 -
91, When handling an unlock inside onLockChange (the callback passed to
useConversationSSE), ensure you trigger a final messages refresh before clearing
busy and stopping the settle timer: after clearing settleTimerRef (if present)
call the existing cache invalidation/fetch for the conversation messages (the
K.messages(activeConvId) invalidation or the queryClient/invalidateQueries
equivalent) so the assistant reply is fetched, then setBusy(false); retain the
early return for locked true and keep the settleTimerRef clearing logic.
PR Review Summary — multi-agent review (7 agents)Full panel ran: code, docs, tests, comments, errors, types, simplify. I verified the headline finding directly against Critical Issues (1) — blocks merge
Important Issues (3)
Suggestions (6)
Strengths
DocumentationNone needed — purely intra-experiment; CLAUDE.md and Verdict: NEEDS FIXES (1 critical)CI is green and mergeable, but C1 makes the PR's primary feature non-functional — the completion card only ever shows the summary text, never the status/counts/duration/cost/link it exists to add. This is exactly the class of bug green CI can't catch (valid types, no DOM test, no browser smoke). Fix C1 before merge; I1 (latent crash) and I3 (observability) are cheap and worth folding in. Recommended actions
🤖 Multi-agent review via Claude Code |
…rdening
C1 (critical): the completion card never rendered its rich content. useEntity
returns `error: Error | undefined` (cache.ts), so `error !== null` was always
true and `run` was always null → the summary-only fallback every time. Compare
against `undefined`. Verified end-to-end against a real completed run:
GET /api/workflows/runs/:id returns {run, events}, so the rich path now executes
(16/22 nodes computed on that run).
I1: guard meta.workflowResult with `!= null` (not `!== undefined`) so an explicit
JSON null can't reach `typeof wr.workflowName` and throw. +regression test.
I2: countTerminalNodes deduped by nodeId — a resumed run reuses one run id and
emits both the original `completed` and a later node_skipped_prior_success, which
double-counted nodes. Relocated to primitives/event.ts (pure RunEvent reducer,
its natural home) and unit-tested (6 cases incl. the dedup guard).
I3: console.warn on the two silent swallows (getRun error path, parseMetadata
catch) — the PR's whole point is to stop silently dropping completions.
S1: dropped the dead `| null` from the useEntity generic (getRun never resolves
to null; undefined is the cache-miss signal).
S3: extracted NEAR_BOTTOM_PX for the duplicated 120px scroll threshold.
S5/S6: corrected the card JSDoc (loading also degrades to summary) and documented
why ParsedMetadata.workflowResult stays inline/untrusted.
Deferred — S2 (overeng): kept RESULT_GLYPH/RESULT_LABEL as Partial<Record> plus a
clarifying comment rather than padding them to a total record with running/paused
entries a completion card never displays.
Validation: web type-check / lint / format:check clean; 51 console tests pass.
Review fixes applied —
|
| # | Finding | Fix |
|---|---|---|
| C1 | card never rendered (error !== null vs Error | undefined → always summary-only) |
compare against undefined. Verified end-to-end: GET /api/workflows/runs/:id returns {run, events} on a real completed run → the rich path executes (16/22 nodes computed) |
| I1 | TypeError on explicit workflowResult: null |
guard with != null + regression test |
| I2 | countTerminalNodes double-counts resumed runs |
dedup by nodeId; relocated to primitives/event.ts as a pure reducer + 6 unit tests |
| I3 | two silent swallows (getRun error, parseMetadata catch) |
console.warn on both |
| S1 | dead | null in the useEntity generic |
dropped |
| S3 | duplicated 120 scroll threshold |
NEAR_BOTTOM_PX const |
| S5/S6 | card JSDoc omitted loading; inline ParsedMetadata undocumented |
reworded / commented |
Deferred — S2 (over-engineering): kept RESULT_GLYPH/RESULT_LABEL as Partial<Record> + a clarifying comment instead of padding to a total record with running/paused entries a completion card never shows.
Not done — pixel screenshot of the card in chat: the 88 existing workflow_result messages all live in CLI conversations, which the console chat (web-only) never surfaces — so a visual smoke needs a live web-chat workflow launch or DB seeding, disproportionate for a spike. The C1 fix is verified at the data layer (the fetch resolves, run is non-null, counts compute) and the one tricky helper is unit-tested.
Local: web type-check / lint / format:check clean; 51 console tests pass.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
packages/web/src/experiments/console/routes/ChatPage.tsx (1)
86-93:⚠️ Potential issue | 🟠 Major | ⚡ Quick winKeep one last message refresh on unlock.
The SSE hook only invalidates
K.messages(...)ontext/tool_*events. If that final burst is the one that gets dropped, Line 92 clearsbusyand tears down the recovery poll before the assistant reply is ever refetched, so the chat can stay stuck on the trailing user message. Re-invalidate the active conversation before unlocking the composer.Suggested fix
- const onLockChange = useCallback((locked: boolean): void => { + const onLockChange = useCallback((locked: boolean): void => { if (locked) return; if (settleTimerRef.current !== null) { clearTimeout(settleTimerRef.current); settleTimerRef.current = null; } + if (activeConvId !== null) { + invalidate(K.messages(activeConvId)); + } setBusy(false); - }, []); + }, [activeConvId]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/web/src/experiments/console/routes/ChatPage.tsx` around lines 86 - 93, The unlock path in onLockChange currently clears settleTimerRef and calls setBusy(false) before giving the SSE-driven refetch a last chance, which can leave the assistant reply un-fetched; before clearing the timer / calling setBusy(false), invoke the cache invalidation for the active conversation (e.g. call queryClient.invalidateQueries for K.messages(...) using the current active conversation id) so the final assistant reply is re-requested, then proceed to clear settleTimerRef and setBusy(false).
🧹 Nitpick comments (2)
packages/web/src/experiments/console/primitives/event.test.ts (1)
2-2: ⚡ Quick winAdd an explicit return type to the
nodehelper.Annotating it as
RunEventkeeps this test helper aligned with strict TS function-annotation guidance and prevents accidental widening in future edits.Patch suggestion
-import { toRunEvent, countTerminalNodes } from './event'; +import { toRunEvent, countTerminalNodes, type RunEvent } from './event'; @@ - const node = (nodeId: string | null, eventType: string) => + const node = (nodeId: string | null, eventType: string): RunEvent => toRunEvent(raw({ event_type: eventType, step_name: nodeId }));As per coding guidelines: "Enforce strict TypeScript configuration with complete type annotations on all functions; no
anytypes without explicit justification."Also applies to: 243-244
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/web/src/experiments/console/primitives/event.test.ts` at line 2, The test helper function named node should be given an explicit return type of RunEvent to satisfy strict TS annotations; update the node helper signature in event.test.ts to annotate its return type as RunEvent, import RunEvent from its module if not already imported, and apply the same change to the other helper occurrence mentioned (around lines referenced) so both helpers return RunEvent explicitly.packages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsx (1)
54-60: ⚡ Quick winUse the project logger instead of
console.warnfor this fetch-failure breadcrumb.Route this through the structured Pino logger (event-named) rather than raw console output so observability is consistent and error text handling stays controlled.
As per coding guidelines: "Use Pino logger with structured logging event naming..." and "Never log API keys, tokens ... or PII."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsx` around lines 54 - 60, Replace the raw console.warn in ConsoleWorkflowResultCard's useEffect with the project's Pino structured logger (use the module's logger instance, e.g., logger.warn or the standard project logger), emit an event-named message like "console.workflow.result.getRunFailed" and include runId and a non-sensitive error.message in the structured fields; ensure you do not log any tokens/PII and keep the payload minimal (error.message and runId) to meet observability guidelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/web/src/experiments/console/routes/ChatPage.tsx`:
- Around line 178-182: The effect that measures the scroll DOM (useEffect using
scrollRef and lastBottomRef) should also initialize the atBottom state so it
reflects the same measurement on mount; update atBottom (the state variable
managed where handleScroll currently sets it) inside that useEffect using the
same condition (el.scrollHeight - el.scrollTop - el.clientHeight <
NEAR_BOTTOM_PX) that is assigned to lastBottomRef.current so both lastBottomRef
and atBottom are synced on initial render (and repeat the same change in the
second effect mentioned around handleScroll usage).
---
Duplicate comments:
In `@packages/web/src/experiments/console/routes/ChatPage.tsx`:
- Around line 86-93: The unlock path in onLockChange currently clears
settleTimerRef and calls setBusy(false) before giving the SSE-driven refetch a
last chance, which can leave the assistant reply un-fetched; before clearing the
timer / calling setBusy(false), invoke the cache invalidation for the active
conversation (e.g. call queryClient.invalidateQueries for K.messages(...) using
the current active conversation id) so the final assistant reply is
re-requested, then proceed to clear settleTimerRef and setBusy(false).
---
Nitpick comments:
In
`@packages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsx`:
- Around line 54-60: Replace the raw console.warn in ConsoleWorkflowResultCard's
useEffect with the project's Pino structured logger (use the module's logger
instance, e.g., logger.warn or the standard project logger), emit an event-named
message like "console.workflow.result.getRunFailed" and include runId and a
non-sensitive error.message in the structured fields; ensure you do not log any
tokens/PII and keep the payload minimal (error.message and runId) to meet
observability guidelines.
In `@packages/web/src/experiments/console/primitives/event.test.ts`:
- Line 2: The test helper function named node should be given an explicit return
type of RunEvent to satisfy strict TS annotations; update the node helper
signature in event.test.ts to annotate its return type as RunEvent, import
RunEvent from its module if not already imported, and apply the same change to
the other helper occurrence mentioned (around lines referenced) so both helpers
return RunEvent explicitly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 286d6714-d0c9-4cac-a92b-fce83e6f5d82
📒 Files selected for processing (6)
packages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsxpackages/web/src/experiments/console/primitives/event.test.tspackages/web/src/experiments/console/primitives/event.tspackages/web/src/experiments/console/primitives/message.test.tspackages/web/src/experiments/console/primitives/message.tspackages/web/src/experiments/console/routes/ChatPage.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/web/src/experiments/console/primitives/message.ts
| useEffect(() => { | ||
| const el = scrollRef.current; | ||
| if (el === null) return; | ||
| lastBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 120; | ||
| lastBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX; | ||
| }); |
There was a problem hiding this comment.
Initialize atBottom from the same DOM measurement.
Line 181 already detects that a newly opened long conversation is away from the bottom, but atBottom stays true until the user manually scrolls. That hides the new jump-to-bottom button exactly when the page first loads above the latest message. Sync both lastBottomRef and atBottom from the same measurement instead of updating the state only in handleScroll.
Suggested fix
+ const syncBottomState = useCallback((): void => {
+ const el = scrollRef.current;
+ if (el === null) return;
+ const near = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
+ lastBottomRef.current = near;
+ setAtBottom(near);
+ }, []);
+
const scrollRef = useRef<HTMLDivElement | null>(null);
const lastBottomRef = useRef(true);
useEffect(() => {
- const el = scrollRef.current;
- if (el === null) return;
- lastBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
- });
+ syncBottomState();
+ }, [messages?.length, syncBottomState]);
@@
- const handleScroll = useCallback((): void => {
- const el = scrollRef.current;
- if (el === null) return;
- const near = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
- lastBottomRef.current = near;
- setAtBottom(near);
- }, []);
+ const handleScroll = useCallback((): void => {
+ syncBottomState();
+ }, [syncBottomState]);Also applies to: 191-198
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/web/src/experiments/console/routes/ChatPage.tsx` around lines 178 -
182, The effect that measures the scroll DOM (useEffect using scrollRef and
lastBottomRef) should also initialize the atBottom state so it reflects the same
measurement on mount; update atBottom (the state variable managed where
handleScroll currently sets it) inside that useEffect using the same condition
(el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX) that is
assigned to lastBottomRef.current so both lastBottomRef and atBottom are synced
on initial render (and repeat the same change in the second effect mentioned
around handleScroll usage).
…1890) * feat(web/console): settings core — assistant config + system panel Adds the console's first settings surface (/console/settings, global) — the parity floor before cutover. PR #4 of the console sequence (after #1878/#1881/#1885). - skills/settings.ts: getConfig/updateAssistantConfig/getHealth/getUpdateCheck plus the pure buildAssistantUpdate(form) transform (8 unit tests). skills/providers.ts: listProviders. Types from @/lib/api.generated (console isolation boundary). - store/keys.ts: config/health/providers/updateCheck keys (health reuses the literal 'health' so it shares lib/health's cache entry). - lib/health.ts: full HealthResponse + useHealth(); useIsDocker derives from it. - AssistantConfigPanel: default-assistant picker (registered providers) + free-text model per provider + codex reasoning/web-search; dirty-gated Save → PATCH /api/config/assistants → ~/.archon/config.yaml → invalidate(K.config) re-seeds. Model is free-text for every provider (Archon does not validate model strings). - SystemPanel: status/adapter/db/version, concurrency (active/maxConcurrent, coerced defensively — concurrency is an open record), running workflows, platform badges, update-check. - ConsoleApp: /console/settings route, gear header link, ',' global keybinding; shortcuts.ts catalogue entry. Honors the error-is-undefined cache contract throughout (the #1885 gotcha). Excludes the GitHub device-flow panel (PR #5) and env-var editing (project-scoped). Validation: web type-check / lint / format:check clean; 59 console tests pass (8 new). Verified end-to-end on an isolated server: read APIs return the expected shapes, save round-trips to config.yaml (binary paths preserved via the server deep-merge), and a browser smoke of /console/settings renders both panels (5 providers, codex effort/web-search, system grid, Save dirty-gated). * fix(web/console): address PR #1890 review — comments + update-check silent failure I1: correct the buildAssistantUpdate JSDoc. Verified the PATCH route does NOT safe-filter per field on the write path — it validates only provider ids and merges the body into config.yaml unfiltered (safe-filtering is read-path only). The real invariant is that this function only ever attaches codex-only fields to the codex entry; the comment now says that instead of the false "server safe-filters" claim. I2: rewrite the K.health note. This PR routes lib/health through K.health, so the old "lib/health already caches under this literal" premise is stale; the invariant is that both consumers read via useHealth() to share one cache entry. I3 (silent failure): SystemPanel showed "checking…" forever on a failed update-check (the error was destructured away). Surface updateError via an UpdateStatus helper → "update check unavailable". S1: SettingsSection children typed ReactNode (ReactElement|ReactElement[] fought the `cond && <el/>` pattern). S5: replaced the nested update-status ternary with the UpdateStatus helper + early returns for the health loading/error states. S6: extracted the shared SettingsSection card shell (PR #5 is the 3rd consumer), a SELECT_CLASS const for the two codex selects, and bound activePlatforms once. Docs: added /console/settings to the console README routes. Deferred (with rationale): - S2 (re-seed on providers identity): latent only — nothing invalidates K.providers, and a ref-snapshot "fix" introduces a config-vs-providers load-order race. Keep the simple [config, providers] effect. - S3 (literal-union effort/webSearch types): kept bare string so seedForm tolerates an out-of-enum value in config.yaml; the <select> is the write-side enforcement. - S4 (show both load errors): an error panel showing the first error is acceptable. Validation: web type-check / lint / format:check clean; 59 console tests pass.
Summary
/chat: (1) a chat-launched workflow's completion was invisible — itsworkflow_resultmessage (summary +{workflowName, runId}) was swallowed becauseChatStreamfilters the wholeworkflow_category prefix andtoMessagenever parsedworkflowResult; (2) the composer stayed disabled ~6s (SETTLE_MS) after each reply because the SSE lock event wasn't wired; (3) no jump-to-bottom./chatparity — Theme A of the console-replaces-old-UI sequence.workflowResult; render a newConsoleWorkflowResultCard; wireonLockChange→busy; add a jump-to-bottom button.workflow_resultmessage, its payload, and the lock events all already exist server-side. No un-suppressing theworkflow_prefix (otherworkflow_*narration stays hidden). NoMessageItemchange. No streaming-cursor.UX Journey
Before
After
Architecture Diagram
Connection inventory:
toMessageMessage.workflowResultChatStreamConsoleWorkflowResultCardcategory==='workflow_result'before the filterConsoleWorkflowResultCardskill.getRunuseEntity(K.run)), not react-queryChatPageuseConversationSSEonLockChangeLabel Snapshot
risk: lowsize: Mwebweb:consoleChange Metadata
featurewebLinked Issue
Validation Evidence (required)
Security Impact (required)
GET /api/workflows/runs/:id+ the SSE stream)Compatibility / Migration
Human Verification (required)
onLockChangeisuseCallback-stable (no EventSource reconnect loop; the hook's effect deps on it), and the card degrades to the summary prose ongetRunloading/error (never a broken card).workflowResult: null(hard-failure path) → falls through toMessageItem; malformedworkflowResult→null(tested); empty summary → body hidden; SSE-absent → settle timer still frees the composer.@archon/webhas no jsdom/testing-library — consistent with fix(web/console): gate /console behind auth + correct run-event normalizer #1878/feat(web/console): show run provenance — input message + platform icons #1881). Manual smoke recommended: launch a workflow from console chat and confirm the card + the instant composer-free.Side Effects / Blast Radius (required)
onLockChangeweren't stable — guarded byuseCallback([])+ a no-reconnect acceptance check. Lettingworkflow_resultpast the filter is an explicit=== 'workflow_result'OR;isSystemCategoryitself is untouched, so otherworkflow_*stay hidden.Rollback Plan (required)
git revert <merge-commit>— 5 files, web-only.Risks and Mitigations
onLockChangereconnects SSE each render. Mitigation:useCallback([])(refs + stable setter only).getRunfailure → broken card. Mitigation: loading/error path renders the summary alone.Summary by CodeRabbit
New Features
Bug Fixes
Tests