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.
1380 lines
40 KiB
Svelte
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>
|