feat(briefs): Feature briefs UI in SummaryPanel (T10.6, M1.5)

This commit is contained in:
iamdoubz committed 2026-07-07 00:36:20 -05:00
1 parent 63bfc293cb
commit 02fb7de6a8
1 file changed
+296
+296
View File
@@ -9,6 +9,8 @@
errorMessage,
type ActionItem,
type CalendarEventDetail,
type FeatureBrief,
type FeatureBriefInfo,
type LlmStatus,
} from "../api";
import { renderMarkdown } from "../markdown";
@@ -22,6 +24,9 @@
Mic2,
Bell,
UploadCloud,
FileText,
Copy,
Check,
} from "@lucide/svelte";
onMount(() => calendar.load());
@@ -250,6 +255,120 @@
generatingTags = false;
}
}
// ---- Feature briefs (Phase 10 M1, ADR-0011, FR-MCP-4) ----
// Agent-ready specs distilled from the meeting via the configured LLM
// provider. `exposed` (MCP scope control, FR-MCP-3) lives only on the list
// row (FeatureBriefInfo) — the full brief the viewer shows doesn't carry it.
let briefs = $state<FeatureBriefInfo[]>([]);
let selectedBriefId = $state<string | null>(null);
let selectedBrief = $state<FeatureBrief | null>(null);
let briefTargetRepo = $state("");
let creatingBrief = $state(false);
let briefError = $state<string | null>(null);
let loadingBriefsForId: string | null = null;
$effect(() => {
const m = meetings.selected;
if (!m) {
briefs = [];
selectedBriefId = null;
selectedBrief = null;
loadingBriefsForId = null;
return;
}
if (m.id === loadingBriefsForId) return;
loadingBriefsForId = m.id;
selectedBriefId = null;
selectedBrief = null;
api
.listFeatureBriefs(m.id)
.then((list) => (briefs = list))
.catch(() => (briefs = []));
});
async function createBrief() {
const m = meetings.selected;
if (!m) return;
creatingBrief = true;
briefError = null;
try {
const brief = await api.createFeatureBrief(m.id, briefTargetRepo.trim() || undefined);
briefs = [
{
id: brief.id,
meeting_id: brief.meeting_id,
title: brief.title,
target_repo: brief.target_repo,
exposed: false,
},
...briefs,
];
selectedBriefId = brief.id;
selectedBrief = brief;
briefTargetRepo = "";
} catch (e) {
briefError = errorMessage(e);
} finally {
creatingBrief = false;
}
}
async function openBrief(id: string) {
selectedBriefId = id;
briefError = null;
try {
selectedBrief = await api.getFeatureBrief(id);
} catch (e) {
selectedBrief = null;
briefError = errorMessage(e);
}
}
async function toggleBriefExposed(brief: FeatureBriefInfo) {
const next = !brief.exposed;
try {
await api.setBriefExposed(brief.id, next);
briefs = briefs.map((b) => (b.id === brief.id ? { ...b, exposed: next } : b));
} catch (e) {
briefError = errorMessage(e);
}
}
function briefAsMarkdown(b: FeatureBrief): string {
const criteria = b.acceptance_criteria.length
? b.acceptance_criteria.map((c) => `- ${c}`).join("\n")
: "- None";
let md =
`## Title\n${b.title}\n\n` +
`## Problem\n${b.problem}\n\n` +
`## Desired Outcome\n${b.desired_outcome}\n\n` +
`## Acceptance Criteria\n${criteria}`;
if (b.context_excerpts.length) {
md += `\n\n## Context\n${b.context_excerpts.map((e) => `> **${e.speaker}:** ${e.text}`).join("\n\n")}`;
}
return md;
}
// Brief copy feedback (T10.6, M1.5): a transient checkmark rather than a
// toast — consistent with this panel having no toast system elsewhere.
let copiedFormat = $state<"md" | "json" | null>(null);
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
function flashCopied(format: "md" | "json") {
copiedFormat = format;
clearTimeout(copiedTimer);
copiedTimer = setTimeout(() => (copiedFormat = null), 1500);
}
async function copyBriefMarkdown() {
if (!selectedBrief) return;
await navigator.clipboard.writeText(briefAsMarkdown(selectedBrief));
flashCopied("md");
}
async function copyBriefJson() {
if (!selectedBrief) return;
await navigator.clipboard.writeText(JSON.stringify(selectedBrief, null, 2));
flashCopied("json");
}
</script>
<div class="wrap">
@@ -391,6 +510,91 @@
<p class="error">{meetings.summaryError}</p>
{/if}
<h3><FileText size={14} aria-hidden="true" /> Feature briefs</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to create or view feature briefs.</p>
{:else}
<p class="muted small">
Distills this meeting into an agent-ready spec (requires a local LLM provider) — hand it to a
coding agent, or serve it over the MCP server once that's on.
</p>
<div class="brief-create">
<input
type="text"
placeholder="Target repo (optional), e.g. acme/reporting-web"
bind:value={briefTargetRepo}
disabled={creatingBrief}
/>
<button class="primary" onclick={createBrief} disabled={creatingBrief}>
<Sparkles size={14} aria-hidden="true" />
{creatingBrief ? "Distilling…" : "Create feature brief"}
</button>
</div>
{#if briefError}
<p class="error">{briefError}</p>
{/if}
{#if briefs.length > 0}
<ul class="briefs">
{#each briefs as b (b.id)}
<li class:active={b.id === selectedBriefId}>
<button class="brief-title" onclick={() => openBrief(b.id)}>
{b.title}
{#if b.target_repo}<span class="muted small">{b.target_repo}</span>{/if}
</button>
<label class="expose" title="Available when the MCP server is on">
<input type="checkbox" checked={b.exposed} onchange={() => toggleBriefExposed(b)} />
<span class="muted small">MCP</span>
</label>
</li>
{/each}
</ul>
{/if}
{#if selectedBrief}
<div class="brief-viewer">
<div class="brief-actions">
<button class="link" onclick={copyBriefMarkdown}>
{#if copiedFormat === "md"}
<Check size={13} aria-hidden="true" /> Copied
{:else}
<Copy size={13} aria-hidden="true" /> Copy as Markdown
{/if}
</button>
<button class="link" onclick={copyBriefJson}>
{#if copiedFormat === "json"}
<Check size={13} aria-hidden="true" /> Copied
{:else}
<Copy size={13} aria-hidden="true" /> Copy as JSON
{/if}
</button>
</div>
<h4>Problem</h4>
<p class="brief-text">{selectedBrief.problem}</p>
<h4>Desired outcome</h4>
<p class="brief-text">{selectedBrief.desired_outcome}</p>
<h4>Acceptance criteria</h4>
{#if selectedBrief.acceptance_criteria.length}
<ul class="decisions">
{#each selectedBrief.acceptance_criteria as c, i (i)}
<li>{c}</li>
{/each}
</ul>
{:else}
<p class="muted small">None captured.</p>
{/if}
{#if selectedBrief.context_excerpts.length}
<h4>Context</h4>
<ul class="excerpts">
{#each selectedBrief.context_excerpts as e, i (i)}
<li><span class="speaker">{e.speaker}:</span> "{e.text}"</li>
{/each}
</ul>
{/if}
</div>
{/if}
{/if}
<h3><ListTodo size={14} aria-hidden="true" /> Action items</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to see its action items.</p>
@@ -761,4 +965,96 @@
.err {
cursor: help;
}
/* ---- Feature briefs (Phase 10 M1, ADR-0011) ---- */
.brief-create {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.3rem 0;
}
.brief-create input {
flex: 1;
min-width: 0;
}
ul.briefs {
list-style: none;
padding: 0;
margin: 0.4rem 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
ul.briefs li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
}
ul.briefs li.active {
border-color: var(--accent);
background: var(--bg-hover);
}
.brief-title {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
padding: 0.35rem 0.4rem;
font-size: 0.85rem;
color: var(--fg);
}
.expose {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.4rem;
cursor: pointer;
}
.brief-viewer {
margin-top: 0.5rem;
padding: 0.6rem 0.7rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.brief-actions {
display: flex;
gap: 0.9rem;
margin-bottom: 0.4rem;
}
.brief-actions .link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.brief-text {
font-size: 0.85rem;
line-height: 1.5;
margin: 0.2rem 0 0.4rem;
white-space: pre-wrap;
}
ul.excerpts {
list-style: none;
padding: 0;
margin: 0.2rem 0;
font-size: 0.82rem;
color: var(--muted);
}
ul.excerpts li {
padding: 0.2rem 0;
font-style: italic;
}
ul.excerpts .speaker {
font-style: normal;
font-weight: 600;
color: var(--fg);
}
</style>