Feature chore bug 007 #21

Merged
iamdoubz merged 68 commits from feature_chore_bug_007 into main 2026-07-12 12:59:04 -05:00
Showing only changes of commit 88969019b3 - Show all commits
+120 -97
View File
@@ -15,6 +15,7 @@
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 {
@@ -91,12 +92,24 @@
// 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).
const PROVIDER_LABELS: Record<string, string> = {
off: "Off",
ollama: "Ollama",
custom: "Custom",
anthropic: "Anthropic (Claude)",
};
// 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;
}
@@ -108,7 +121,7 @@
// 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("this hosted provider");
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) {
@@ -145,13 +158,13 @@
if (next === "anthropic") {
// Deterministically hosted — worth confirming before even switching to
// it, not just before the next generate.
requireHostedAck(PROVIDER_LABELS[next], () => void switchProvider(next));
requireHostedAck(providerLabel(next), () => void switchProvider(next));
} else {
void switchProvider(next);
}
}
function onGenerateClick() {
const label = llmStatus ? (PROVIDER_LABELS[llmStatus.provider] ?? llmStatus.provider) : "";
const label = llmStatus ? providerLabel(llmStatus.provider) : "";
if (isHostedStatus(llmStatus)) {
requireHostedAck(label, generateSummary);
} else {
@@ -321,8 +334,8 @@
});
function addTag(raw: string) {
const t = raw.trim().toLowerCase();
if (t && !pendingTags.includes(t)) pendingTags.push(t);
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.
@@ -342,8 +355,8 @@
if (tagDraft.trim()) addTag(tagDraft);
tagDraft = "";
}
function removeTag(t: string) {
pendingTags = pendingTags.filter((x) => x !== t);
function removeTag(tag: string) {
pendingTags = pendingTags.filter((x) => x !== tag);
}
let savingTags = $state(false);
@@ -493,7 +506,7 @@
<div class="wrap">
{#if meetings.selected?.recorded}
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
<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. -->
@@ -511,12 +524,12 @@
{:else if audioError}
<p class="muted">{audioError}</p>
{:else}
<p class="muted">Loading recording…</p>
<p class="muted">{t("summary.loading_recording")}</p>
{/if}
{/if}
{#if meetings.selected && settings.settings.sync_enabled}
<h3><UploadCloud size={14} aria-hidden="true" /> Sync</h3>
<h3><UploadCloud size={14} aria-hidden="true" /> {t("summary.sync_heading")}</h3>
<button
class="primary upload-now"
disabled={meetings.uploading}
@@ -526,7 +539,7 @@
}}
>
<UploadCloud size={14} aria-hidden="true" />
{meetings.uploading ? "Uploading…" : "Upload now"}
{meetings.uploading ? t("summary.uploading") : t("summary.upload_now")}
</button>
{#if meetings.syncJobs.length}
<ul class="jobs">
@@ -545,34 +558,36 @@
onclick={() => {
const m = meetings.selected;
if (m) meetings.retryJob(job.jobId, m.id);
}}>retry</button
}}>{t("summary.retry")}</button
>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="muted">No uploads yet for this meeting.</p>
<p class="muted">{t("summary.no_uploads")}</p>
{/if}
{/if}
<h3><Tags size={14} aria-hidden="true" /> Tags</h3>
<h3><Tags size={14} aria-hidden="true" /> {t("summary.tags_heading")}</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to tag it.</p>
<p class="muted">{t("summary.tags_select")}</p>
{:else}
<div class="tag-editor">
{#each pendingTags as t (t)}
{#each pendingTags as tag (tag)}
<TagChip
tag={t}
{tag}
removable
onRemove={() => removeTag(t)}
onClick={() => meetings.filterByTag(t)}
onRemove={() => removeTag(tag)}
onClick={() => meetings.filterByTag(tag)}
/>
{/each}
<input
class="tag-input"
list="known-tags"
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
placeholder={pendingTags.length
? t("summary.tag_add_placeholder")
: t("summary.tag_first_placeholder")}
bind:value={tagDraft}
oninput={onTagInput}
onkeydown={onTagInputKeydown}
@@ -580,17 +595,17 @@
/>
</div>
<datalist id="known-tags">
{#each meetings.allTags as t (t)}
<option value={t}></option>
{#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 ? "Generating…" : "Generate tags"}
{generatingTags ? t("summary.generating") : t("summary.generate_tags")}
</button>
<button class="link" onclick={saveTags} disabled={savingTags}>
{savingTags ? "Saving…" : "Save tags"}
{savingTags ? t("summary.saving") : t("summary.save_tags")}
</button>
</div>
{#if tagGenError}
@@ -598,18 +613,18 @@
{/if}
{/if}
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
<h3><Sparkles size={14} aria-hidden="true" /> {t("summary.summary_heading")}</h3>
<div class="provider-row">
<label class="provider-select">
<span class="sr-only">AI provider</span>
<span class="sr-only">{t("summary.ai_provider")}</span>
<select
value={llmStatus?.provider ?? settings.settings.llm_provider}
onchange={onProviderChange}
disabled={switchingProvider}
>
{#each Object.entries(PROVIDER_LABELS) as [id, label] (id)}
<option value={id}>{label}</option>
{#each PROVIDER_IDS as id (id)}
<option value={id}>{providerLabel(id)}</option>
{/each}
</select>
</label>
@@ -619,7 +634,7 @@
size={12}
aria-hidden="true"
/>{/if}
{llmStatus.isLocal ? "local" : "leaves this device"}
{llmStatus.isLocal ? t("summary.local") : t("summary.leaves_device")}
</span>
{/if}
</div>
@@ -632,9 +647,9 @@
{/if}
{#if !meetings.selected}
<p class="muted">Select a meeting to generate a summary.</p>
<p class="muted">{t("summary.select_generate")}</p>
{:else if meetings.summarizingId === meetings.selected.id}
<p class="muted">Generating…</p>
<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>
@@ -643,22 +658,22 @@
<!-- 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>Decisions</h4>
<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}>Regenerate</button>
<button class="link" onclick={onGenerateClick}>{t("summary.regenerate")}</button>
{:else}
<p class="muted">Generated locally after the meeting (requires a local LLM provider).</p>
<p class="muted">{t("summary.generated_hint")}</p>
{#if llmStatus && llmStatus.provider === "off"}
<p class="muted small">No AI provider configured — enable one in Settings.</p>
<p class="muted small">{t("summary.no_provider")}</p>
{:else}
<button class="primary" onclick={onGenerateClick}>
<Sparkles size={14} aria-hidden="true" />
Generate summary
{t("summary.generate_summary")}
</button>
{/if}
{/if}
@@ -666,24 +681,21 @@
<p class="error">{meetings.summaryError}</p>
{/if}
<h3><FileText size={14} aria-hidden="true" /> Feature briefs</h3>
<h3><FileText size={14} aria-hidden="true" /> {t("summary.briefs_heading")}</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to create or view feature briefs.</p>
<p class="muted">{t("summary.briefs_select")}</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>
<p class="muted small">{t("summary.briefs_desc")}</p>
<div class="brief-create">
<input
type="text"
placeholder="Target repo (optional), e.g. acme/reporting-web"
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 ? "Distilling…" : "Create feature brief"}
{creatingBrief ? t("summary.distilling") : t("summary.create_brief")}
</button>
</div>
{#if briefError}
@@ -698,7 +710,7 @@
{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">
<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>
@@ -712,24 +724,24 @@
<div class="brief-actions">
<button class="link" onclick={copyBriefMarkdown}>
{#if copiedFormat === "md"}
<Check size={13} aria-hidden="true" /> Copied
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
{:else}
<Copy size={13} aria-hidden="true" /> Copy as Markdown
<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" /> Copied
<Check size={13} aria-hidden="true" /> {t("summary.copied")}
{:else}
<Copy size={13} aria-hidden="true" /> Copy as JSON
<Copy size={13} aria-hidden="true" /> {t("summary.copy_json")}
{/if}
</button>
</div>
<h4>Problem</h4>
<h4>{t("summary.brief_problem")}</h4>
<p class="brief-text">{selectedBrief.problem}</p>
<h4>Desired outcome</h4>
<h4>{t("summary.brief_outcome")}</h4>
<p class="brief-text">{selectedBrief.desired_outcome}</p>
<h4>Acceptance criteria</h4>
<h4>{t("summary.brief_criteria")}</h4>
{#if selectedBrief.acceptance_criteria.length}
<ul class="decisions">
{#each selectedBrief.acceptance_criteria as c, i (i)}
@@ -737,10 +749,10 @@
{/each}
</ul>
{:else}
<p class="muted small">None captured.</p>
<p class="muted small">{t("summary.brief_none")}</p>
{/if}
{#if selectedBrief.context_excerpts.length}
<h4>Context</h4>
<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>
@@ -751,14 +763,12 @@
{/if}
{/if}
<h3><ListTodo size={14} aria-hidden="true" /> Action items</h3>
<h3><ListTodo size={14} aria-hidden="true" /> {t("summary.action_items_heading")}</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to see its action items.</p>
<p class="muted">{t("summary.ai_select")}</p>
{:else}
{#if editableItems.length === 0}
<p class="muted">
None yet — add one below, or generate a summary to extract them automatically.
</p>
<p class="muted">{t("summary.ai_empty")}</p>
{:else}
<ul class="action-items">
{#each editableItems as item, i (i)}
@@ -766,38 +776,38 @@
<input
type="checkbox"
bind:checked={item.confirmed}
aria-label="Confirmed"
title="Confirmed"
aria-label={t("summary.confirmed")}
title={t("summary.confirmed")}
/>
<input
class="ai-text"
bind:value={item.text}
placeholder="Action item…"
aria-label="Action item text"
placeholder={t("summary.ai_text_placeholder")}
aria-label={t("summary.ai_text_aria")}
/>
<input
class="ai-owner"
value={item.owner ?? ""}
oninput={(e) => (item.owner = (e.target as HTMLInputElement).value || null)}
placeholder="Owner"
aria-label="Owner"
placeholder={t("summary.owner")}
aria-label={t("summary.owner")}
/>
<input
type="date"
class="due-date"
aria-label="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="Schedule a local reminder for this due date">
<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>
<button
class="ai-del"
onclick={() => removeActionItem(i)}
title="Delete this action item"
aria-label="Delete action item"
title={t("summary.ai_delete_title")}
aria-label={t("summary.ai_delete_aria")}
>
<X size={14} aria-hidden="true" />
</button>
@@ -807,47 +817,57 @@
{/if}
<div class="ai-actions">
<button class="link" onclick={addActionItem}>
<Plus size={14} aria-hidden="true" /> Add action item
<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 ? "Saving…" : "Save action items"}
{savingItems ? t("summary.saving") : t("summary.save_action_items")}
</button>
</div>
{/if}
<h3><CalendarDays size={14} aria-hidden="true" /> Calendar event</h3>
<h3><CalendarDays size={14} aria-hidden="true" /> {t("summary.calendar_heading")}</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to link it to a calendar event.</p>
<p class="muted">{t("summary.cal_select")}</p>
{:else}
<div class="row">
<input type="text" bind:value={eventLinkSearch} placeholder="Search event title…" />
<input
type="text"
bind:value={eventLinkSearch}
placeholder={t("summary.event_search_placeholder")}
/>
<input type="date" bind:value={eventLinkDateFilter} />
</div>
<label class="pick"
>Linked event
>{t("summary.linked_event")}
<select value={meetings.selected.calendar_event_id ?? ""} onchange={onPickEvent}>
<option value="" disabled>{eventDetail ? "Change event…" : "Link an event…"}</option>
<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 ?? "(untitled)"} — {formatEventTime(ev.starts_at)}</option
>{ev.subject ?? t("summary.untitled_event")} — {formatEventTime(ev.starts_at)}</option
>
{/each}
</select>
</label>
{#if calendar.events.length === 0}
<p class="muted small">
No events imported yet — import a <code>.pst</code> from Settings → Calendar.
{t("summary.no_events_1")} <code>.pst</code>
{t("summary.no_events_2")}
</p>
{:else if filteredLinkEvents.length === 0}
<p class="muted small">No events match this search/date — try clearing one.</p>
<p class="muted small">{t("summary.no_events_match")}</p>
{/if}
{#if eventDetail}
<div class="event-card">
<div class="event-title">{eventDetail.event.subject ?? "(untitled)"}</div>
<div class="event-title">{eventDetail.event.subject ?? t("summary.untitled_event")}</div>
{#if eventDetail.event.organizer}
<div class="muted small">Organizer: {eventDetail.event.organizer}</div>
<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>
@@ -856,9 +876,9 @@
{/if}
{/if}
<h3><Users size={14} aria-hidden="true" /> Participants</h3>
<h3><Users size={14} aria-hidden="true" /> {t("summary.participants_heading")}</h3>
{#if !eventDetail || eventDetail.participants.length === 0}
<p class="muted">Populated from the linked calendar event.</p>
<p class="muted">{t("summary.participants_hint")}</p>
{:else}
<ul class="participants">
{#each eventDetail.participants as p (p.id)}
@@ -870,11 +890,11 @@
</ul>
{/if}
<h3><Mic2 size={14} aria-hidden="true" /> Speakers</h3>
<h3><Mic2 size={14} aria-hidden="true" /> {t("summary.speakers_heading")}</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to name its speakers.</p>
<p class="muted">{t("summary.speakers_select")}</p>
{:else if meetings.selected.speakers.length === 0}
<p class="muted">No speakers detected yet.</p>
<p class="muted">{t("summary.no_speakers")}</p>
{:else}
<ul class="speakers">
{#each meetings.selected.speakers as s (s.label)}
@@ -883,23 +903,26 @@
{#if addingNameFor === s.label}
<input
class="grow"
placeholder="Speaker name"
placeholder={t("summary.speaker_name_placeholder")}
bind:value={newNameDraft}
onkeydown={(e) => e.key === "Enter" && confirmNewName(s.label)}
/>
<button class="link" onclick={() => confirmNewName(s.label)}>Save</button>
<button class="link" onclick={() => (addingNameFor = null)}>Cancel</button>
<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 ?? "Name this speaker…"}</option>
<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}>+ Add new name…</option>
<option value={NEW_NAME}>{t("summary.add_new_name")}</option>
</select>
{/if}
</li>