feat(briefs): Feature briefs UI in SummaryPanel (T10.6, M1.5)
This commit is contained in:
1 parent
63bfc293cb
commit
02fb7de6a8
1 file changed
+296
@@ -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>
|
||||
Reference in new issue
Block a user