From bdf0a0f2e0ee47fbbe602e132a00308171946007 Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Wed, 18 Feb 2026 16:18:07 -0600 Subject: [PATCH] Consensus nav, collapsible sections, and decision-first layout Add sticky right-side navigation and collapsible disclosure sections to both the live consensus page and stored thread detail view. Decisions now surface to the top when consensus is complete, with dissent getting equal treatment via a refactored DissentBanner that parses model attribution. Key changes: - Shared Disclosure primitive (chevron + toggle) reused across all cards - ConsensusNav/ThreadNav sticky sidebars with per-challenger model names - ConsensusComplete and thread decisions collapsible, positioned at top - DissentBanner parses [model:name]: prefix for proper ModelBadge display - Individual challengers and contributions independently collapsible - Responsive: nav hidden on mobile, collapsible sections still work - 166 Vitest tests (40 new), build clean Co-Authored-By: Claude Opus 4.6 --- memory-bank/activeContext.md | 90 ++-- memory-bank/progress.md | 15 +- web/src/__tests__/consensus-nav.test.tsx | 409 ++++++++++++++++++ web/src/__tests__/thread-nav.test.tsx | 113 +++++ .../consensus/ConsensusComplete.tsx | 119 +++-- web/src/components/consensus/ConsensusNav.tsx | 137 ++++++ .../components/consensus/ConsensusPanel.tsx | 125 +++--- .../components/consensus/DissentBanner.tsx | 28 +- web/src/components/consensus/PhaseCard.tsx | 69 ++- web/src/components/consensus/index.ts | 1 + web/src/components/shared/Disclosure.tsx | 33 ++ web/src/components/shared/index.ts | 1 + web/src/components/threads/ThreadDetail.tsx | 84 +++- web/src/components/threads/ThreadNav.tsx | 60 +++ web/src/components/threads/TurnCard.tsx | 89 +++- web/src/components/threads/index.ts | 1 + web/src/pages/ConsensusPage.tsx | 15 +- web/src/pages/ThreadDetailPage.tsx | 15 +- 18 files changed, 1177 insertions(+), 227 deletions(-) create mode 100644 web/src/__tests__/consensus-nav.test.tsx create mode 100644 web/src/__tests__/thread-nav.test.tsx create mode 100644 web/src/components/consensus/ConsensusNav.tsx create mode 100644 web/src/components/shared/Disclosure.tsx create mode 100644 web/src/components/threads/ThreadNav.tsx diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 040cda1..f21221c 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -1,68 +1,58 @@ # Active Context **Last Updated**: 2026-02-18 -**Current Phase**: Epistemic Confidence (Phase A) — on branch `epistemic-confidence-phase-a` -**Next Action**: Commit, push, create PR to merge to main. +**Current Phase**: Consensus UX — right-side nav, collapsible sections, decision-first layout +**Next Action**: PR open for review. -## What Just Shipped: Epistemic Confidence Phase A +## What Just Shipped: Consensus Navigation & Collapsible Sections -### Core Change -Confidence scoring is now **epistemic** — it reflects inherent uncertainty of the question domain, not just challenge quality. +### Core Changes +The consensus page and thread detail view now have proper navigation and information hierarchy for multi-round deliberations. -**Before**: `confidence = _compute_confidence(challenges)` — measured rigor only (0.5–1.0 based on sycophancy ratio). -**After**: Two separate scores: -- **Rigor** (renamed from old confidence) — how genuine the challenges were (0.5–1.0) -- **Confidence** — `min(domain_cap(intent), rigor)` — rigor clamped by question type ceiling +**Before**: Long vertical scroll of rounds with no way to navigate or collapse. Decision buried at the bottom after all rounds. +**After**: +- Sticky right-side nav panel shows progress through rounds/phases +- All sections are independently collapsible via a shared `Disclosure` primitive +- Decision surfaces to the **top** when consensus is complete (both live + stored threads) +- Individual challengers shown by model name in nav and each collapsible +- Dissent gets equal treatment: collapsible `DissentBanner` with model attribution parsed from `[model:name]:` prefix -### Domain Caps -| Intent | Cap | Rationale | -|--------|-----|-----------| -| factual | 0.95 | Verifiable answers, near-certain | -| technical | 0.90 | Strong consensus possible | -| creative | 0.85 | Subjective, multiple valid answers | -| judgment | 0.80 | Requires weighing trade-offs | -| strategic | 0.70 | Inherent future uncertainty | -| unknown/None | 0.85 | Default conservative cap | +### New Shared Component: `Disclosure` +Reusable chevron + toggle primitive (`web/src/components/shared/Disclosure.tsx`): +- Props: `header`, `defaultOpen`, `forceOpen`, `className` +- Used by: PhaseCard, TurnCard, ConsensusComplete, DissentBanner, ThreadDetail -### Files Changed (47 files, +997, -230) +### Files Changed (17 files) **New files:** -- `src/duh/calibration.py` — ECE (Expected Calibration Error) computation -- `src/duh/memory/migrations.py` — SQLite schema migration (adds rigor column) -- `tests/unit/test_calibration.py` — 15 calibration tests -- `tests/unit/test_confidence_scoring.py` — 20 epistemic confidence tests -- `tests/unit/test_cli_calibration.py` — 4 CLI calibration tests -- `web/src/components/calibration/CalibrationDashboard.tsx` — Calibration viz -- `web/src/pages/CalibrationPage.tsx` — Calibration page -- `web/src/stores/calibration.ts` — Calibration Zustand store +- `web/src/components/shared/Disclosure.tsx` — Shared collapsible primitive +- `web/src/components/consensus/ConsensusNav.tsx` — Sticky nav for live consensus +- `web/src/components/threads/ThreadNav.tsx` — Sticky nav for thread detail +- `web/src/__tests__/consensus-nav.test.tsx` — 32 tests (Disclosure, PhaseCard, DissentBanner, TurnCard, ConsensusNav) +- `web/src/__tests__/thread-nav.test.tsx` — 8 tests (ThreadNav) -**Modified across full stack:** -- `consensus/handlers.py` — Renamed `_compute_confidence` → `_compute_rigor`, added `_domain_cap()`, `DOMAIN_CAPS`, epistemic formula -- `consensus/machine.py` — Added `rigor` to ConsensusContext, RoundResult -- `consensus/scheduler.py` — Propagates rigor through subtask results -- `consensus/synthesis.py` — Averages rigor across subtask results -- `consensus/voting.py` — Added rigor to VoteResult, VotingAggregation -- `memory/models.py` — Added `rigor` column to Decision ORM -- `memory/repository.py` — Accepts `rigor` param in `save_decision()` -- `memory/context.py` — Shows rigor in context builder output -- `cli/app.py` — All output paths show rigor; new `duh calibration` command; PDF export enhanced -- `cli/display.py` — `show_commit()` and `show_final_decision()` show rigor -- `api/routes/crud.py` — `GET /api/calibration` endpoint; rigor in decision space -- `api/routes/ask.py`, `ws.py`, `threads.py` — Propagate rigor -- `mcp/server.py` — Propagates rigor -- Frontend: ConfidenceMeter, ConsensusComplete, ConsensusPanel, ThreadDetail, TurnCard, ExportMenu, Sidebar, DecisionCloud, stores updated +**Modified:** +- `PhaseCard.tsx` — Uses Disclosure for outer collapse + per-challenger Disclosure +- `TurnCard.tsx` — Uses Disclosure for outer collapse + per-contribution Disclosure +- `ConsensusComplete.tsx` — Collapsible via Disclosure, dissent moved inside panel +- `DissentBanner.tsx` — Uses Disclosure, parses `[model:name]:` prefix for ModelBadge +- `ConsensusPanel.tsx` — Decision at top when complete, scroll target IDs +- `ConsensusPage.tsx` — Flex-row layout with sticky ConsensusNav sidebar +- `ThreadDetail.tsx` — Decision surfaced to top, DissentBanner for dissent, scroll IDs +- `ThreadDetailPage.tsx` — Flex-row layout with sticky ThreadNav sidebar +- Barrel exports: `consensus/index.ts`, `threads/index.ts`, `shared/index.ts` + +### Test Results +- 1586 Python tests + 166 Vitest tests (1752 total) +- Build clean, all tests pass --- ## Current State -- **Branch `epistemic-confidence-phase-a`** — all changes uncommitted, ready to commit. -- **1586 Python tests + 126 Vitest tests** (1712 total), ruff clean, mypy strict clean. -- **~62 Python source files + 70 frontend source files** (~132 total). -- All previous features intact (v0.1–v0.5 + export). - -## Next Task: Model Selection Controls + Provider Updates - -Deferred from before Phase A. See `progress.md` for details. +- **Branch `consensus-nav-collapsible`** — ready for PR. +- **1586 Python tests + 166 Vitest tests** (1752 total). +- **~62 Python source files + 75 frontend source files** (~137 total). +- All previous features intact (v0.1–v0.5 + export + epistemic confidence). ## Open Questions (Still Unresolved) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 5d6b98a..2381039 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -4,7 +4,19 @@ --- -## Current State: Epistemic Confidence Phase A COMPLETE +## Current State: Consensus Nav + Collapsible Sections COMPLETE + +### Consensus Navigation & Collapsible Sections + +- **Shared `Disclosure` primitive** — reusable chevron + toggle component used across PhaseCard, TurnCard, ConsensusComplete, DissentBanner, ThreadDetail +- **Sticky right-side nav** — `ConsensusNav` (live consensus) and `ThreadNav` (thread detail) show round/phase progress, individual challenger model names, scroll-to-section on click +- **Decision-first layout** — `ConsensusComplete` and thread final decision surface to the top when consensus is complete, collapsible via Disclosure +- **Per-challenger collapsibility** — each individual challenger is its own Disclosure within the CHALLENGE phase, nav shows short model names (e.g. `gpt-4`, `gemini`) +- **DissentBanner refactored** — uses Disclosure, parses `[model:name]:` prefix to extract model attribution and display ModelBadge +- **Responsive** — nav hidden on mobile (`hidden lg:block`), collapsible sections still work +- **Both views** — ConsensusPage (live streaming) and ThreadDetailPage (stored threads) share the same patterns +- 1586 Python tests + 166 Vitest tests (1752 total), build clean +- New files: Disclosure.tsx, ConsensusNav.tsx, ThreadNav.tsx, consensus-nav.test.tsx, thread-nav.test.tsx ### Epistemic Confidence Phase A @@ -168,3 +180,4 @@ Phase 0 benchmark framework — fully functional, pilot-tested on 5 questions. | 2026-02-17 | v0.5.0 — "It Scales" | **Complete** | | 2026-02-17 | Export to Markdown & PDF (CLI + API + Web UI) | Done | | 2026-02-18 | Epistemic Confidence Phase A (rigor + domain caps + calibration) | Done | +| 2026-02-18 | Consensus nav + collapsible sections + decision-first layout | Done | diff --git a/web/src/__tests__/consensus-nav.test.tsx b/web/src/__tests__/consensus-nav.test.tsx new file mode 100644 index 0000000..fb08b74 --- /dev/null +++ b/web/src/__tests__/consensus-nav.test.tsx @@ -0,0 +1,409 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { Disclosure } from '@/components/shared/Disclosure' +import { PhaseCard } from '@/components/consensus/PhaseCard' +import { DissentBanner } from '@/components/consensus/DissentBanner' +import { TurnCard } from '@/components/threads/TurnCard' +import type { Turn } from '@/api/types' + +// ── DissentBanner ──────────────────────────────────────── + +describe('DissentBanner', () => { + it('renders DISSENT label', () => { + render() + expect(screen.getByText('DISSENT')).toBeInTheDocument() + }) + + it('renders dissent content', () => { + render() + expect(screen.getByText('Alternative approach recommended')).toBeInTheDocument() + }) + + it('extracts model name from [model:name]: prefix', () => { + render() + expect(screen.getByText('google:gemini-3-flash')).toBeInTheDocument() + expect(screen.getByText('Use a different strategy')).toBeInTheDocument() + }) + + it('handles dissent without model prefix', () => { + render() + expect(screen.getByText('Plain dissent without model')).toBeInTheDocument() + }) + + it('is collapsible via Disclosure', () => { + render() + // Content is visible by default + expect(screen.getByText('Collapsible content')).toBeInTheDocument() + // Click DISSENT header to collapse + fireEvent.click(screen.getByText('DISSENT')) + expect(screen.queryByText('Collapsible content')).toBeNull() + }) +}) + +// ── Disclosure (shared primitive) ──────────────────────── + +describe('Disclosure', () => { + it('shows children when defaultOpen=true', () => { + render( + Header} defaultOpen> +

