Skip to content

feat(web/console): chat workflow-completion card + SSE-lock + jump-to-bottom#1885

Merged
Wirasm merged 2 commits into
devfrom
feat/console-completion-card
Jun 5, 2026
Merged

feat(web/console): chat workflow-completion card + SSE-lock + jump-to-bottom#1885
Wirasm merged 2 commits into
devfrom
feat/console-completion-card

Conversation

@Wirasm

@Wirasm Wirasm commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Problem: Three gaps kept the console's project-scoped chat from replacing the old /chat: (1) a chat-launched workflow's completion was invisible — its workflow_result message (summary + {workflowName, runId}) was swallowed because ChatStream filters the whole workflow_ category prefix and toMessage never parsed workflowResult; (2) the composer stayed disabled ~6s (SETTLE_MS) after each reply because the SSE lock event wasn't wired; (3) no jump-to-bottom.
  • Why it matters: Console-vs-/chat parity — Theme A of the console-replaces-old-UI sequence.
  • What changed: Parse workflowResult; render a new ConsoleWorkflowResultCard; wire onLockChangebusy; add a jump-to-bottom button.
  • What did NOT change (scope boundary): No server change — the workflow_result message, its payload, and the lock events all already exist server-side. No un-suppressing the workflow_ prefix (other workflow_* narration stays hidden). No MessageItem change. No streaming-cursor.

UX Journey

Before

chat: launch a workflow → it completes → …nothing renders… (workflow_result swallowed)
composer: disabled ~6s after every reply (SETTLE_MS), even though the server already released the lock

After

┌─────────────────────────────────────────────────────────┐
│ ✓ Workflow complete: e2e-deterministic  7/8 nodes  18s  │  ◄── ConsoleWorkflowResultCard
│   summarised result…                          Open run →│
└─────────────────────────────────────────────────────────┘
composer: frees the instant conversation_lock:false arrives (settle timer = fallback)
↓ Jump to bottom — appears when scrolled up

Architecture Diagram

