Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/duh/api/routes/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/test_api_threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions web/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export interface ThreadDetail {
question: string
status: string
created_at: string
overview?: string | null
turns: Turn[]
followups?: string[]
usage?: Usage | null
Expand Down
152 changes: 48 additions & 104 deletions web/src/components/consensus/ConsensusComplete.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
const { question, rounds, threadId } = useConsensusStore()
Expand All @@ -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')
Expand All @@ -140,105 +131,58 @@ export function ConsensusComplete({ decision, confidence, rigor, dissent, cost,
setExportOpen(false)
}

const header = (
<>
<span className="font-mono text-xs text-[var(--color-green)] font-semibold">CONSENSUS REACHED</span>
<CostTicker cost={cost} usage={usage} />
<div className="flex items-center gap-3 ml-auto">
<ConfidenceMeter value={confidence} label="Confidence" />
<ConfidenceMeter value={rigor} size={48} label="Rigor" />
</div>
</>
)

const actions = (
<div className="flex gap-2 mb-4">
<GlowButton variant="ghost" size="sm" onClick={handleCopy}>
{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 = (
<div className="relative" ref={exportRef}>
<GlowButton variant="ghost" size="sm" onClick={() => setExportOpen(!exportOpen)}>
Export
</GlowButton>
<div className="relative" ref={exportRef}>
<GlowButton variant="ghost" size="sm" onClick={() => setExportOpen(!exportOpen)}>
Export
</GlowButton>
{exportOpen && (
<div className="absolute top-full left-0 mt-1 bg-[var(--color-surface-solid)] border border-[var(--color-border)] rounded-[var(--radius-md)] shadow-lg py-1 min-w-[200px] z-[var(--z-dropdown)]">
<button
className="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--color-surface-hover)] text-[var(--color-text)]"
onClick={() => handleExportMarkdown('decision')}
>
Markdown (decision only)
</button>
<button
className="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--color-surface-hover)] text-[var(--color-text)]"
onClick={() => handleExportMarkdown('full')}
>
Markdown (full report)
</button>
<button
className="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--color-surface-hover)] text-[var(--color-text)]"
onClick={() => handleExportPdf('decision')}
>
PDF (decision only)
</button>
<button
className="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--color-surface-hover)] text-[var(--color-text)]"
onClick={() => handleExportPdf('full')}
>
PDF (full report)
</button>
</div>
)}
</div>
</div>
)

const body = (
<>
{actions}
{overview ? (
<>
<Markdown className="text-sm">{overview}</Markdown>
<div className="mt-4">
<Disclosure header={<span className="font-mono text-xs text-[var(--color-text-dim)]">Full Decision</span>} defaultOpen={false}>
<Markdown className="text-sm">{decision}</Markdown>
</Disclosure>
</div>
</>
) : (
<Markdown className="text-sm">{decision}</Markdown>
{exportOpen && (
<div className="absolute top-full left-0 mt-1 bg-[var(--color-surface-solid)] border border-[var(--color-border)] rounded-[var(--radius-md)] shadow-lg py-1 min-w-[200px] z-[var(--z-dropdown)]">
<button
className="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--color-surface-hover)] text-[var(--color-text)]"
onClick={() => handleExportMarkdown('decision')}
>
Markdown (decision only)
</button>
<button
className="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--color-surface-hover)] text-[var(--color-text)]"
onClick={() => handleExportMarkdown('full')}
>
Markdown (full report)
</button>
<button
className="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--color-surface-hover)] text-[var(--color-text)]"
onClick={() => handleExportPdf('decision')}
>
PDF (decision only)
</button>
<button
className="w-full text-left px-3 py-1.5 text-xs hover:bg-[var(--color-surface-hover)] text-[var(--color-text)]"
onClick={() => handleExportPdf('full')}
>
PDF (full report)
</button>
</div>
)}
</>
</div>
)

if (collapsible) {
return (
<div className="space-y-4 animate-fade-in-up">
<GlassPanel glow="strong" padding="lg">
<Disclosure header={header} defaultOpen>
{body}
{dissent && <div className="mt-4"><DissentBanner dissent={dissent} /></div>}
</Disclosure>
</GlassPanel>
</div>
)
}

return (
<div className="space-y-4 animate-fade-in-up">
<GlassPanel glow="strong" padding="lg">
<div className="flex items-start justify-between gap-4 mb-4">
<div className="flex items-center gap-3">
<span className="font-mono text-xs text-[var(--color-green)] font-semibold">CONSENSUS REACHED</span>
<CostTicker cost={cost} usage={usage} />
</div>
<div className="flex items-center gap-3">
<ConfidenceMeter value={confidence} label="Confidence" />
<ConfidenceMeter value={rigor} size={48} label="Rigor" />
</div>
</div>
{body}
{dissent && <div className="mt-4"><DissentBanner dissent={dissent} /></div>}
</GlassPanel>
<ConsensusReport
label="CONSENSUS REACHED"
decision={decision}
overview={overview}
confidence={confidence}
rigor={rigor}
dissent={dissent}
cost={cost}
usage={usage}
exportSlot={exportSlot}
collapsible={collapsible}
/>
</div>
)
}
110 changes: 110 additions & 0 deletions web/src/components/consensus/ConsensusReport.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<>
<span className="font-mono text-xs text-[var(--color-green)] font-semibold">{label}</span>
<CostTicker cost={cost} usage={usage} />
<div className="flex items-center gap-3 ml-auto">
<ConfidenceMeter value={confidence} label="Confidence" />
<ConfidenceMeter value={rigor} size={48} label="Rigor" />
</div>
</>
)

const body = (
<>
<div className="flex gap-2 mb-4">
<GlowButton variant="ghost" size="sm" onClick={handleCopy}>
{copied ? 'Copied' : 'Copy'}
</GlowButton>
{exportSlot}
</div>

{overview ? (
<>
<Markdown className="text-sm">{overview}</Markdown>
<div className="mt-4">
<Disclosure
header={<span className="font-mono text-xs text-[var(--color-text-dim)]">Full Decision</span>}
defaultOpen={false}
>
<Markdown className="text-sm">{decision}</Markdown>
</Disclosure>
</div>
</>
) : (
<Markdown className="text-sm">{decision}</Markdown>
)}

{dissent && (
<div className="mt-4">
<DissentBanner dissent={dissent} defaultOpen={false} />
</div>
)}
</>
)

return (
<GlassPanel glow="strong" padding="lg" className="animate-fade-in-up">
{collapsible ? (
<Disclosure header={header} defaultOpen>
{body}
</Disclosure>
) : (
<>
<div className="flex items-center gap-3 mb-4">{header}</div>
{body}
</>
)}
</GlassPanel>
)
}
Loading
Loading