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 && ( + + handleExportMarkdown('decision')} + > + Markdown (decision only) + + handleExportMarkdown('full')} + > + Markdown (full report) + + handleExportPdf('decision')} + > + PDF (decision only) + + handleExportPdf('full')} + > + PDF (full report) + + + )} + + + > + ) + + 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 && ( - - handleExportMarkdown('decision')} - > - Markdown (decision only) - - handleExportMarkdown('full')} - > - Markdown (full report) - - handleExportPdf('decision')} - > - PDF (decision only) - - handleExportPdf('full')} - > - PDF (full report) - - - )} - - + {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 + + + {isComplete && ( + scrollTo('consensus-complete')} + > + + DECISION + + )} + + {rounds.map((round) => { + const challengeStatus = getPhaseStatus(round.round, 'CHALLENGE', currentRound, currentPhase, isStreaming, round) + + return ( + + scrollTo(`consensus-round-${round.round}`)} + > + ROUND {round.round} + + + {round.proposer && ( + scrollTo(`consensus-round-${round.round}-propose`)} + > + + PROPOSE + + )} + {round.challenges.length > 0 ? ( + round.challenges.map((ch, i) => ( + scrollTo(`consensus-round-${round.round}-challenge`)} + > + + {shortModel(ch.model)} + + )) + ) : round.challengers.length > 0 ? ( + round.challengers.map((model, i) => ( + scrollTo(`consensus-round-${round.round}-challenge`)} + > + + {shortModel(model)} + + )) + ) : null} + {round.reviser && ( + scrollTo(`consensus-round-${round.round}-revise`)} + > + + REVISE + + )} + + + ) + })} + + + + ) +} 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 + + + {thread.status === 'complete' && thread.turns.some((t) => t.decision) && ( + scrollTo('thread-decision')} + > + + DECISION + + )} + + {thread.turns.map((turn) => { + const hasDecision = !!turn.decision + return ( + scrollTo(`thread-round-${turn.round_number}`)} + > + + ROUND {turn.round_number} + {hasDecision && ( + + {Math.round(turn.decision!.confidence * 100)}% + + )} + + ) + })} + + {thread.status === 'complete' && ( + scrollTo('thread-feedback')} + > + + FEEDBACK + + )} + + + ) +} 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 ( - - + + + + + + + + + + + )
Content
Hidden
Body
Always visible
C
How was this decision?
Feedback recorded