diff --git a/src/duh/api/routes/threads.py b/src/duh/api/routes/threads.py index 3c1b2d7..ee9a38c 100644 --- a/src/duh/api/routes/threads.py +++ b/src/duh/api/routes/threads.py @@ -61,6 +61,7 @@ class ThreadDetailResponse(BaseModel): question: str status: str created_at: str + overview: str | None = None turns: list[TurnResponse] = Field(default_factory=list) followups: list[str] = Field(default_factory=list) usage: UsageSummary = UsageSummary() @@ -236,11 +237,15 @@ def _build_thread_detail(thread: object) -> ThreadDetailResponse: total_out = int(stored.get("output_tokens", total_out)) total_cost = float(stored.get("cost_usd", total_cost)) + summary = getattr(thread, "summary", None) + overview = summary.summary if summary else None + return ThreadDetailResponse( thread_id=thread.id, # type: ignore[attr-defined] question=thread.question, # type: ignore[attr-defined] status=thread.status, # type: ignore[attr-defined] created_at=thread.created_at.isoformat(), # type: ignore[attr-defined] + overview=overview, turns=turns, followups=followups, usage=UsageSummary( diff --git a/tests/unit/test_api_threads.py b/tests/unit/test_api_threads.py index 2e0ef5f..bb6ea38 100644 --- a/tests/unit/test_api_threads.py +++ b/tests/unit/test_api_threads.py @@ -238,6 +238,33 @@ async def test_returns_thread_without_decision(self) -> None: assert turn["decision"] is None assert len(turn["contributions"]) == 1 + async def test_returns_overview(self) -> None: + """The executive overview (thread summary) is returned for history.""" + app = await _make_app() + async with app.state.db_factory() as session: + repo = MemoryRepository(session) + thread = await repo.create_thread("Has overview") + await repo.save_thread_summary( + thread.id, "Executive summary text.", "overview" + ) + thread.status = "complete" + await session.commit() + tid = thread.id + + client = TestClient(app, raise_server_exceptions=False) + resp = client.get(f"/api/threads/{tid}") + assert resp.status_code == 200 + assert resp.json()["overview"] == "Executive summary text." + + async def test_overview_null_when_absent(self) -> None: + """Threads without a summary report a null overview.""" + app = await _make_app() + tid = await _seed_thread(app, "No overview") + client = TestClient(app, raise_server_exceptions=False) + resp = client.get(f"/api/threads/{tid}") + assert resp.status_code == 200 + assert resp.json()["overview"] is None + async def test_usage_prefers_stored_usage_json(self) -> None: """Run-level usage_json overrides the per-contribution sum.""" app = await _make_app() diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 1012690..e33fb7b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -141,6 +141,7 @@ export interface ThreadDetail { question: string status: string created_at: string + overview?: string | null turns: Turn[] followups?: string[] usage?: Usage | null diff --git a/web/src/components/consensus/ConsensusComplete.tsx b/web/src/components/consensus/ConsensusComplete.tsx index 84035da..e26ff7b 100644 --- a/web/src/components/consensus/ConsensusComplete.tsx +++ b/web/src/components/consensus/ConsensusComplete.tsx @@ -1,8 +1,6 @@ import { useState, useRef, useEffect } from 'react' -import { GlassPanel, GlowButton, Markdown, Disclosure } from '@/components/shared' -import { ConfidenceMeter } from './ConfidenceMeter' -import { DissentBanner } from './DissentBanner' -import { CostTicker } from './CostTicker' +import { GlowButton } from '@/components/shared' +import { ConsensusReport } from './ConsensusReport' import { useConsensusStore } from '@/stores/consensus' import type { RoundData } from '@/stores/consensus' import type { Usage } from '@/api/types' @@ -101,7 +99,6 @@ function downloadFile(content: string | Blob, filename: string, mimeType: string } export function ConsensusComplete({ decision, confidence, rigor, dissent, cost, usage, collapsible, overview }: ConsensusCompleteProps) { - const [copied, setCopied] = useState(false) const [exportOpen, setExportOpen] = useState(false) const exportRef = useRef(null) const { question, rounds, threadId } = useConsensusStore() @@ -118,12 +115,6 @@ export function ConsensusComplete({ decision, confidence, rigor, dissent, cost, return () => document.removeEventListener('mousedown', handler) }, [exportOpen]) - const handleCopy = async () => { - await navigator.clipboard.writeText(overview ?? decision) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - const handleExportMarkdown = (content: 'full' | 'decision') => { const md = generateExportMarkdown(question, decision, confidence, rigor, dissent, cost, rounds, content, true, overview) downloadFile(md, `consensus-${content}.md`, 'text/markdown') @@ -140,105 +131,58 @@ export function ConsensusComplete({ decision, confidence, rigor, dissent, cost, setExportOpen(false) } - const header = ( - <> - CONSENSUS REACHED - -
- - -
- - ) - - const actions = ( -
- - {copied ? 'Copied' : 'Copy'} + // Live export uses round data from the store (the thread may not be loaded + // as a ThreadDetail yet). The stored view uses the shared ExportMenu instead. + const exportSlot = ( +
+ setExportOpen(!exportOpen)}> + Export -
- setExportOpen(!exportOpen)}> - Export - - {exportOpen && ( -
- - - - -
- )} -
-
- ) - - const body = ( - <> - {actions} - {overview ? ( - <> - {overview} -
- Full Decision} defaultOpen={false}> - {decision} - -
- - ) : ( - {decision} + {exportOpen && ( +
+ + + + +
)} - +
) - if (collapsible) { - return ( -
- - - {body} - {dissent &&
} -
-
-
- ) - } - return (
- -
-
- CONSENSUS REACHED - -
-
- - -
-
- {body} - {dissent &&
} -
+
) } diff --git a/web/src/components/consensus/ConsensusReport.tsx b/web/src/components/consensus/ConsensusReport.tsx new file mode 100644 index 0000000..54680ad --- /dev/null +++ b/web/src/components/consensus/ConsensusReport.tsx @@ -0,0 +1,110 @@ +import { useState, type ReactNode } from 'react' +import { GlassPanel, GlowButton, Markdown, Disclosure } from '@/components/shared' +import { ConfidenceMeter } from './ConfidenceMeter' +import { DissentBanner } from './DissentBanner' +import { CostTicker } from './CostTicker' +import type { Usage } from '@/api/types' + +interface ConsensusReportProps { + /** Header label, e.g. "CONSENSUS REACHED". */ + label: string + /** The full decision text (markdown). */ + decision: string + /** Executive summary; when present it leads and the decision collapses. */ + overview?: string | null + confidence: number + rigor: number + dissent?: string | null + cost?: number | null + usage?: Usage | null + /** View-specific export control rendered next to Copy. */ + exportSlot?: ReactNode + /** Wrap the whole report in a Disclosure (live view collapses on follow-up). */ + collapsible?: boolean +} + +/** + * The unified consensus decision report, shared by the live view + * (ConsensusComplete) and the stored thread view (ThreadDetail) so both + * render identically: meters, Copy/Export, an executive summary that leads + * with the full decision tucked into a disclosure, and dissent. + */ +export function ConsensusReport({ + label, + decision, + overview, + confidence, + rigor, + dissent, + cost = null, + usage = null, + exportSlot, + collapsible = false, +}: ConsensusReportProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard.writeText(overview ?? decision) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const header = ( + <> + {label} + +
+ + +
+ + ) + + const body = ( + <> +
+ + {copied ? 'Copied' : 'Copy'} + + {exportSlot} +
+ + {overview ? ( + <> + {overview} +
+ Full Decision} + defaultOpen={false} + > + {decision} + +
+ + ) : ( + {decision} + )} + + {dissent && ( +
+ +
+ )} + + ) + + return ( + + {collapsible ? ( + + {body} + + ) : ( + <> +
{header}
+ {body} + + )} +
+ ) +} diff --git a/web/src/components/threads/ThreadDetail.tsx b/web/src/components/threads/ThreadDetail.tsx index c0cdb8e..5f01d7d 100644 --- a/web/src/components/threads/ThreadDetail.tsx +++ b/web/src/components/threads/ThreadDetail.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useThreadsStore } from '@/stores' -import { GlassPanel, GlowButton, Skeleton, Badge, ExportMenu, Markdown, Disclosure } from '@/components/shared' -import { ConfidenceMeter } from '@/components/consensus/ConfidenceMeter' -import { DissentBanner } from '@/components/consensus/DissentBanner' +import { GlassPanel, GlowButton, Skeleton, Badge, ExportMenu } from '@/components/shared' +import { ConsensusReport } from '@/components/consensus/ConsensusReport' import { PhaseCard } from '@/components/consensus/PhaseCard' import { CostTicker } from '@/components/consensus/CostTicker' import type { Turn } from '@/api/types' @@ -99,36 +98,23 @@ export function ThreadDetail() { {currentThread.status} - {currentThread.status === 'complete' && } {finalDecision && (
- - - DECISION -
- - -
- - } - defaultOpen - > -
- {finalDecision.content} -
- {finalDecision.dissent && ( -
- -
- )} -
-
+ } + />
)}