diff --git a/src/lib/views/SummaryPanel.svelte b/src/lib/views/SummaryPanel.svelte index b8eb7de..576e572 100644 --- a/src/lib/views/SummaryPanel.svelte +++ b/src/lib/views/SummaryPanel.svelte @@ -4,8 +4,15 @@ import { meetings } from "../stores/meetings.svelte"; import { calendar } from "../stores/calendar.svelte"; import { settings } from "../stores/settings.svelte"; - import { api, type ActionItem, type CalendarEventDetail, type LlmStatus } from "../api"; + import { + api, + errorMessage, + type ActionItem, + type CalendarEventDetail, + type LlmStatus, + } from "../api"; import { renderMarkdown } from "../markdown"; + import TagChip from "../components/TagChip.svelte"; import { Tags, Sparkles, @@ -150,24 +157,79 @@ } // ---- Tags (T8.3, FR-SEARCH-2) ---- - // Writable derived: reflects meetings.selected.tags, but typing (bind:value) - // locally overrides it until the selection changes again. - let tagsInput = $derived((meetings.selected?.tags ?? []).join(", ")); + // 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([]); + 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 t = raw.trim().toLowerCase(); + if (t && !pendingTags.includes(t)) pendingTags.push(t); + } + // 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(t: string) { + pendingTags = pendingTags.filter((x) => x !== t); + } + let savingTags = $state(false); async function saveTags() { const m = meetings.selected; if (!m) return; + commitDraft(); savingTags = true; try { - const tags = tagsInput - .split(",") - .map((t) => t.trim()) - .filter(Boolean); - await meetings.setTags(m.id, tags); + await meetings.setTags(m.id, pendingTags); } finally { savingTags = false; } } + + let generatingTags = $state(false); + let tagGenError = $state(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; + } + }
@@ -214,21 +276,42 @@ {#if !meetings.selected}

Select a meeting to tag it.

{:else} - e.key === "Enter" && saveTags()} - /> +
+ {#each pendingTags as t (t)} + removeTag(t)} + onClick={() => meetings.filterByTag(t)} + /> + {/each} + +
{#each meetings.allTags as t (t)} {/each} - +
+ + +
+ {#if tagGenError} +

{tagGenError}

+ {/if} {/if}

@@ -435,6 +518,30 @@ 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; }