Files
WhispAssist/src/lib/views/SummaryPanel.svelte
T
iamdoubz a552ddb1fa fix(ui): action items as two-row cards so the action text is always readable in the narrow pane
Text row (confirm + full-width text + delete) over a wrapping meta row
(owner, due date, reminder). Also void the seekNonce dependency read to
satisfy no-unused-expressions.
2026-07-15 17:41:38 -05:00

1380 lines
40 KiB
Svelte

<script lang="ts">
// Right pane: LLM summary, action items, participants (Phase 5/6, FR-LLM-*/FR-CAL-*).
import { onMount } from "svelte";
import { meetings } from "../stores/meetings.svelte";
import { calendar } from "../stores/calendar.svelte";
import { settings } from "../stores/settings.svelte";
import { player } from "../stores/player.svelte";
import {
api,
errorMessage,
type ActionItem,
type CalendarEventDetail,
type FeatureBrief,
type FeatureBriefInfo,
type LlmStatus,
} from "../api";
import { renderMarkdown } from "../markdown";
import { t } from "../i18n/index.svelte";
import TagChip from "../components/TagChip.svelte";
import HostedAiBanner from "../components/HostedAiBanner.svelte";
import {
Tags,
Sparkles,
ListTodo,
CalendarDays,
Users,
Mic2,
Bell,
UploadCloud,
FileText,
Copy,
Check,
Cpu,
Globe,
Plus,
X,
} from "@lucide/svelte";
onMount(() => calendar.load());
// ---- Recording playback (FR-REC-5) ----
let audioSrc = $state<string | null>(null);
let audioError = $state<string | null>(null);
let audioEl = $state<HTMLAudioElement | null>(null);
$effect(() => {
const m = meetings.selected;
audioSrc = null;
audioError = null;
player.reset();
if (m?.recorded) {
const id = m.id;
api
.recordingPlaybackPath(id)
.then((url) => {
if (meetings.selected?.id === id) audioSrc = url;
})
.catch((e) => {
if (meetings.selected?.id === id) audioError = errorMessage(e);
});
}
});
// Seek + play when a transcript segment is clicked (player.seek bumps the
// nonce so re-clicking the same segment still jumps). Reading seekNonce is
// what makes this effect re-run.
$effect(() => {
void player.seekNonce;
const ms = player.seekMs;
if (ms == null || !audioEl) return;
audioEl.currentTime = ms / 1000;
void audioEl.play().catch(() => {});
});
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
let llmStatus = $state<LlmStatus | null>(null);
async function refreshLlmStatus() {
try {
llmStatus = await api.llmStatus();
} catch {
llmStatus = null;
}
}
onMount(refreshLlmStatus);
function generateSummary() {
const m = meetings.selected;
if (m) meetings.generateSummary(m.id);
}
// ---- Active provider indicator + per-use quick switch (T10.3/M3.3) ----
// Same four providers Settings' AI section offers; switching here reuses
// the exact same set_llm_provider command (and hence the same "anthropic's
// endpoint is fixed" / "switching away resets a stale hosted endpoint"
// guarantees — see apply_llm_provider_args in commands.rs).
// Provider dropdown values. Labels resolve through `providerLabel` (a
// function, not a static map, so a language switch re-renders them);
// brand names stay literal, only the generic "Off"/"Custom" are translated.
const PROVIDER_IDS = ["off", "ollama", "custom", "anthropic"];
function providerLabel(id: string): string {
switch (id) {
case "off":
return t("summary.provider_off");
case "custom":
return t("summary.provider_custom");
case "ollama":
return "Ollama";
case "anthropic":
return "Anthropic (Claude)";
default:
return id;
}
}
function isHostedStatus(status: LlmStatus | null): boolean {
return !!status && status.provider !== "off" && !status.isLocal;
}
let switchingProvider = $state(false);
// ---- Hosted-AI "leaves your device" one-time gate (T10.3/M3.3) — shared
// by the quick switch (switching TO Anthropic) and Generate/Regenerate
// (the actual point a transcript would leave the device, so this is the
// gate that matters even if the quick switch's own check is skipped, e.g.
// "custom" resolving to a hosted endpoint that was configured elsewhere).
let showHostedBanner = $state(false);
let hostedBannerLabel = $state(t("summary.this_hosted_provider"));
let pendingHostedAction = $state<(() => void) | null>(null);
function requireHostedAck(providerLabel: string, action: () => void) {
if (!settings.settings.hosted_ai_acknowledged) {
hostedBannerLabel = providerLabel;
pendingHostedAction = action;
showHostedBanner = true;
return;
}
action();
}
async function acceptHostedBanner() {
await settings.acknowledgeHostedAi();
showHostedBanner = false;
const action = pendingHostedAction;
pendingHostedAction = null;
action?.();
}
function cancelHostedBanner() {
showHostedBanner = false;
pendingHostedAction = null;
}
async function switchProvider(provider: string) {
switchingProvider = true;
try {
await settings.setLlmProvider({ provider });
await refreshLlmStatus();
} finally {
switchingProvider = false;
}
}
function onProviderChange(e: Event) {
const next = (e.target as HTMLSelectElement).value;
if (next === "anthropic") {
// Deterministically hosted — worth confirming before even switching to
// it, not just before the next generate.
requireHostedAck(providerLabel(next), () => void switchProvider(next));
} else {
void switchProvider(next);
}
}
function onGenerateClick() {
const label = llmStatus ? providerLabel(llmStatus.provider) : "";
if (isHostedStatus(llmStatus)) {
requireHostedAck(label, generateSummary);
} else {
generateSummary();
}
}
// Editable working copy so add/delete/edit don't persist until "Save" is
// pressed. Resynced from the meeting's table-backed action_items whenever a
// *different* meeting is selected (same guard pattern as pendingTags), so
// it isn't clobbered mid-edit.
let editableItems = $state<ActionItem[]>([]);
let loadedItemsForId: string | null = null;
$effect(() => {
const m = meetings.selected;
if (m && m.id !== loadedItemsForId) {
editableItems = m.action_items.map((i) => ({ ...i }));
loadedItemsForId = m.id;
} else if (!m) {
loadedItemsForId = null;
}
});
function addActionItem() {
editableItems = [
...editableItems,
{ id: null, text: "", owner: null, due_at: null, confirmed: false, reminder_set: false },
];
}
function removeActionItem(i: number) {
editableItems = editableItems.filter((_, idx) => idx !== i);
}
let savingItems = $state(false);
async function saveActionItems() {
const m = meetings.selected;
if (!m) return;
savingItems = true;
try {
// Drop blank rows (an added-but-never-typed item) rather than persisting
// empty tasks; keep null owner rather than "".
const items = editableItems
.filter((i) => i.text.trim())
.map((i) => ({ ...i, owner: i.owner?.trim() || null }));
await meetings.confirmActionItems(m.id, items);
// confirmActionItems reloaded the meeting — resync so freshly inserted
// rows carry their new ids (a second save would otherwise re-insert them).
editableItems = (meetings.selected?.action_items ?? []).map((i) => ({ ...i }));
} finally {
savingItems = false;
}
}
// Due date + reminder (T8.6, FR-CAL-5) — a reminder needs a due date to
// schedule against, so the checkbox is disabled until one is set.
function dueDateInput(secs: number | null): string {
if (!secs) return "";
return new Date(secs * 1000).toISOString().slice(0, 10);
}
function onDueDateChange(item: ActionItem, value: string) {
item.due_at = value ? Math.floor(new Date(value).getTime() / 1000) : null;
if (!item.due_at) item.reminder_set = false;
}
let eventDetail = $state<CalendarEventDetail | null>(null);
$effect(() => {
const eventId = meetings.selected?.calendar_event_id;
if (!eventId) {
eventDetail = null;
return;
}
api
.getCalendarEvent(eventId)
.then((d) => (eventDetail = d))
.catch(() => (eventDetail = null));
});
function formatEventTime(unixSecs: number | null): string {
if (!unixSecs) return "";
return new Date(unixSecs * 1000).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
async function onPickEvent(e: Event) {
const m = meetings.selected;
const eventId = (e.target as HTMLSelectElement).value;
if (!m || !eventId) return;
await meetings.attachEvent(m.id, eventId);
}
// ---- Search/date filter for the event picker (a real mailbox import can
// be thousands of events) — defaults to the recording's own date since
// that's almost always the event being linked. ----
let eventLinkSearch = $state("");
let eventLinkDateFilter = $state("");
$effect(() => {
const m = meetings.selected;
eventLinkDateFilter = m ? eventLocalYmd(m.started_at) : "";
eventLinkSearch = "";
});
function eventLocalYmd(unixSecs: number | null): string {
if (!unixSecs) return "";
const d = new Date(unixSecs * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let filteredLinkEvents = $derived(
calendar.events.filter((ev) => {
if (eventLinkDateFilter && eventLocalYmd(ev.starts_at) !== eventLinkDateFilter) return false;
if (
eventLinkSearch &&
!(ev.subject ?? "").toLowerCase().includes(eventLinkSearch.toLowerCase())
)
return false;
return true;
}),
);
// ---- Attendee-aware speaker naming (T6.5, FR-SPK-4) ----
const NEW_NAME = "__new__";
let addingNameFor = $state<string | null>(null);
let newNameDraft = $state("");
async function onPickSpeakerOption(e: Event, label: string) {
const m = meetings.selected;
const value = (e.target as HTMLSelectElement).value;
if (!m || !value) return;
if (value === NEW_NAME) {
addingNameFor = label;
newNameDraft = "";
return;
}
await meetings.mapSpeaker(m.id, label, value);
}
async function confirmNewName(label: string) {
const m = meetings.selected;
const name = newNameDraft.trim();
if (!m || !name) return;
await meetings.renameSpeaker(m.id, label, name);
addingNameFor = null;
newNameDraft = "";
}
// ---- Tags (T8.3, FR-SEARCH-2) ----
// pendingTags is the editable working copy — resynced from
// meetings.selected.tags whenever a *different* meeting is selected, same
// guard pattern as notesText in TranscriptNotes.svelte, so it isn't
// clobbered by other reactivity while the user is mid-edit.
let pendingTags = $state<string[]>([]);
let tagDraft = $state("");
let loadedTagsForId: string | null = null;
$effect(() => {
const m = meetings.selected;
if (m && m.id !== loadedTagsForId) {
pendingTags = [...m.tags];
tagDraft = "";
loadedTagsForId = m.id;
} else if (!m) {
loadedTagsForId = null;
}
});
function addTag(raw: string) {
const tag = raw.trim().toLowerCase();
if (tag && !pendingTags.includes(tag)) pendingTags.push(tag);
}
// GitHub-topics-style input: a comma commits everything before it as its
// own chip immediately, leaving whatever's after as the live draft.
function onTagInput() {
if (!tagDraft.includes(",")) return;
const parts = tagDraft.split(",");
tagDraft = parts.pop() ?? "";
parts.forEach(addTag);
}
function onTagInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
commitDraft();
}
}
function commitDraft() {
if (tagDraft.trim()) addTag(tagDraft);
tagDraft = "";
}
function removeTag(tag: string) {
pendingTags = pendingTags.filter((x) => x !== tag);
}
let savingTags = $state(false);
async function saveTags() {
const m = meetings.selected;
if (!m) return;
commitDraft();
savingTags = true;
try {
await meetings.setTags(m.id, pendingTags);
} finally {
savingTags = false;
}
}
let generatingTags = $state(false);
let tagGenError = $state<string | null>(null);
async function generateTagsNow() {
const m = meetings.selected;
if (!m) return;
generatingTags = true;
tagGenError = null;
try {
const suggested = await api.generateTags(m.id);
suggested.forEach(addTag);
} catch (e) {
tagGenError = errorMessage(e);
} finally {
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">
{#if meetings.selected?.recorded}
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.recording_heading")}</h3>
{#if audioSrc}
<!-- a meeting recording has no caption track — no svelte-ignore needed,
the current eslint-plugin-svelte doesn't flag this element. -->
<!-- controlsList/contextmenu: no download affordance — the decrypted audio
must not be savable to disk (would undermine encryption at rest). -->
<audio
class="player"
controls
controlsList="nodownload noplaybackrate"
oncontextmenu={(e) => e.preventDefault()}
bind:this={audioEl}
ontimeupdate={() => audioEl && (player.currentMs = audioEl.currentTime * 1000)}
src={audioSrc}
></audio>
{:else if audioError}
<p class="muted">{audioError}</p>
{:else}
<p class="muted">{t("summary.loading_recording")}</p>
{/if}
{/if}
{#if meetings.selected && settings.settings.sync_enabled}
<h3><UploadCloud size={14} aria-hidden="true" /> {t("summary.sync_heading")}</h3>
<button
class="primary upload-now"
disabled={meetings.uploading}
onclick={() => {
const m = meetings.selected;
if (m) meetings.uploadNow(m.id);
}}
>
<UploadCloud size={14} aria-hidden="true" />
{meetings.uploading ? t("summary.uploading") : t("summary.upload_now")}
</button>
{#if meetings.syncJobs.length}
<ul class="jobs">
{#each meetings.syncJobs as job (job.jobId)}
<li>
<span class="artifact">{job.artifact}</span>
<span class="job-status {job.status}">{job.status}</span>
{#if job.status === "uploading" && job.bytesTotal}
<progress class="job-bar" max={job.bytesTotal} value={job.bytesSent}></progress>
<span class="muted">{Math.round((100 * job.bytesSent) / job.bytesTotal)}%</span>
{/if}
{#if job.status === "failed"}
{#if job.error}<span class="err" title={job.error}>⚠</span>{/if}
<button
class="link"
onclick={() => {
const m = meetings.selected;
if (m) meetings.retryJob(job.jobId, m.id);
}}>{t("summary.retry")}</button
>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="muted">{t("summary.no_uploads")}</p>
{/if}
{/if}
<h3><Tags size={14} aria-hidden="true" /> {t("summary.tags_heading")}</h3>
{#if !meetings.selected}
<p class="muted">{t("summary.tags_select")}</p>
{:else}
<div class="tag-editor">
{#each pendingTags as tag (tag)}
<TagChip
{tag}
removable
onRemove={() => removeTag(tag)}
onClick={() => meetings.filterByTag(tag)}
/>
{/each}
<input
class="tag-input"
list="known-tags"
placeholder={pendingTags.length
? t("summary.tag_add_placeholder")
: t("summary.tag_first_placeholder")}
bind:value={tagDraft}
oninput={onTagInput}
onkeydown={onTagInputKeydown}
onblur={commitDraft}
/>
</div>
<datalist id="known-tags">
{#each meetings.allTags as tag (tag)}
<option value={tag}></option>
{/each}
</datalist>
<div class="actions">
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
<Sparkles size={14} aria-hidden="true" />
{generatingTags ? t("summary.generating") : t("summary.generate_tags")}
</button>
<button class="link" onclick={saveTags} disabled={savingTags}>
{savingTags ? t("summary.saving") : t("summary.save_tags")}
</button>
</div>
{#if tagGenError}
<p class="error">{tagGenError}</p>
{/if}
{/if}
<h3><Sparkles size={14} aria-hidden="true" /> {t("summary.summary_heading")}</h3>
<div class="provider-row">
<label class="provider-select">
<span class="sr-only">{t("summary.ai_provider")}</span>
<select
value={llmStatus?.provider ?? settings.settings.llm_provider}
onchange={onProviderChange}
disabled={switchingProvider}
>
{#each PROVIDER_IDS as id (id)}
<option value={id}>{providerLabel(id)}</option>
{/each}
</select>
</label>
{#if llmStatus && llmStatus.provider !== "off"}
<span class="provider-badge" class:hosted={!llmStatus.isLocal}>
{#if llmStatus.isLocal}<Cpu size={12} aria-hidden="true" />{:else}<Globe
size={12}
aria-hidden="true"
/>{/if}
{llmStatus.isLocal ? t("summary.local") : t("summary.leaves_device")}
</span>
{/if}
</div>
{#if showHostedBanner}
<HostedAiBanner
providerLabel={hostedBannerLabel}
onAccept={acceptHostedBanner}
onCancel={cancelHostedBanner}
/>
{/if}
{#if !meetings.selected}
<p class="muted">{t("summary.select_generate")}</p>
{:else if meetings.summarizingId === meetings.selected.id}
<p class="muted">{t("summary.generating")}</p>
{#if meetings.summaryStream}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
<div class="summary-md">{@html renderMarkdown(meetings.summaryStream)}</div>
{/if}
{:else if meetings.selected.summary}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
<div class="summary-md">{@html renderMarkdown(meetings.selected.summary.summary_md)}</div>
{#if meetings.selected.summary.decisions.length > 0}
<h4>{t("summary.decisions_heading")}</h4>
<ul class="decisions">
{#each meetings.selected.summary.decisions as d, i (i)}
<li>{d}</li>
{/each}
</ul>
{/if}
<button class="link" onclick={onGenerateClick}>{t("summary.regenerate")}</button>
{:else}
<p class="muted">{t("summary.generated_hint")}</p>
{#if llmStatus && llmStatus.provider === "off"}
<p class="muted small">{t("summary.no_provider")}</p>
{:else}
<button class="primary" onclick={onGenerateClick}>
<Sparkles size={14} aria-hidden="true" />
{t("summary.generate_summary")}
</button>
{/if}
{/if}
{#if meetings.summaryError}
<p class="error">{meetings.summaryError}</p>
{/if}
<h3><FileText size={14} aria-hidden="true" /> {t("summary.briefs_heading")}</h3>
{#if !meetings.selected}
<p class="muted">{t("summary.briefs_select")}</p>
{:else}
<p class="muted small">{t("summary.briefs_desc")}</p>
<div class="brief-create">
<input
type="text"
placeholder={t("summary.brief_repo_placeholder")}
bind:value={briefTargetRepo}
disabled={creatingBrief}
/>
<button class="primary" onclick={createBrief} disabled={creatingBrief}>
<Sparkles size={14} aria-hidden="true" />
{creatingBrief ? t("summary.distilling") : t("summary.create_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={t("summary.brief_mcp_title")}>
<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" /> {t("summary.copied")}
{:else}
<Copy size={13} aria-hidden="true" /> {t("summary.copy_md")}
{/if}
</button>
<button class="link" onclick={copyBriefJson}>
{#if copiedFormat === "json"}
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
{:else}
<Copy size={13} aria-hidden="true" /> {t("summary.copy_json")}
{/if}
</button>
</div>
<h4>{t("summary.brief_problem")}</h4>
<p class="brief-text">{selectedBrief.problem}</p>
<h4>{t("summary.brief_outcome")}</h4>
<p class="brief-text">{selectedBrief.desired_outcome}</p>
<h4>{t("summary.brief_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">{t("summary.brief_none")}</p>
{/if}
{#if selectedBrief.context_excerpts.length}
<h4>{t("summary.brief_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" /> {t("summary.action_items_heading")}</h3>
{#if !meetings.selected}
<p class="muted">{t("summary.ai_select")}</p>
{:else}
{#if editableItems.length === 0}
<p class="muted">{t("summary.ai_empty")}</p>
{:else}
<!-- Two-row card per item: the action text owns the full first row (it
was unreadable when six controls shared one row in this narrow
pane); owner/due/reminder are a secondary meta row beneath it. -->
<ul class="action-items">
{#each editableItems as item, i (i)}
<li>
<div class="ai-main">
<input
type="checkbox"
bind:checked={item.confirmed}
aria-label={t("summary.confirmed")}
title={t("summary.confirmed")}
/>
<input
class="ai-text"
bind:value={item.text}
placeholder={t("summary.ai_text_placeholder")}
aria-label={t("summary.ai_text_aria")}
/>
<button
class="ai-del"
onclick={() => removeActionItem(i)}
title={t("summary.ai_delete_title")}
aria-label={t("summary.ai_delete_aria")}
>
<X size={14} aria-hidden="true" />
</button>
</div>
<div class="ai-meta">
<input
class="ai-owner"
value={item.owner ?? ""}
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
placeholder={t("summary.owner")}
aria-label={t("summary.owner")}
/>
<input
type="date"
class="due-date"
aria-label={t("summary.due_date")}
value={dueDateInput(item.due_at)}
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
/>
<label class="remind" title={t("summary.reminder_title")}>
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
<Bell size={14} aria-hidden="true" />
</label>
</div>
</li>
{/each}
</ul>
{/if}
<div class="ai-actions">
<button class="link" onclick={addActionItem}>
<Plus size={14} aria-hidden="true" />
{t("summary.add_action_item")}
</button>
<button class="primary" onclick={saveActionItems} disabled={savingItems}>
<Check size={14} aria-hidden="true" />
{savingItems ? t("summary.saving") : t("summary.save_action_items")}
</button>
</div>
{/if}
<h3><CalendarDays size={14} aria-hidden="true" /> {t("summary.calendar_heading")}</h3>
{#if !meetings.selected}
<p class="muted">{t("summary.cal_select")}</p>
{:else}
<div class="row">
<input
type="text"
bind:value={eventLinkSearch}
placeholder={t("summary.event_search_placeholder")}
/>
<input type="date" bind:value={eventLinkDateFilter} />
</div>
<label class="pick"
>{t("summary.linked_event")}
<select value={meetings.selected.calendar_event_id ?? ""} onchange={onPickEvent}>
<option value="" disabled
>{eventDetail ? t("summary.change_event") : t("summary.link_event")}</option
>
{#each filteredLinkEvents as ev (ev.id)}
<option value={ev.id}
>{ev.subject ?? t("summary.untitled_event")} — {formatEventTime(ev.starts_at)}</option
>
{/each}
</select>
</label>
{#if calendar.events.length === 0}
<p class="muted small">
{t("summary.no_events_1")} <code>.pst</code>
{t("summary.no_events_2")}
</p>
{:else if filteredLinkEvents.length === 0}
<p class="muted small">{t("summary.no_events_match")}</p>
{/if}
{#if eventDetail}
<div class="event-card">
<div class="event-title">{eventDetail.event.subject ?? t("summary.untitled_event")}</div>
{#if eventDetail.event.organizer}
<div class="muted small">
{t("summary.organizer_label", { name: eventDetail.event.organizer })}
</div>
{/if}
{#if eventDetail.event.starts_at}
<div class="muted small">{formatEventTime(eventDetail.event.starts_at)}</div>
{/if}
</div>
{/if}
{/if}
<h3><Users size={14} aria-hidden="true" /> {t("summary.participants_heading")}</h3>
{#if !eventDetail || eventDetail.participants.length === 0}
<p class="muted">{t("summary.participants_hint")}</p>
{:else}
<ul class="participants">
{#each eventDetail.participants as p (p.id)}
<li>
<span class="name">{p.name}</span>
{#if p.role}<span class="muted small">{p.role}</span>{/if}
</li>
{/each}
</ul>
{/if}
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.speakers_heading")}</h3>
{#if !meetings.selected}
<p class="muted">{t("summary.speakers_select")}</p>
{:else if meetings.selected.speakers.length === 0}
<p class="muted">{t("summary.no_speakers")}</p>
{:else}
<ul class="speakers">
{#each meetings.selected.speakers as s (s.label)}
<li>
<span class="label">{s.label}</span>
{#if addingNameFor === s.label}
<input
class="grow"
placeholder={t("summary.speaker_name_placeholder")}
bind:value={newNameDraft}
onkeydown={(e) => e.key === "Enter" && confirmNewName(s.label)}
/>
<button class="link" onclick={() => confirmNewName(s.label)}>{t("summary.save")}</button
>
<button class="link" onclick={() => (addingNameFor = null)}
>{t("summary.cancel")}</button
>
{:else}
<select
class="grow"
value={s.participant_id ?? ""}
onchange={(e) => onPickSpeakerOption(e, s.label)}
>
<option value="" disabled>{s.display_name ?? t("summary.name_speaker")}</option>
{#each eventDetail?.participants ?? [] as p (p.id)}
<option value={p.id}>{p.name}</option>
{/each}
<option value={NEW_NAME}>{t("summary.add_new_name")}</option>
</select>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<style>
.wrap {
padding: 0.75rem;
}
h3 {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
margin: 1.1rem 0 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
h3:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.provider-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: -0.3rem 0 0.6rem;
}
.provider-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
color: var(--muted);
padding: 0.15rem 0.5rem;
border-radius: var(--radius-full, 999px);
border: 1px solid var(--border);
}
.provider-badge.hosted {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
}
button.primary {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--accent);
color: var(--accent-fg);
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0.4rem 0.7rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
}
button.primary:disabled {
opacity: 0.5;
cursor: default;
}
.upload-now {
align-self: start;
}
.provider-select select {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--fg);
font-size: 0.78rem;
padding: 0.3rem 0.5rem;
cursor: pointer;
}
.provider-select select:hover:not(:disabled) {
background: var(--bg-hover);
}
.provider-select select:disabled {
opacity: 0.6;
cursor: default;
}
.muted {
color: var(--muted);
font-size: 0.85rem;
margin: 0;
}
.tag-editor {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0;
}
.tag-input {
flex: 1;
min-width: 6rem;
border: none;
background: none;
padding: 0.2rem 0;
font-size: 0.8rem;
}
.tag-input:focus {
outline: none;
}
.actions {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.4rem;
}
.muted.small {
font-size: 0.78rem;
}
h4 {
font-size: 0.82rem;
margin: 0.5rem 0 0.2rem;
color: var(--muted);
}
.summary-md {
font-size: 0.85rem;
line-height: 1.5;
}
.summary-md :global(p) {
margin: 0.3rem 0;
}
ul.decisions,
ul.action-items {
list-style: none;
padding: 0;
margin: 0.2rem 0;
font-size: 0.85rem;
}
ul.decisions li {
padding: 0.15rem 0;
}
ul.action-items li {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.45rem 0;
border-bottom: 1px solid var(--border);
}
ul.action-items label {
display: flex;
align-items: center;
gap: 0.4rem;
}
.ai-main {
display: flex;
align-items: center;
gap: 0.35rem;
}
.ai-text {
flex: 1;
min-width: 0;
font-size: 0.85rem;
}
/* Meta row indented under the text (past the confirm checkbox), wrapping
rather than crushing its inputs when the pane is narrow. */
.ai-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
padding-left: 1.4rem;
}
.ai-owner {
flex: 1;
min-width: 5rem;
font-size: 0.75rem;
}
.ai-del {
display: flex;
align-items: center;
flex: none;
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 0.15rem;
border-radius: var(--radius-sm);
}
.ai-del:hover {
color: var(--danger);
background: var(--bg-hover);
}
.ai-actions {
display: flex;
align-items: center;
gap: 0.8rem;
margin-top: 0.5rem;
}
.ai-actions .link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.due-date {
font-size: 0.75rem;
padding: 0.2rem;
max-width: 8.5rem;
border-radius: var(--radius-sm);
}
.remind {
display: flex;
align-items: center;
color: var(--fg-subtle);
}
.remind:has(input:checked) {
color: var(--warning);
}
.error {
color: var(--danger);
font-size: 0.85rem;
margin: 0.3rem 0;
}
.pick {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.8rem;
margin: 0.3rem 0;
}
select,
input {
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 5px;
padding: 0.3rem;
font: inherit;
}
.event-card {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.6rem;
margin: 0.4rem 0;
}
.event-title {
font-weight: 600;
font-size: 0.85rem;
}
ul.participants,
ul.speakers {
list-style: none;
padding: 0;
margin: 0.3rem 0;
}
ul.participants li {
display: flex;
justify-content: space-between;
gap: 0.4rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
}
ul.speakers li {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border);
}
ul.speakers .label {
font-size: 0.8rem;
color: var(--muted);
min-width: 2.5rem;
}
.grow {
flex: 1;
min-width: 0;
}
.link {
background: none;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 0.8rem;
padding: 0;
}
.jobs {
list-style: none;
padding: 0;
margin: 0.4rem 0 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.jobs li {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
}
.artifact {
min-width: 5rem;
}
.job-bar {
flex: 1;
height: 0.4rem;
min-width: 3rem;
border: none;
border-radius: var(--radius-full);
overflow: hidden;
accent-color: var(--accent, #2563eb);
}
.job-bar::-webkit-progress-bar {
background: var(--bg-hover);
border-radius: var(--radius-full);
}
.job-bar::-webkit-progress-value {
background: var(--accent, #2563eb);
border-radius: var(--radius-full);
}
.player {
width: 100%;
margin: 0.2rem 0 0.6rem;
}
.job-status {
text-transform: capitalize;
color: var(--muted);
}
.job-status.done {
color: var(--success, #16a34a);
}
.job-status.uploading {
color: var(--accent, #2563eb);
}
.job-status.failed {
color: var(--danger, #dc2626);
}
.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>