Content

+
+ ) + expect(screen.getByText('Content')).toBeInTheDocument() + }) + + it('hides children when defaultOpen=false', () => { + render( + Header} defaultOpen={false}> +

Hidden

+
+ ) + expect(screen.queryByText('Hidden')).toBeNull() + }) + + it('toggles on header click', () => { + render( + Toggle} defaultOpen={false}> +

Body

+
+ ) + expect(screen.queryByText('Body')).toBeNull() + fireEvent.click(screen.getByText('Toggle')) + expect(screen.getByText('Body')).toBeInTheDocument() + fireEvent.click(screen.getByText('Toggle')) + expect(screen.queryByText('Body')).toBeNull() + }) + + it('stays open when forceOpen=true regardless of clicks', () => { + render( + Forced} defaultOpen={false} forceOpen> +

Always visible

+
+ ) + expect(screen.getByText('Always visible')).toBeInTheDocument() + fireEvent.click(screen.getByText('Forced')) + expect(screen.getByText('Always visible')).toBeInTheDocument() + }) + + it('renders chevron that rotates when open', () => { + const { container } = render( + H} defaultOpen> +

C

+
+ ) + const chevron = container.querySelector('svg') + expect(chevron?.className).toContain('rotate-90') + }) + + it('chevron does not rotate when closed', () => { + const { container } = render( + H} defaultOpen={false}> +

C

+
+ ) + const chevron = container.querySelector('svg') + expect(chevron?.className).not.toContain('rotate-90') + }) +}) + +// ── PhaseCard collapsible ──────────────────────────────── + +describe('PhaseCard collapsible', () => { + it('renders content when not collapsible', () => { + render() + expect(screen.getByText('Some proposal')).toBeInTheDocument() + }) + + it('hides content when collapsible and defaultOpen=false', () => { + render( + + ) + expect(screen.queryByText('Hidden proposal')).toBeNull() + }) + + it('shows content when collapsible and defaultOpen=true', () => { + render( + + ) + expect(screen.getByText('Visible proposal')).toBeInTheDocument() + }) + + it('toggles content on header click', () => { + render( + + ) + expect(screen.queryByText('Toggle me')).toBeNull() + fireEvent.click(screen.getByText('PROPOSE')) + expect(screen.getByText('Toggle me')).toBeInTheDocument() + }) + + it('forces open when isActive regardless of defaultOpen', () => { + const { container } = render( + + ) + // Active overrides collapsed state — content div is rendered (StreamingText starts empty) + const contentDiv = container.querySelector('.text-sm') + expect(contentDiv).toBeInTheDocument() + }) + + it('renders individual challengers as separate disclosures', () => { + const challenges = [ + { model: 'openai:gpt-4', content: 'Challenge A' }, + { model: 'google:gemini', content: 'Challenge B' }, + ] + render() + // Both model badges rendered + expect(screen.getByText('openai:gpt-4')).toBeInTheDocument() + expect(screen.getByText('google:gemini')).toBeInTheDocument() + // Both contents visible (not collapsible by default) + expect(screen.getByText('Challenge A')).toBeInTheDocument() + expect(screen.getByText('Challenge B')).toBeInTheDocument() + }) + + it('individual challengers are collapsed when phase is collapsible', () => { + const challenges = [ + { model: 'openai:gpt-4', content: 'Challenge A' }, + ] + render() + // Phase is open but individual challenges start collapsed + expect(screen.queryByText('Challenge A')).toBeNull() + // Click the model badge to expand + fireEvent.click(screen.getByText('openai:gpt-4')) + expect(screen.getByText('Challenge A')).toBeInTheDocument() + }) +}) + +// ── TurnCard collapsible ───────────────────────────────── + +function makeTurn(overrides: Partial = {}): Turn { + return { + round_number: 1, + state: 'PROPOSE', + contributions: [ + { model_ref: 'anthropic:claude', role: 'proposer', content: 'Proposal text', input_tokens: 100, output_tokens: 200, cost_usd: 0.01 }, + ], + decision: null, + ...overrides, + } +} + +describe('TurnCard collapsible', () => { + it('renders contributions when not collapsible', () => { + render() + expect(screen.getByText('Proposal text')).toBeInTheDocument() + }) + + it('hides content when collapsible and defaultOpen=false', () => { + render() + expect(screen.queryByText('Proposal text')).toBeNull() + }) + + it('toggles round open on header click, contributions still collapsed', () => { + render() + expect(screen.queryByText('proposer')).toBeNull() + fireEvent.click(screen.getByText('ROUND 1')) + // Round opens — contribution headers visible but content still collapsed + expect(screen.getByText('proposer')).toBeInTheDocument() + expect(screen.queryByText('Proposal text')).toBeNull() + // Click contribution to expand + fireEvent.click(screen.getByText('proposer')) + expect(screen.getByText('Proposal text')).toBeInTheDocument() + }) + + it('shows confidence preview when collapsed with decision', () => { + const turn = makeTurn({ + decision: { content: 'Decision', confidence: 0.85, rigor: 0.78, dissent: null }, + }) + render() + expect(screen.getByText('85%')).toBeInTheDocument() + }) + + it('individual contributions are collapsible when turn is collapsible', () => { + const turn = makeTurn() + render() + // Contribution starts collapsed + expect(screen.queryByText('Proposal text')).toBeNull() + // Click the contribution header to expand + fireEvent.click(screen.getByText('proposer')) + expect(screen.getByText('Proposal text')).toBeInTheDocument() + }) +}) + +// ── ConsensusNav ───────────────────────────────────────── + +// Mock the consensus store +const mockStoreState = { + status: 'idle' as string, + rounds: [] as Array<{ + round: number + proposer: string | null + proposal: string | null + challengers: string[] + challenges: Array<{ model: string; content: string }> + reviser: string | null + revision: string | null + confidence: number | null + rigor: number | null + dissent: string | null + }>, + currentRound: 0, + currentPhase: null as string | null, +} + +vi.mock('@/stores/consensus', () => ({ + useConsensusStore: () => mockStoreState, +})) + +const { ConsensusNav } = await import('@/components/consensus/ConsensusNav') + +describe('ConsensusNav', () => { + beforeEach(() => { + mockStoreState.status = 'idle' + mockStoreState.rounds = [] + mockStoreState.currentRound = 0 + mockStoreState.currentPhase = null + }) + + it('returns null when no rounds', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders PROGRESS header when rounds exist', () => { + mockStoreState.status = 'streaming' + mockStoreState.rounds = [{ + round: 1, proposer: 'anthropic:claude', proposal: 'Test', + challengers: [], challenges: [], reviser: null, revision: null, + confidence: null, rigor: null, dissent: null, + }] + mockStoreState.currentRound = 1 + mockStoreState.currentPhase = 'PROPOSE' + + render() + expect(screen.getByText('PROGRESS')).toBeInTheDocument() + }) + + it('renders round labels', () => { + mockStoreState.status = 'streaming' + mockStoreState.rounds = [ + { + round: 1, proposer: 'a:b', proposal: 'P', challengers: ['c:d'], + challenges: [{ model: 'c:d', content: 'Ch' }], reviser: 'a:b', revision: 'R', + confidence: null, rigor: null, dissent: null, + }, + { + round: 2, proposer: 'a:b', proposal: null, + challengers: [], challenges: [], reviser: null, revision: null, + confidence: null, rigor: null, dissent: null, + }, + ] + mockStoreState.currentRound = 2 + mockStoreState.currentPhase = 'PROPOSE' + + render() + expect(screen.getByText('ROUND 1')).toBeInTheDocument() + expect(screen.getByText('ROUND 2')).toBeInTheDocument() + }) + + it('renders individual challenger model names instead of CHALLENGE', () => { + mockStoreState.status = 'complete' + mockStoreState.rounds = [{ + round: 1, proposer: 'a:b', proposal: 'P', + challengers: ['openai:gpt-4', 'google:gemini'], + challenges: [ + { model: 'openai:gpt-4', content: 'C1' }, + { model: 'google:gemini', content: 'C2' }, + ], + reviser: 'a:b', revision: 'R', + confidence: 0.85, rigor: 0.78, dissent: null, + }] + + render() + // Individual model names shown (short form) + expect(screen.getByText('gpt-4')).toBeInTheDocument() + expect(screen.getByText('gemini')).toBeInTheDocument() + // No generic CHALLENGE label + expect(screen.queryByText('CHALLENGE')).toBeNull() + }) + + it('shows challenger names from challengers list when challenges not yet received', () => { + mockStoreState.status = 'streaming' + mockStoreState.rounds = [{ + round: 1, proposer: 'a:b', proposal: 'P', + challengers: ['openai:gpt-4'], + challenges: [], + reviser: null, revision: null, + confidence: null, rigor: null, dissent: null, + }] + mockStoreState.currentRound = 1 + mockStoreState.currentPhase = 'CHALLENGE' + + render() + expect(screen.getByText('gpt-4')).toBeInTheDocument() + }) + + it('renders DECISION entry at top when complete', () => { + mockStoreState.status = 'complete' + mockStoreState.rounds = [{ + round: 1, proposer: 'a:b', proposal: 'P', challengers: ['c:d'], + challenges: [{ model: 'c:d', content: 'Ch' }], reviser: 'a:b', revision: 'R', + confidence: 0.85, rigor: 0.78, dissent: null, + }] + + render() + expect(screen.getByText('DECISION')).toBeInTheDocument() + }) + + it('does not render DECISION entry when streaming', () => { + mockStoreState.status = 'streaming' + mockStoreState.rounds = [{ + round: 1, proposer: 'a:b', proposal: 'P', + challengers: [], challenges: [], reviser: null, revision: null, + confidence: null, rigor: null, dissent: null, + }] + mockStoreState.currentRound = 1 + mockStoreState.currentPhase = 'PROPOSE' + + render() + expect(screen.queryByText('DECISION')).toBeNull() + }) + + it('calls scrollIntoView on nav click', () => { + const scrollMock = vi.fn() + const fakeEl = { scrollIntoView: scrollMock } as unknown as HTMLElement + vi.spyOn(document, 'getElementById').mockReturnValue(fakeEl) + + mockStoreState.status = 'streaming' + mockStoreState.rounds = [{ + round: 1, proposer: 'a:b', proposal: 'P', + challengers: [], challenges: [], reviser: null, revision: null, + confidence: null, rigor: null, dissent: null, + }] + mockStoreState.currentRound = 1 + mockStoreState.currentPhase = 'PROPOSE' + + render() + fireEvent.click(screen.getByText('ROUND 1')) + + expect(document.getElementById).toHaveBeenCalledWith('consensus-round-1') + expect(scrollMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }) + + vi.restoreAllMocks() + }) + + it('renders PROPOSE and REVISE entries for populated phases', () => { + mockStoreState.status = 'streaming' + mockStoreState.rounds = [{ + round: 1, proposer: 'a:b', proposal: 'P', + challengers: ['c:d'], challenges: [{ model: 'c:d', content: 'Ch' }], + reviser: 'a:b', revision: 'R', + confidence: null, rigor: null, dissent: null, + }] + mockStoreState.currentRound = 1 + mockStoreState.currentPhase = 'REVISE' + + render() + expect(screen.getByText('PROPOSE')).toBeInTheDocument() + expect(screen.getByText('REVISE')).toBeInTheDocument() + }) +}) diff --git a/web/src/__tests__/thread-nav.test.tsx b/web/src/__tests__/thread-nav.test.tsx new file mode 100644 index 0000000..93b3bbe --- /dev/null +++ b/web/src/__tests__/thread-nav.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import type { ThreadDetail } from '@/api/types' + +// Mock the threads store +let mockThread: ThreadDetail | null = null + +vi.mock('@/stores/threads', () => ({ + useThreadsStore: (selector?: (s: { currentThread: ThreadDetail | null }) => unknown) => { + const state = { currentThread: mockThread } + return selector ? selector(state) : state + }, +})) + +// Must import after mock +const { ThreadNav } = await import('@/components/threads/ThreadNav') + +function makeThread(turns: ThreadDetail['turns'], status = 'complete'): ThreadDetail { + return { + thread_id: 'test-thread-id', + question: 'Test question?', + status, + created_at: '2025-01-01T00:00:00Z', + turns, + } +} + +describe('ThreadNav', () => { + beforeEach(() => { + mockThread = null + }) + + it('returns null when no thread', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('returns null when thread has no turns', () => { + mockThread = makeThread([]) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders ROUNDS header when turns exist', () => { + mockThread = makeThread([{ + round_number: 1, state: 'PROPOSE', + contributions: [], decision: null, + }]) + + render() + expect(screen.getByText('ROUNDS')).toBeInTheDocument() + }) + + it('renders round buttons', () => { + mockThread = makeThread([ + { round_number: 1, state: 'PROPOSE', contributions: [], decision: null }, + { round_number: 2, state: 'REVISE', contributions: [], decision: null }, + ]) + + render() + expect(screen.getByText('ROUND 1')).toBeInTheDocument() + expect(screen.getByText('ROUND 2')).toBeInTheDocument() + }) + + it('renders DECISION entry at top when thread is complete with decision', () => { + mockThread = makeThread([{ + round_number: 1, state: 'PROPOSE', contributions: [], + decision: { content: 'Decision', confidence: 0.9, rigor: 0.8, dissent: null }, + }], 'complete') + + render() + expect(screen.getByText('DECISION')).toBeInTheDocument() + expect(screen.getByText('FEEDBACK')).toBeInTheDocument() + }) + + it('does not render DECISION or FEEDBACK entry when thread is active', () => { + mockThread = makeThread([{ + round_number: 1, state: 'PROPOSE', contributions: [], decision: null, + }], 'active') + + render() + expect(screen.queryByText('DECISION')).toBeNull() + expect(screen.queryByText('FEEDBACK')).toBeNull() + }) + + it('shows confidence percentage for rounds with decisions', () => { + mockThread = makeThread([{ + round_number: 1, state: 'PROPOSE', contributions: [], + decision: { content: 'D', confidence: 0.92, rigor: 0.85, dissent: null }, + }]) + + render() + expect(screen.getByText('92%')).toBeInTheDocument() + }) + + it('calls scrollIntoView on round click', () => { + const scrollMock = vi.fn() + const fakeEl = { scrollIntoView: scrollMock } as unknown as HTMLElement + vi.spyOn(document, 'getElementById').mockReturnValue(fakeEl) + + mockThread = makeThread([{ + round_number: 1, state: 'PROPOSE', contributions: [], decision: null, + }]) + + render() + fireEvent.click(screen.getByText('ROUND 1')) + + expect(document.getElementById).toHaveBeenCalledWith('thread-round-1') + expect(scrollMock).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }) + + vi.restoreAllMocks() + }) +}) diff --git a/web/src/components/consensus/ConsensusComplete.tsx b/web/src/components/consensus/ConsensusComplete.tsx index 88942b0..5abf6a0 100644 --- a/web/src/components/consensus/ConsensusComplete.tsx +++ b/web/src/components/consensus/ConsensusComplete.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { GlassPanel, GlowButton, Markdown } from '@/components/shared' +import { GlassPanel, GlowButton, Markdown, Disclosure } from '@/components/shared' import { ConfidenceMeter } from './ConfidenceMeter' import { DissentBanner } from './DissentBanner' import { CostTicker } from './CostTicker' @@ -12,6 +12,7 @@ interface ConsensusCompleteProps { rigor: number dissent: string | null cost: number | null + collapsible?: boolean } export function generateExportMarkdown( @@ -88,7 +89,7 @@ function downloadFile(content: string | Blob, filename: string, mimeType: string URL.revokeObjectURL(url) } -export function ConsensusComplete({ decision, confidence, rigor, dissent, cost }: ConsensusCompleteProps) { +export function ConsensusComplete({ decision, confidence, rigor, dissent, cost, collapsible }: ConsensusCompleteProps) { const [copied, setCopied] = useState(false) const [exportOpen, setExportOpen] = useState(false) const { question, rounds, threadId } = useConsensusStore() @@ -115,6 +116,75 @@ export function ConsensusComplete({ decision, confidence, rigor, dissent, cost } setExportOpen(false) } + const header = ( + <> + CONSENSUS REACHED + +
+ + +
+ + ) + + const body = ( + <> + {decision} + +
+ + {copied ? 'Copied' : 'Copy'} + +
+ setExportOpen(!exportOpen)}> + Export + + {exportOpen && ( +
+ + + + +
+ )} +
+
+ + ) + + if (collapsible) { + return ( +
+ + + {body} + {dissent &&
} +
+
+
+ ) + } + return (
@@ -128,50 +198,9 @@ export function ConsensusComplete({ decision, confidence, rigor, dissent, cost }
- - {decision} - -
- - {copied ? 'Copied' : 'Copy'} - -
- setExportOpen(!exportOpen)}> - Export - - {exportOpen && ( -
- - - - -
- )} -
-
+ {body} + {dissent &&
} - - {dissent && } ) } diff --git a/web/src/components/consensus/ConsensusNav.tsx b/web/src/components/consensus/ConsensusNav.tsx new file mode 100644 index 0000000..fcf44e7 --- /dev/null +++ b/web/src/components/consensus/ConsensusNav.tsx @@ -0,0 +1,137 @@ +import { GlassPanel } from '@/components/shared' +import { useConsensusStore } from '@/stores/consensus' + +type PhaseStatus = 'complete' | 'active' | 'pending' + +function getPhaseStatus( + roundNum: number, + phase: string, + currentRound: number, + currentPhase: string | null, + isStreaming: boolean, + roundData: { proposal: string | null; challenges: { model: string; content: string }[]; revision: string | null }, +): PhaseStatus { + if (!isStreaming) { + if (phase === 'PROPOSE' && roundData.proposal) return 'complete' + if (phase === 'CHALLENGE' && roundData.challenges.length > 0) return 'complete' + if (phase === 'REVISE' && roundData.revision) return 'complete' + return 'pending' + } + + if (roundNum < currentRound) return 'complete' + if (roundNum > currentRound) return 'pending' + + const phases = ['PROPOSE', 'CHALLENGE', 'REVISE', 'COMMIT'] + const currentIdx = currentPhase ? phases.indexOf(currentPhase) : -1 + const phaseIdx = phases.indexOf(phase) + + if (phaseIdx < currentIdx) return 'complete' + if (phaseIdx === currentIdx) return 'active' + return 'pending' +} + +function StatusDot({ status }: { status: PhaseStatus }) { + if (status === 'active') { + return + } + if (status === 'complete') { + return + } + return +} + +function shortModel(model: string): string { + const parts = model.split(':') + return parts.length > 1 ? parts[1]! : model +} + +export function ConsensusNav() { + const { status, rounds, currentRound, currentPhase } = useConsensusStore() + + if (rounds.length === 0) return null + + const isStreaming = status === 'connecting' || status === 'streaming' + const isComplete = status === 'complete' + + const scrollTo = (id: string) => { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + return ( + + + PROGRESS + + + + ) +} diff --git a/web/src/components/consensus/ConsensusPanel.tsx b/web/src/components/consensus/ConsensusPanel.tsx index 2cc3e0a..baac70c 100644 --- a/web/src/components/consensus/ConsensusPanel.tsx +++ b/web/src/components/consensus/ConsensusPanel.tsx @@ -13,6 +13,7 @@ export function ConsensusPanel() { } = useConsensusStore() const isActive = status === 'connecting' || status === 'streaming' + const isComplete = status === 'complete' return (
@@ -30,66 +31,80 @@ export function ConsensusPanel() { )} - {(isActive || status === 'complete') && rounds.length > 0 && ( + {isComplete && decision && confidence !== null && ( +
+ +
+ )} + + {(isActive || isComplete) && rounds.length > 0 && (
- {rounds.map((round) => ( -
-
- - ROUND {round.round} - - {isActive && round.round === currentRound && ( - - )} -
+ {rounds.map((round) => { + const isCurrentRound = isActive && round.round === currentRound + const isCompletedRound = !isCurrentRound && (round.round < currentRound || isComplete) - {round.proposer && ( - - )} + return ( +
+
+ + ROUND {round.round} + + {isCurrentRound && ( + + )} +
- {(round.challengers.length > 0 || round.challenges.length > 0) && ( - - )} + {round.proposer && ( + + )} - {round.reviser && ( - - )} + {(round.challengers.length > 0 || round.challenges.length > 0) && ( + + )} - {round.confidence !== null && ( -
- Confidence: {(round.confidence * 100).toFixed(0)}% - {round.rigor !== null && Rigor: {(round.rigor * 100).toFixed(0)}%} - {round.dissent && Dissent noted} -
- )} -
- ))} -
- )} + {round.reviser && ( + + )} - {status === 'complete' && decision && confidence !== null && ( - + {round.confidence !== null && ( +
+ Confidence: {(round.confidence * 100).toFixed(0)}% + {round.rigor !== null && Rigor: {(round.rigor * 100).toFixed(0)}%} + {round.dissent && Dissent noted} +
+ )} +
+ ) + })} +
)} {isActive && ( @@ -100,7 +115,7 @@ export function ConsensusPanel() { )} - {status === 'complete' && ( + {isComplete && (
New Question diff --git a/web/src/components/consensus/DissentBanner.tsx b/web/src/components/consensus/DissentBanner.tsx index f717859..20a9304 100644 --- a/web/src/components/consensus/DissentBanner.tsx +++ b/web/src/components/consensus/DissentBanner.tsx @@ -1,14 +1,32 @@ -import { GlassPanel, Markdown } from '@/components/shared' +import { GlassPanel, Markdown, Disclosure } from '@/components/shared' +import { ModelBadge } from './ModelBadge' + +function parseModelFromDissent(dissent: string): { model: string | null; content: string } { + const match = dissent.match(/^\[([^\]]+)\]:\s*/) + if (match) { + return { model: match[1]!, content: dissent.slice(match[0].length) } + } + return { model: null, content: dissent } +} export function DissentBanner({ dissent }: { dissent: string }) { + const { model, content } = parseModelFromDissent(dissent) + return ( -
- DISSENT + + DISSENT + {model && } + + } + defaultOpen + >
- {dissent} + {content}
-
+
) } diff --git a/web/src/components/consensus/PhaseCard.tsx b/web/src/components/consensus/PhaseCard.tsx index 9c0981e..a1e438c 100644 --- a/web/src/components/consensus/PhaseCard.tsx +++ b/web/src/components/consensus/PhaseCard.tsx @@ -1,4 +1,4 @@ -import { GlassPanel, Markdown } from '@/components/shared' +import { GlassPanel, Markdown, Disclosure } from '@/components/shared' import { ModelBadge } from './ModelBadge' import { StreamingText } from './StreamingText' @@ -9,24 +9,24 @@ interface PhaseCardProps { content?: string | null isActive?: boolean challenges?: Array<{ model: string; content: string }> + collapsible?: boolean + defaultOpen?: boolean } -export function PhaseCard({ phase, model, models, content, isActive, challenges }: PhaseCardProps) { - return ( - -
- {phase} - {isActive && ( - - )} - {model && } - {models?.map((m) => )} -
+export function PhaseCard({ phase, model, models, content, isActive, challenges, collapsible, defaultOpen = true }: PhaseCardProps) { + const header = ( + <> + {phase} + {isActive && ( + + )} + {model && } + {models?.map((m) => )} + + ) + const body = ( + <> {content && (
{isActive ? ( @@ -40,12 +40,16 @@ export function PhaseCard({ phase, model, models, content, isActive, challenges {challenges && challenges.length > 0 && (
{challenges.map((ch, i) => ( -
- -
+ } + className="pl-3 border-l-2 border-[var(--color-amber)]/30" + > +
{ch.content}
-
+ ))}
)} @@ -59,6 +63,31 @@ export function PhaseCard({ phase, model, models, content, isActive, challenges Processing...
)} + + ) + + if (collapsible) { + return ( + + + {body} + + + ) + } + + return ( + +
{header}
+ {body}
) } diff --git a/web/src/components/consensus/index.ts b/web/src/components/consensus/index.ts index 9c98754..8cde94b 100644 --- a/web/src/components/consensus/index.ts +++ b/web/src/components/consensus/index.ts @@ -7,3 +7,4 @@ export { DissentBanner } from './DissentBanner' export { StreamingText } from './StreamingText' export { ModelBadge } from './ModelBadge' export { CostTicker } from './CostTicker' +export { ConsensusNav } from './ConsensusNav' diff --git a/web/src/components/shared/Disclosure.tsx b/web/src/components/shared/Disclosure.tsx new file mode 100644 index 0000000..8de5890 --- /dev/null +++ b/web/src/components/shared/Disclosure.tsx @@ -0,0 +1,33 @@ +import { type ReactNode, useState } from 'react' + +interface DisclosureProps { + children: ReactNode + header: ReactNode + defaultOpen?: boolean + forceOpen?: boolean + className?: string +} + +export function Disclosure({ children, header, defaultOpen = true, forceOpen, className = '' }: DisclosureProps) { + const [open, setOpen] = useState(defaultOpen) + const isOpen = forceOpen || open + + return ( +
+
{ if (!forceOpen) setOpen(!open) }} + > + + + + {header} +
+ {isOpen && children} +
+ ) +} diff --git a/web/src/components/shared/index.ts b/web/src/components/shared/index.ts index c15355e..20bfd7e 100644 --- a/web/src/components/shared/index.ts +++ b/web/src/components/shared/index.ts @@ -8,3 +8,4 @@ export { ParticleField } from './ParticleField' export { PageTransition } from './PageTransition' export { Markdown } from './Markdown' export { ExportMenu } from './ExportMenu' +export { Disclosure } from './Disclosure' diff --git a/web/src/components/threads/ThreadDetail.tsx b/web/src/components/threads/ThreadDetail.tsx index c79ee41..4fc9e8c 100644 --- a/web/src/components/threads/ThreadDetail.tsx +++ b/web/src/components/threads/ThreadDetail.tsx @@ -2,7 +2,9 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useThreadsStore } from '@/stores' import { TurnCard } from './TurnCard' -import { GlassPanel, GlowButton, Skeleton, Badge, ExportMenu } from '@/components/shared' +import { GlassPanel, GlowButton, Skeleton, Badge, ExportMenu, Markdown, Disclosure } from '@/components/shared' +import { ConfidenceMeter } from '@/components/consensus/ConfidenceMeter' +import { DissentBanner } from '@/components/consensus/DissentBanner' function formatDate(iso: string): string { return new Date(iso).toLocaleString('en-US', { @@ -54,6 +56,10 @@ export function ThreadDetail() { setFeedbackSent(true) } + // Find the final decision from the last turn + const lastTurn = currentThread.turns[currentThread.turns.length - 1] + const finalDecision = currentThread.status === 'complete' && lastTurn?.decision ? lastTurn.decision : null + return (
@@ -74,30 +80,66 @@ export function ThreadDetail() {
+ {finalDecision && ( +
+ + + DECISION +
+ + +
+ + } + defaultOpen + > +
+ {finalDecision.content} +
+ {finalDecision.dissent && ( +
+ +
+ )} +
+
+
+ )} + {currentThread.turns.map((turn, i) => ( - +
+ 1} + defaultOpen={!finalDecision && i === currentThread.turns.length - 1} + /> +
))} - {currentThread.status === 'complete' && !feedbackSent && ( - -

How was this decision?

-
- handleFeedback('success')}> - Success - - handleFeedback('partial')}> - Partial - - handleFeedback('failure')}> - Failure - -
-
- )} +
+ {currentThread.status === 'complete' && !feedbackSent && ( + +

How was this decision?

+
+ handleFeedback('success')}> + Success + + handleFeedback('partial')}> + Partial + + handleFeedback('failure')}> + Failure + +
+
+ )} - {feedbackSent && ( -

Feedback recorded

- )} + {feedbackSent && ( +

Feedback recorded

+ )} +
navigate('/threads')}> diff --git a/web/src/components/threads/ThreadNav.tsx b/web/src/components/threads/ThreadNav.tsx new file mode 100644 index 0000000..e8440fd --- /dev/null +++ b/web/src/components/threads/ThreadNav.tsx @@ -0,0 +1,60 @@ +import { GlassPanel } from '@/components/shared' +import { useThreadsStore } from '@/stores/threads' + +export function ThreadNav() { + const thread = useThreadsStore((s) => s.currentThread) + + if (!thread || thread.turns.length === 0) return null + + const scrollTo = (id: string) => { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + return ( + + + ROUNDS + + + + ) +} diff --git a/web/src/components/threads/TurnCard.tsx b/web/src/components/threads/TurnCard.tsx index c098090..3ffae28 100644 --- a/web/src/components/threads/TurnCard.tsx +++ b/web/src/components/threads/TurnCard.tsx @@ -1,35 +1,56 @@ -import { GlassPanel, Markdown } from '@/components/shared' +import { GlassPanel, Markdown, Disclosure } from '@/components/shared' import { ModelBadge } from '@/components/consensus/ModelBadge' import { ConfidenceMeter } from '@/components/consensus/ConfidenceMeter' +import { DissentBanner } from '@/components/consensus/DissentBanner' import type { Turn } from '@/api/types' -export function TurnCard({ turn }: { turn: Turn }) { - return ( - -
- - ROUND {turn.round_number} - - {turn.state} -
+interface TurnCardProps { + turn: Turn + collapsible?: boolean + defaultOpen?: boolean +} + +export function TurnCard({ turn, collapsible, defaultOpen = true }: TurnCardProps) { + const header = ( + <> + + ROUND {turn.round_number} + + {turn.state} + + ) + + const collapsedPreview = turn.decision && ( + + {Math.round(turn.decision.confidence * 100)}% + + ) + const body = ( +
{turn.contributions.map((contrib, i) => ( -
-
- - {contrib.role} - - - {contrib.cost_usd > 0 && ( - - ${contrib.cost_usd.toFixed(4)} + + + {contrib.role} - )} -
+ + {contrib.cost_usd > 0 && ( + + ${contrib.cost_usd.toFixed(4)} + + )} + + } + className="pl-3 border-l-2 border-[var(--color-border)]" + >
{contrib.content}
-
+ ))} {turn.decision && ( @@ -41,8 +62,8 @@ export function TurnCard({ turn }: { turn: Turn }) { {turn.decision.content}
{turn.decision.dissent && ( -
- {`**Dissent:** ${turn.decision.dissent}`} +
+
)}
@@ -53,6 +74,26 @@ export function TurnCard({ turn }: { turn: Turn }) {
)} +
+ ) + + if (collapsible) { + return ( + + {header}{collapsedPreview}} + defaultOpen={defaultOpen} + > + {body} + + + ) + } + + return ( + +
{header}
+ {body}
) } diff --git a/web/src/components/threads/index.ts b/web/src/components/threads/index.ts index 0309da7..43818e5 100644 --- a/web/src/components/threads/index.ts +++ b/web/src/components/threads/index.ts @@ -4,3 +4,4 @@ export { ThreadSearch } from './ThreadSearch' export { ThreadFilters } from './ThreadFilters' export { ThreadDetail } from './ThreadDetail' export { TurnCard } from './TurnCard' +export { ThreadNav } from './ThreadNav' diff --git a/web/src/pages/ConsensusPage.tsx b/web/src/pages/ConsensusPage.tsx index b7fb6ba..f8059b4 100644 --- a/web/src/pages/ConsensusPage.tsx +++ b/web/src/pages/ConsensusPage.tsx @@ -1,11 +1,20 @@ -import { ConsensusPanel } from '@/components/consensus' +import { ConsensusPanel, ConsensusNav } from '@/components/consensus' import { PageTransition } from '@/components/shared' export function ConsensusPage() { return ( -
- +
+
+
+ +
+
+
+ +
+
+
) diff --git a/web/src/pages/ThreadDetailPage.tsx b/web/src/pages/ThreadDetailPage.tsx index e28d838..12a5317 100644 --- a/web/src/pages/ThreadDetailPage.tsx +++ b/web/src/pages/ThreadDetailPage.tsx @@ -1,11 +1,20 @@ -import { ThreadDetail } from '@/components/threads' +import { ThreadDetail, ThreadNav } from '@/components/threads' import { PageTransition } from '@/components/shared' export function ThreadDetailPage() { return ( -
- +
+
+
+ +
+
+
+ +
+
+
)