feat(tags): Generate tags button + chip-based tag editor

Generate tags mirrors Generate summary: reads the transcript (the same
build_prompt assembly, non-streamed) and suggests 1-8 tags, merged
into the working tag list rather than replacing it outright so a
manually-added tag never silently disappears.

Replaces the old comma-separated text input with GitHub-topic-style
chips: typing a comma commits everything before it as its own chip
immediately, each chip is removable via its 'x', and clicking a chip's
label filters the meeting list to that tag (meetings.filterByTag).
This commit is contained in:
iamdoubz committed 2026-07-06 15:38:18 -05:00
1 parent cf4232cbae
commit 16fcb154f3
1 file changed
+126 -19
+126 -19
View File
@@ -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<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 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<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;
}
}
</script>
<div class="wrap">
@@ -214,21 +276,42 @@
{#if !meetings.selected}
<p class="muted">Select a meeting to tag it.</p>
{:else}
<input
class="grow"
list="known-tags"
placeholder="project, client, topic…"
bind:value={tagsInput}
onkeydown={(e) => e.key === "Enter" && saveTags()}
/>
<div class="tag-editor">
{#each pendingTags as t (t)}
<TagChip
tag={t}
removable
onRemove={() => removeTag(t)}
onClick={() => meetings.filterByTag(t)}
/>
{/each}
<input
class="tag-input"
list="known-tags"
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
bind:value={tagDraft}
oninput={onTagInput}
onkeydown={onTagInputKeydown}
onblur={commitDraft}
/>
</div>
<datalist id="known-tags">
{#each meetings.allTags as t (t)}
<option value={t}></option>
{/each}
</datalist>
<button class="link" onclick={saveTags} disabled={savingTags}>
{savingTags ? "Saving…" : "Save tags"}
</button>
<div class="actions">
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
<Sparkles size={14} aria-hidden="true" />
{generatingTags ? "Generating…" : "Generate tags"}
</button>
<button class="link" onclick={saveTags} disabled={savingTags}>
{savingTags ? "Saving…" : "Save tags"}
</button>
</div>
{#if tagGenError}
<p class="error">{tagGenError}</p>
{/if}
{/if}
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
@@ -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;
}