orchestrator workflow_result (already emitted+persisted+SSE'd)
  → toMessage() NOW parses workflowResult → Message.workflowResult
      → ChatStream: lets workflow_result through the filter → ConsoleWorkflowResultCard
          → skill.getRun(runId) (same call as RunDetailPage) → status/counts/duration/cost
useConversationSSE(activeConvId, onLockChange)  ← NEW 2nd arg (stable)
  → conversation_lock:false → clear settle timer + setBusy(false)

Connection inventory:

From To Status Notes
toMessage Message.workflowResult new parse of an already-persisted field
ChatStream ConsoleWorkflowResultCard new branch on category==='workflow_result' before the filter
ConsoleWorkflowResultCard skill.getRun new console cache (useEntity(K.run)), not react-query
ChatPage useConversationSSE onLockChange new stable callback → busy

Label Snapshot

  • Risk: risk: low
  • Size: size: M
  • Scope: web
  • Module: web:console

Change Metadata

  • Change type: feature
  • Primary scope: web

Linked Issue

Validation Evidence (required)

bun --filter @archon/web type-check   # exit 0
bun run lint                          # exit 0 (--max-warnings 0)
bun run format:check                  # clean
bun run validate                      # exit 0 — all 7 CI checks incl. full suite
# console tests: 42 pass / 0 fail (6 new in message.test.ts)

Security Impact (required)

  • New permissions/capabilities? No
  • New external network calls? No (reuses existing GET /api/workflows/runs/:id + the SSE stream)
  • Secrets/tokens handling changed? No
  • File system access scope changed? No

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Database migration needed? No

Human Verification (required)

  • Verified: type-check/lint/format/validate green; 42 console tests pass. The two plan gotchas were handled explicitly — onLockChange is useCallback-stable (no EventSource reconnect loop; the hook's effect deps on it), and the card degrades to the summary prose on getRun loading/error (never a broken card).
  • Edge cases covered by code/guards: workflowResult: null (hard-failure path) → falls through to MessageItem; malformed workflowResultnull (tested); empty summary → body hidden; SSE-absent → settle timer still frees the composer.
  • Not verified: live browser screenshot (presentational; @archon/web has 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)

  • Affected: console chat rendering + the message primitive + ChatPage turn/scroll state (web only).
  • Potential unintended effects: a stray EventSource reconnect if onLockChange weren't stable — guarded by useCallback([]) + a no-reconnect acceptance check. Letting workflow_result past the filter is an explicit === 'workflow_result' OR; isSystemCategory itself is untouched, so other workflow_* stay hidden.
  • Guardrails: console tests run in CI.

Rollback Plan (required)

  • Fast rollback: git revert <merge-commit> — 5 files, web-only.
  • Feature flags/toggles: none.
  • Observable symptoms: completion cards stop rendering / composer reverts to the 6s settle delay.

Risks and Mitigations

  • Risk: inline onLockChange reconnects SSE each render. Mitigation: useCallback([]) (refs + stable setter only).
  • Risk: getRun failure → broken card. Mitigation: loading/error path renders the summary alone.

Summary by CodeRabbit

  • New Features

    • Chat now surfaces workflow run results inline with status, node counts, duration, optional cost, summary, and a link to open the run.
    • “Jump to bottom” button improves navigation when scrolled up.
  • Bug Fixes

    • Improved conversation lock handling and more accurate busy/turn-complete behavior for real-time chat.
  • Tests

    • Expanded parsing and event tests for workflow result handling, malformed metadata, and node-counting robustness.

…-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.
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds 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.

Changes

Workflow Result Display in Console Chat

Layer / File(s) Summary
Message contract and workflow result metadata
packages/web/src/experiments/console/primitives/message.ts
Introduces WorkflowResultMeta and adds workflowResult: WorkflowResultMeta | null to Message; augments ParsedMetadata, logs JSON parse warnings, and derives workflowResult in toMessage only when workflowName and runId are valid strings.
Message parsing validation for workflow results
packages/web/src/experiments/console/primitives/message.test.ts
Tests toMessage parsing for workflow_result, explicit nulls, malformed metadata, dispatch regression, and isSystemCategory classification.
ConsoleWorkflowResultCard component
packages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsx
New React component fetching run via useEntity/skill.getRun, computing terminal node counts, status glyph/label, duration, optional cost badge, rendering header and optional body, and falling back to summary-only when run unavailable.
ChatStream rendering of workflow result cards
packages/web/src/experiments/console/components/ChatStream.tsx
Imports ConsoleWorkflowResultCard and updates message filtering/rendering to allow workflow_result messages through default visibility and render them as result cards when workflowResult is present; preserves existing tool-call suppression unless showTools is enabled.
Node counting utility and tests
packages/web/src/experiments/console/primitives/event.ts, packages/web/src/experiments/console/primitives/event.test.ts
Adds countTerminalNodes(events) to compute { completed, total } per run without double-counting resumed nodes; adds tests covering in-flight exclusion, mixed outcomes, deduplication, unrelated events, and null nodeId handling.
ChatPage SSE lock handling and jump-to-bottom scrolling
packages/web/src/experiments/console/routes/ChatPage.tsx
Wires onLockChange into useConversationSSE to clear busy on unlock; refactors settle timer/signature refs; adds NEAR_BOTTOM_PX, atBottom, handleScroll, scrollToBottom, and a conditional "Jump to bottom" button; updates scroll container markup with onScroll and relative positioning.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • coleam00/Archon#1819: Also modifies ChatStream.tsx message rendering/filtering logic; likely related to workflow_result passthrough and display.
  • coleam00/Archon#1052: Related to emitting workflow dispatch/status/result messages consumed by the Web UI; coordinates on message contract.
  • coleam00/Archon#1025: Implements workflow result card UI semantics (status, duration, node counts) similar to the new ConsoleWorkflowResultCard.

Poem

🐰 I munched a carrot while runs ran past,
A card appeared: the workflow's cast.
Nodes counted, status bright and clear,
A jump-to-bottom button hops near,
Rabbit cheers — the console's here at last!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the three main changes: workflow-completion card rendering, SSE lock integration for immediate composer enable, and jump-to-bottom UI functionality.
Description check ✅ Passed The description is comprehensive and follows the template structure with clear Problem/Why/What Changed sections, detailed UX journey with before/after flows, architecture diagrams with connection inventory, proper validation evidence, security/compatibility assessments, human verification details, side effects analysis, rollback plan, and risk mitigations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/console-completion-card

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{"name":"HttpError","status":500,"request":{"method":"PATCH","url":"https://api.github.com/repos/coleam00/Archon/issues/comments/4630333054","headers":{"accept":"application/vnd.github.v3+json","user-agent":"octokit.js/0.0.0-development octokit-core.js/7.0.6 Node.js/24","authorization":"token [REDACTED]","content-type":"application/json; charset=utf-8"},"body":{"body":"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- review_stack_entry_start -->\n\n[![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/coleam00/Archon/pull/1885?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)\n\n<!-- review_stack_entry_end -->\n<!-- This is an auto-generated comment: review in progress by coderabbit.ai -->\n\n> [!NOTE]\n> Currently processing new changes in this PR. This may take a few minutes, please wait...\n> \n> <details>\n> <summary>⚙️ Run configuration</summary>\n> \n> **Configuration used**: defaults\n> \n> **Review profile**: CHILL\n> \n> **Plan**: Pro\n> \n> **Run ID**: `07e1a29e-176c-4e43-afcd-8b42e3108ab4`\n> \n> </details>\n> \n> <details>\n> <summary>📥 Commits</summary>\n> \n> Reviewing files that changed from the base of the PR and between 58fd9dc25835324bb0f4552d2ad66ca11e890bc7 and 9a9a498501e49b35b48a11e91dc302eda67d519f.\n> \n> </details>\n> \n> <details>\n> <summary>📒 Files selected for processing (5)</summary>\n> \n> * `packages/web/src/experiments/console/components/ChatStream.tsx`\n> * `packages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsx`\n> * `packages/web/src/experiments/console/primitives/message.test.ts`\n> * `packages/web/src/experiments/console/primitives/message.ts`\n> * `packages/web/src/experiments/console/routes/ChatPage.tsx`\n> \n> </details>\n> \n> ```ascii\n>  ________________________\n> < GLaDOS? I can fix her! >\n>  ------------------------\n>   \\\n>    \\   \\\n>         \\ /\\\n>         ( )\n>       .( o ).\n> ```\n\n<!-- end of auto-generated comment: review in progress by coderabbit.ai -->\n\n<!-- finishing_touch_checkbox_start -->\n\n<details>\n<summary>✨ Finishing Touches</summary>\n\n<details>\n<summary>📝 Generate docstrings</summary>\n\n- [ ] <!-- {\"checkboxId\": \"7962f53c-55bc-4827-bfbf-6a18da830691\"} --> Create stacked PR\n- [ ] <!-- {\"checkboxId\": \"3e1879ae-f29b-4d0d-8e06-d12b7ba33d98\"} --> Commit on current branch\n\n</details>\n<details>\n<summary>🧪 Generate unit tests (beta)</summary>\n\n- [ ] <!-- {\"checkboxId\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Create PR with unit tests\n- [ ] <!-- {\"checkboxId\": \"6ba7b810-9dad-11d1-80b4-00c04fd430c8\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Commit unit tests in branch `feat/console-completion-card`\n\n</details>\n\n</details>\n\n<!-- finishing_touch_checkbox_end -->\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=coleam00/Archon&utm_content=1885)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)\n- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)\n- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)\n- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)\n\n</details>\n\n\n<sub>Comment `@coderabbitai help` to get the list of available commands and usage tips.</sub>\n\n<!-- tips_end -->"},"request":{"retryCount":3,"signal":{},"retries":3,"retryAfter":16}}}

@coderabbitai coderabbitai Bot 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.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 58fd9dc and 9a9a498.

📒 Files selected for processing (5)
  • packages/web/src/experiments/console/components/ChatStream.tsx
  • packages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsx
  • packages/web/src/experiments/console/primitives/message.test.ts
  • packages/web/src/experiments/console/primitives/message.ts
  • packages/web/src/experiments/console/routes/ChatPage.tsx

Comment on lines +83 to +91
const onLockChange = useCallback((locked: boolean): void => {
if (locked) return;
if (settleTimerRef.current !== null) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
setBusy(false);
}, []);
useConversationSSE(activeConvId, onLockChange);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

@Wirasm

Wirasm commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

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 store/cache.ts rather than relaying it.

Critical Issues (1) — blocks merge

# Agent(s) Issue Location
C1 code-reviewer (95) + silent-failure + type-design + comment-analyzer The rich completion card never renders — it always degrades to summary-only. const run = error !== null ? null : (data?.run ?? null) compares against null, but useEntity returns error: Error | undefined (cache.ts:135, errors.get(key) from a Map<string,Error> at :190 — never null). On a successful fetch error is undefined, so error !== null is truerun = null → the summary-only fallback is returned every time. Node counts, duration, cost, and the "Open run →" link are dead code. Type-check passes (both branches valid) and no browser smoke test was run, so it's green-but-broken. Fix: error !== undefined (or error != null). ConsoleWorkflowResultCard.tsx (const run = line)

Important Issues (3)

# Agent Issue Location
I1 pr-test-analyzer (8) Latent crash on workflowResult: null. The guard wr !== undefined && typeof wr.workflowName === 'string' lets an explicit JSON null through the first clause, then typeof wr.workflowName evaluates null.workflowNameTypeError, crashing message rendering for that conversation. Server likely omits (→undefined) rather than nulls today, so it's latent — but it's a one-char fix: wr != null. Add a workflowResult: null test. message.ts (the wr !== undefined guard)
I2 code-reviewer (82) countTerminalNodes double-counts resumed runs. It counts every non-started node_transition. On resume, a node with both an original completed and a node_skipped_prior_success contributes two terminal events → "16/16" for an 8-node run. Dedupe by nodeId (last-write-wins). (Confirm getRun returns merged cross-resume event history before fixing — but the #1878 resume-skip behavior makes this plausible.) ConsoleWorkflowResultCard.tsx countTerminalNodes
I3 silent-failure-hunter (2× MEDIUM) Two silent swallows — given this PR's whole thesis is "stop silently dropping completions": (a) the getRun error path discards the error with no console.warn (a transient 500 looks identical to a deleted run); (b) parseMetadata's catch {} swallows JSON.parse failures silently, so a corrupt-metadata workflow_result row falls through to a plain message with zero diagnostic. Add a console.warn to each. (a) becomes reachable once C1 is fixed. ConsoleWorkflowResultCard.tsx (error path); message.ts parseMetadata

Suggestions (6)

# Agent Suggestion Location
S1 type-design + simplify While fixing C1, drop the spurious | null from useEntity<Awaited<ReturnType<typeof skill.getRun>> | null>getRun never resolves to null; undefined is already the cache-miss signal. Dead type space. ConsoleWorkflowResultCard.tsx (useEntity generic)
S2 simplify + type-design Make RESULT_GLYPH/RESULT_LABEL total Record<RunStatus, string> (drops the ?? '•' / statusLabel[…].toLowerCase() fallbacks), or add a one-line comment that Partial+fallback is deliberate for the rare running/paused race. ConsoleWorkflowResultCard.tsx (the two maps)
S3 simplify Extract the duplicated magic 120 (near-bottom threshold, appears twice) → const NEAR_BOTTOM_PX = 120. ChatPage.tsx (scroll effect + handleScroll)
S4 pr-test-analyzer Export + unit-test countTerminalNodes (pins the started-excluded-from-total and failed/skipped-vs-completed invariants — exactly where I2 lives). Also add tests for non-string workflowName and malformed-JSON metadata. message.test.ts / card
S5 comment-analyzer The card JSDoc says it degrades "if the run can't be loaded (e.g. deleted)" but omits loading (the inline comment correctly says "Loading or unfetchable"). Reword so a future reader doesn't add a "loading spinner fix" that breaks the intended behavior. ConsoleWorkflowResultCard.tsx JSDoc
S6 type-design One-line comment on why ParsedMetadata.workflowResult stays inline/untrusted vs the validated WorkflowResultMeta. message.ts

Strengths

  • The SSE-stability gotcha is handled correctly — verified across 3 agents: onLockChange is useCallback([])-stable (only refs + stable setBusy inside), useConversationSSE's effect deps are [conversationPlatformId, onLockChange], and conversation_lock:false drives onLockChange(false). No EventSource reconnect loop. The settle timer is correctly retained as the SSE-absent fallback.
  • The filter change is minimal and correct — explicit === 'workflow_result' OR; isSystemCategory untouched, so other workflow_* narration stays suppressed (and there's a test pinning isSystemCategory('workflow_result') === true to document why ChatStream must branch before the filter).
  • message.ts uses a clean untrusted-intake → validated-output boundary (as ParsedMetadata cast + typeof gate in toMessage), consistent with the existing workflowDispatch pattern (type-design 8/10).
  • Good test hygiene: Parameters<typeof toMessage>[0], realistic JSON.stringify metadata, a workflowDispatch regression guard, and the architecture-documenting isSystemCategory test.
  • cache.ts invalidate() walks the errors map too, so an errored key retries after invalidation. No nested ternaries anywhere.

Documentation

None needed — purely intra-experiment; CLAUDE.md and experiments/README.md stay accurate.


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

  1. C1error !== undefined (one word) — restores the entire card. Verify in a browser this time (launch a workflow from console chat, confirm the rich card renders).
  2. I1wr != null + a workflowResult: null test.
  3. I3console.warn in the two swallow sites.
  4. I2 — dedupe countTerminalNodes by nodeId (after confirming cross-resume event merging).
  5. S1-S6 — fold in opportunistically (S1 rides along with the C1 edit).

🤖 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.
@Wirasm

Wirasm commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

Review fixes applied — 787f7d84

Addressed the multi-agent review. Summary:

# 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.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/web/src/experiments/console/routes/ChatPage.tsx (1)

86-93: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep one last message refresh on unlock.

The SSE hook only invalidates K.messages(...) on text/tool_* events. If that final burst is the one that gets dropped, Line 92 clears busy and 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 win

Add an explicit return type to the node helper.

Annotating it as RunEvent keeps 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 any types 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 win

Use the project logger instead of console.warn for 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9a9a498 and 787f7d8.

📒 Files selected for processing (6)
  • packages/web/src/experiments/console/components/ConsoleWorkflowResultCard.tsx
  • packages/web/src/experiments/console/primitives/event.test.ts
  • packages/web/src/experiments/console/primitives/event.ts
  • packages/web/src/experiments/console/primitives/message.test.ts
  • packages/web/src/experiments/console/primitives/message.ts
  • packages/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

Comment on lines 178 to 182
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;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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).

@Wirasm Wirasm merged commit 95ee6db into dev Jun 5, 2026
4 checks passed
@Wirasm Wirasm deleted the feat/console-completion-card branch June 5, 2026 11:43
Wirasm added a commit that referenced this pull request Jun 5, 2026
…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.
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.

1 participant